feat: 增加腾讯云双栈自动DDNS

This commit is contained in:
sunlei 2026-07-23 13:00:50 +08:00
parent b5041af7c6
commit ddc0e9580b
29 changed files with 4528 additions and 31 deletions

View File

@ -37,6 +37,13 @@ NETWORK_AGENT_MQTT_USERNAME=
NETWORK_AGENT_MQTT_PASSWORD= NETWORK_AGENT_MQTT_PASSWORD=
NETWORK_AGENT_MQTT_RETRY_MS=5000 NETWORK_AGENT_MQTT_RETRY_MS=5000
# Optional Tencent Cloud DNS DDNS; credentials must come from a private runtime Secret.
NETWORK_DDNS_DNSPOD_ENABLED=false
NETWORK_DDNS_DNSPOD_SECRET_ID=
NETWORK_DDNS_DNSPOD_SECRET_KEY=
NETWORK_DDNS_RECONCILE_INTERVAL_MS=60000
NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS=60000
LOG_LEVEL=info LOG_LEVEL=info
LOG_APP_NAME=kt-template-online-api LOG_APP_NAME=kt-template-online-api
LOG_PRETTY=true LOG_PRETTY=true

15
API.md
View File

@ -88,7 +88,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
当前版本只提供观测和只读自检。重启 Pod、触发 Jenkins 部署、执行迁移、重建 NapCat 容器、启停插件、立即执行插件任务、修改 Caddy/OpenClash/WireGuard/Tencent Cloud 等高风险能力只会以禁用动作展示,后端不提供通用写动作入口。 当前版本只提供观测和只读自检。重启 Pod、触发 Jenkins 部署、执行迁移、重建 NapCat 容器、启停插件、立即执行插件任务、修改 Caddy/OpenClash/WireGuard/Tencent Cloud 等高风险能力只会以禁用动作展示,后端不提供通用写动作入口。
## System 网络端口转发管理 ## System 网络管理
| 方法 | 路径 | 认证 | 说明 | | 方法 | 路径 | 认证 | 说明 |
| -------- | --------------------------------------------------- | ------- | ----------------------------------------- | | -------- | --------------------------------------------------- | ------- | ----------------------------------------- |
@ -103,6 +103,13 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
| `GET` | `/system/network/port-forward/:id/endpoint-history` | `super` | 查询端点状态变化历史 | | `GET` | `/system/network/port-forward/:id/endpoint-history` | `super` | 查询端点状态变化历史 |
| `GET` | `/system/network/agent/status` | `super` | 查询 Agent 在线与 revision 收敛状态 | | `GET` | `/system/network/agent/status` | `super` | 查询 Agent 在线与 revision 收敛状态 |
| `GET` | `/system/network/events/stream` | `super` | SSE 推送已提交的 MQTT 状态变化 | | `GET` | `/system/network/events/stream` | `super` | SSE 推送已提交的 MQTT 状态变化 |
| `GET` | `/system/network/ddns/list` | `super` | 分页查询双栈自动 DDNS 绑定 |
| `GET` | `/system/network/ddns/source-options` | `super` | 查询 A/AAAA 的安全地址来源选项 |
| `GET` | `/system/network/ddns/provider-status` | `super` | 查询腾讯云云解析 DNS 配置状态,不返回凭据 |
| `POST` | `/system/network/ddns` | `super` | 新增本地 DDNS 自动更新绑定 |
| `PUT` | `/system/network/ddns/:id` | `super` | 修改并按需立即协调 DDNS 绑定 |
| `DELETE` | `/system/network/ddns/:id` | `super` | 删除本地绑定,不删除云端 DNS 记录 |
| `POST` | `/system/network/ddns/:id/retry` | `super` | 手动重试一条已启用 DDNS 绑定 |
新增和修改请求只接受名称、备注、`tcp|udp`、外部端口和内部端口;目标 NAS IPv4 固定来自 `NETWORK_AGENT_TARGET_IPV4`,请求体中的未知字段会返回 400。Snowflake ID 与 revision 在 HTTP JSON 中保留为字符串。所有动态响应设置 `Cache-Control: no-store` 新增和修改请求只接受名称、备注、`tcp|udp`、外部端口和内部端口;目标 NAS IPv4 固定来自 `NETWORK_AGENT_TARGET_IPV4`,请求体中的未知字段会返回 400。Snowflake ID 与 revision 在 HTTP JSON 中保留为字符串。所有动态响应设置 `Cache-Control: no-store`
@ -116,6 +123,10 @@ Admin 首次进入网络管理页通过 HTTP 读取快照,随后使用 `/syste
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 处理。 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 处理。
自动 DDNS 支持 `A``AAAA`。A 记录绑定 `sourceType=port_forward_ipv4` 与一条合格的同源端口 UDP KeeperAAAA 记录固定使用 `sourceType=agent_ipv6`,不得携带 `portForwardId`。来源暂不可用时记录进入 `waiting_source`,不会向腾讯云写入空地址。协调器只修改腾讯云云解析 DNS 中已存在、已启用、默认线路且唯一的同类型记录,写入时保留 RecordId、线路和 TTL并回读相同 RecordId 验证结果DNS 值不包含端口。删除接口只删除本地自动更新绑定不删除云端记录。Provider 状态接口只返回开关、配置完整性和官方 provider 标识,不返回 SecretId、SecretKey 或 SDK 原始错误。
Agent 状态响应额外包含可选的 `currentPublicIpv6/currentIpv6ObservedAt`。只有在线且未超过 `NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` 的规范化全局 IPv6 才能作为 AAAA 来源;缺少 IPv6 不影响现有端口转发、UDP Keeper 或 Agent 在线状态。
## 环境变量分组 ## 环境变量分组
| 分组 | 关键变量 | | 分组 | 关键变量 |
@ -129,7 +140,7 @@ TCP 记录支持 API CRUD但不提供 STUN/Keeper当前已验证的 Agent
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | | NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | 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_*` | | 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`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT` | | 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`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_DDNS_DNSPOD_ENABLED`、`NETWORK_DDNS_DNSPOD_SECRET_ID`、`NETWORK_DDNS_DNSPOD_SECRET_KEY`、`NETWORK_DDNS_RECONCILE_INTERVAL_MS`、`NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | 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` | | 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` | | FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |

View File

@ -65,7 +65,7 @@ ci/ Jenkins Agent/Docker 辅助文件
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | | 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_*` | | 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_*` | | 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`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT` | | 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`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_DDNS_DNSPOD_*`、`NETWORK_DDNS_RECONCILE_INTERVAL_MS`、`NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | 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` | | 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` | | FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
@ -82,6 +82,8 @@ Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、
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 阻塞。API 仅在入站 MQTT 事务提交且语义状态实际变化后通过 `/system/network/events/stream` 向 Admin 发布 SSEQoS 1 幂等重投、`status` 心跳时间推进和 `reported` 租约时间续期仍写入数据库,但不触发页面刷新。公网 IP/端口、Keeper/同步/错误/删除状态或 Agent 在线会话变化仍发布事件。SSE 心跳复用最近一次真实状态事件游标,尚无状态事件时显式发送空游标,避免 Nest 自动生成的 ID 污染重放位置;有限重放缺口只要求一次 HTTP 快照。TCP 目前仅保存 CRUD 期望,真实路由器写入仍受设备协议证据门禁并回报 `tcp_router_write_gated`;只有外部端口等于内部端口的 UDP 记录允许启停 Keeper 和立即探测。当前公网端点受 `currentValidUntil` 租约约束,过期后列表隐藏当前值但保留最近观测与历史。生产发布同时把完整 `NETWORK_AGENT_*` 连接配置作为 Jenkins 私有 env 和 `/health/runtime` 必需项,任一项缺失时拒绝发布或报告运行态阻断,避免页面可见但 MQTT 控制链路未接线。 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 阻塞。API 仅在入站 MQTT 事务提交且语义状态实际变化后通过 `/system/network/events/stream` 向 Admin 发布 SSEQoS 1 幂等重投、`status` 心跳时间推进和 `reported` 租约时间续期仍写入数据库,但不触发页面刷新。公网 IP/端口、Keeper/同步/错误/删除状态或 Agent 在线会话变化仍发布事件。SSE 心跳复用最近一次真实状态事件游标,尚无状态事件时显式发送空游标,避免 Nest 自动生成的 ID 污染重放位置;有限重放缺口只要求一次 HTTP 快照。TCP 目前仅保存 CRUD 期望,真实路由器写入仍受设备协议证据门禁并回报 `tcp_router_write_gated`;只有外部端口等于内部端口的 UDP 记录允许启停 Keeper 和立即探测。当前公网端点受 `currentValidUntil` 租约约束,过期后列表隐藏当前值但保留最近观测与历史。生产发布同时把完整 `NETWORK_AGENT_*` 连接配置作为 Jenkins 私有 env 和 `/health/runtime` 必需项,任一项缺失时拒绝发布或报告运行态阻断,避免页面可见但 MQTT 控制链路未接线。
同一模块提供腾讯云云解析 DNS 的双栈自动 DDNS。A 记录只从合格 UDP Keeper 的有效公网 IPv4 取值AAAA 记录只从在线 Agent 最近上报的全局 IPv6 取值DNS 值始终不包含端口。协调器只修改已存在、已启用、默认线路且唯一的 A/AAAA 记录,保留 RecordId、线路和 TTL并在写入后回读确认。删除 Admin 绑定只停止本地自动更新,不删除云端 DNS 记录。凭据只从 API 私有运行环境的 `NETWORK_DDNS_DNSPOD_SECRET_ID/SECRET_KEY` 读取,不进入 Admin、数据库、MQTT、Agent、日志或 Git`DNSPOD` 是腾讯云官方 SDK 的技术服务名。
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MACNapCat/OneBot 配置 hashKT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact并在 QQ `KickedOffLine` 后标记 native login service staleAPI 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker重建 QQCore login service 后再推进 quick/password/qrcode不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc/<pid>/mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q <uin>` 快速登录参数避免重启后退回无账号扫码v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true``o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE``QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile或清空两个 override 后重新部署 manifest 默认值。 NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MACNapCat/OneBot 配置 hashKT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact并在 QQ `KickedOffLine` 后标记 native login service staleAPI 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker重建 QQCore login service 后再推进 quick/password/qrcode不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc/<pid>/mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q <uin>` 快速登录参数避免重启后退回无账号扫码v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true``o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE``QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile或清空两个 override 后重新部署 manifest 默认值。
运行时发布时API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 运行时发布时API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。

View File

@ -1,4 +1,4 @@
-- 通用端口转发控制面数据表与单 Agent 初始状态。 -- 通用网络控制面数据表与单 Agent 初始状态。
SET NAMES utf8mb4; SET NAMES utf8mb4;
@ -37,6 +37,36 @@ CREATE TABLE IF NOT EXISTS `network_port_forward` (
KEY `idx_network_port_forward_protocol` (`protocol`, `external_port`) KEY `idx_network_port_forward_protocol` (`protocol`, `external_port`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `network_ddns_record` (
`id` BIGINT NOT NULL,
`name` VARCHAR(100) NOT NULL,
`remark` TEXT NULL,
`record_type` VARCHAR(8) NOT NULL,
`source_type` VARCHAR(32) NOT NULL,
`port_forward_id` BIGINT NULL,
`domain` VARCHAR(253) NOT NULL,
`sub_domain` VARCHAR(253) NOT NULL,
`active_key` VARCHAR(300) NULL,
`enabled` TINYINT(1) NOT NULL DEFAULT 0,
`sync_status` VARCHAR(32) NOT NULL DEFAULT 'disabled',
`provider_record_id` VARCHAR(32) NULL,
`source_address` VARCHAR(45) NULL,
`applied_address` VARCHAR(45) NULL,
`retry_count` INT UNSIGNED NOT NULL DEFAULT 0,
`next_retry_at` DATETIME(3) NULL,
`last_attempt_at` DATETIME(3) NULL,
`last_synced_at` DATETIME(3) NULL,
`last_error_code` VARCHAR(64) NULL,
`last_error_message` VARCHAR(512) NULL,
`is_deleted` TINYINT(1) NOT NULL DEFAULT 0,
`create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_network_ddns_record_active_key` (`active_key`),
KEY `idx_network_ddns_record_status` (`is_deleted`, `enabled`, `sync_status`, `next_retry_at`),
KEY `idx_network_ddns_record_port_forward` (`port_forward_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `network_agent_state` ( CREATE TABLE IF NOT EXISTS `network_agent_state` (
`agent_id` VARCHAR(64) NOT NULL, `agent_id` VARCHAR(64) NOT NULL,
`target_ipv4` VARCHAR(15) NOT NULL, `target_ipv4` VARCHAR(15) NOT NULL,
@ -48,6 +78,8 @@ CREATE TABLE IF NOT EXISTS `network_agent_state` (
`version` VARCHAR(64) NULL, `version` VARCHAR(64) NULL,
`started_at` DATETIME(6) NULL, `started_at` DATETIME(6) NULL,
`last_heartbeat_at` DATETIME(6) NULL, `last_heartbeat_at` DATETIME(6) NULL,
`current_public_ipv6` VARCHAR(45) NULL,
`current_ipv6_observed_at` DATETIME(3) NULL,
`last_mqtt_error_code` VARCHAR(64) NULL, `last_mqtt_error_code` VARCHAR(64) NULL,
`last_mqtt_error_message` VARCHAR(500) NULL, `last_mqtt_error_message` VARCHAR(500) NULL,
`last_reconcile_error_code` VARCHAR(64) NULL, `last_reconcile_error_code` VARCHAR(64) NULL,
@ -57,6 +89,38 @@ CREATE TABLE IF NOT EXISTS `network_agent_state` (
PRIMARY KEY (`agent_id`) PRIMARY KEY (`agent_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
SET @network_agent_public_ipv6_exists := (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'network_agent_state'
AND COLUMN_NAME = 'current_public_ipv6'
);
SET @network_agent_public_ipv6_sql := IF(
@network_agent_public_ipv6_exists = 0,
'ALTER TABLE `network_agent_state` ADD COLUMN `current_public_ipv6` varchar(45) NULL AFTER `last_heartbeat_at`',
'SELECT 1'
);
PREPARE network_agent_public_ipv6_stmt FROM @network_agent_public_ipv6_sql;
EXECUTE network_agent_public_ipv6_stmt;
DEALLOCATE PREPARE network_agent_public_ipv6_stmt;
SET @network_agent_ipv6_observed_at_exists := (
SELECT COUNT(*)
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'network_agent_state'
AND COLUMN_NAME = 'current_ipv6_observed_at'
);
SET @network_agent_ipv6_observed_at_sql := IF(
@network_agent_ipv6_observed_at_exists = 0,
'ALTER TABLE `network_agent_state` ADD COLUMN `current_ipv6_observed_at` datetime(3) NULL AFTER `current_public_ipv6`',
'SELECT 1'
);
PREPARE network_agent_ipv6_observed_at_stmt FROM @network_agent_ipv6_observed_at_sql;
EXECUTE network_agent_ipv6_observed_at_stmt;
DEALLOCATE PREPARE network_agent_ipv6_observed_at_stmt;
CREATE TABLE IF NOT EXISTS `network_endpoint_history` ( CREATE TABLE IF NOT EXISTS `network_endpoint_history` (
`id` BIGINT NOT NULL, `id` BIGINT NOT NULL,
`event_id` VARCHAR(128) NOT NULL, `event_id` VARCHAR(128) NOT NULL,

View File

@ -1,4 +1,4 @@
-- 增量初始化通用网络端口转发菜单;仅授予启用中的超级管理员。 -- 增量初始化通用网络管理菜单;仅授予启用中的超级管理员。
SET NAMES utf8mb4; SET NAMES utf8mb4;
@ -14,7 +14,12 @@ VALUES
(2041700000000120218, 2041700000000100207, 'SystemNetworkPortForwardRetry', NULL, NULL, NULL, 'System:Network:PortForward:Retry', 'button', '{"title":"common.retry"}', 1, 5), (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), (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), (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) (2041700000000120221, 2041700000000100207, 'SystemNetworkPortForwardHistory', NULL, NULL, NULL, 'System:Network:PortForward:History', 'button', '{"title":"system.network.history"}', 1, 8),
(2041700000000120222, 2041700000000100207, 'SystemNetworkDdnsList', NULL, NULL, NULL, 'System:Network:Ddns:List', 'button', '{"title":"common.list"}', 1, 9),
(2041700000000120223, 2041700000000100207, 'SystemNetworkDdnsCreate', NULL, NULL, NULL, 'System:Network:Ddns:Create', 'button', '{"title":"common.create"}', 1, 10),
(2041700000000120224, 2041700000000100207, 'SystemNetworkDdnsUpdate', NULL, NULL, NULL, 'System:Network:Ddns:Update', 'button', '{"title":"common.edit"}', 1, 11),
(2041700000000120225, 2041700000000100207, 'SystemNetworkDdnsDelete', NULL, NULL, NULL, 'System:Network:Ddns:Delete', 'button', '{"title":"common.delete"}', 1, 12),
(2041700000000120226, 2041700000000100207, 'SystemNetworkDdnsRetry', NULL, NULL, NULL, 'System:Network:Ddns:Retry', 'button', '{"title":"common.retry"}', 1, 13)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`), `name` = VALUES(`name`),
`pid` = VALUES(`pid`), `pid` = VALUES(`pid`),
@ -42,7 +47,12 @@ WHERE role.`role_code` <> 'super'
'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardRetry',
'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardKeeper',
'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardProbe',
'SystemNetworkPortForwardHistory' 'SystemNetworkPortForwardHistory',
'SystemNetworkDdnsList',
'SystemNetworkDdnsCreate',
'SystemNetworkDdnsUpdate',
'SystemNetworkDdnsDelete',
'SystemNetworkDdnsRetry'
); );
INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`) INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`)
@ -57,7 +67,12 @@ JOIN `admin_menu` menu ON menu.`name` IN (
'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardRetry',
'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardKeeper',
'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardProbe',
'SystemNetworkPortForwardHistory' 'SystemNetworkPortForwardHistory',
'SystemNetworkDdnsList',
'SystemNetworkDdnsCreate',
'SystemNetworkDdnsUpdate',
'SystemNetworkDdnsDelete',
'SystemNetworkDdnsRetry'
) )
WHERE role.`role_code` = 'super' WHERE role.`role_code` = 'super'
AND role.`status` = 1 AND role.`status` = 1

View File

@ -160,6 +160,35 @@ CREATE TABLE IF NOT EXISTS network_port_forward (
KEY idx_network_port_forward_protocol (protocol, external_port) KEY idx_network_port_forward_protocol (protocol, external_port)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS network_ddns_record (
id BIGINT NOT NULL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
remark TEXT NULL,
record_type VARCHAR(8) NOT NULL,
source_type VARCHAR(32) NOT NULL,
port_forward_id BIGINT NULL,
domain VARCHAR(253) NOT NULL,
sub_domain VARCHAR(253) NOT NULL,
active_key VARCHAR(300) NULL,
enabled TINYINT(1) NOT NULL DEFAULT 0,
sync_status VARCHAR(32) NOT NULL DEFAULT 'disabled',
provider_record_id VARCHAR(32) NULL,
source_address VARCHAR(45) NULL,
applied_address VARCHAR(45) NULL,
retry_count INT UNSIGNED NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NULL,
last_attempt_at DATETIME(3) NULL,
last_synced_at DATETIME(3) NULL,
last_error_code VARCHAR(64) NULL,
last_error_message VARCHAR(512) NULL,
is_deleted TINYINT(1) NOT NULL DEFAULT 0,
create_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
update_time DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_network_ddns_record_active_key (active_key),
KEY idx_network_ddns_record_status (is_deleted, enabled, sync_status, next_retry_at),
KEY idx_network_ddns_record_port_forward (port_forward_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS network_agent_state ( CREATE TABLE IF NOT EXISTS network_agent_state (
agent_id VARCHAR(64) NOT NULL PRIMARY KEY, agent_id VARCHAR(64) NOT NULL PRIMARY KEY,
target_ipv4 VARCHAR(15) NOT NULL, target_ipv4 VARCHAR(15) NOT NULL,
@ -171,6 +200,8 @@ CREATE TABLE IF NOT EXISTS network_agent_state (
version VARCHAR(64) NULL, version VARCHAR(64) NULL,
started_at DATETIME(6) NULL, started_at DATETIME(6) NULL,
last_heartbeat_at DATETIME(6) NULL, last_heartbeat_at DATETIME(6) NULL,
current_public_ipv6 VARCHAR(45) NULL,
current_ipv6_observed_at DATETIME(3) NULL,
last_mqtt_error_code VARCHAR(64) NULL, last_mqtt_error_code VARCHAR(64) NULL,
last_mqtt_error_message VARCHAR(500) NULL, last_mqtt_error_message VARCHAR(500) NULL,
last_reconcile_error_code VARCHAR(64) NULL, last_reconcile_error_code VARCHAR(64) NULL,

View File

@ -123,6 +123,33 @@ INSERT INTO admin_menu (
1, 1,
0 0
), ),
(
2041700000000100002,
0,
'System',
'/system',
NULL,
NULL,
NULL,
'catalog',
'{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}',
1,
9997
),
(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),
(2041700000000120222, 2041700000000100207, 'SystemNetworkDdnsList', NULL, NULL, NULL, 'System:Network:Ddns:List', 'button', '{"title":"common.list"}', 1, 9),
(2041700000000120223, 2041700000000100207, 'SystemNetworkDdnsCreate', NULL, NULL, NULL, 'System:Network:Ddns:Create', 'button', '{"title":"common.create"}', 1, 10),
(2041700000000120224, 2041700000000100207, 'SystemNetworkDdnsUpdate', NULL, NULL, NULL, 'System:Network:Ddns:Update', 'button', '{"title":"common.edit"}', 1, 11),
(2041700000000120225, 2041700000000100207, 'SystemNetworkDdnsDelete', NULL, NULL, NULL, 'System:Network:Ddns:Delete', 'button', '{"title":"common.delete"}', 1, 12),
(2041700000000120226, 2041700000000100207, 'SystemNetworkDdnsRetry', NULL, NULL, NULL, 'System:Network:Ddns:Retry', 'button', '{"title":"common.retry"}', 1, 13),
( (
2041700000000100400, 2041700000000100400,
0, 0,
@ -180,6 +207,28 @@ ON DUPLICATE KEY UPDATE
sort = VALUES(sort), sort = VALUES(sort),
is_deleted = 0; 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',
'SystemNetworkDdnsList',
'SystemNetworkDdnsCreate',
'SystemNetworkDdnsUpdate',
'SystemNetworkDdnsDelete',
'SystemNetworkDdnsRetry'
);
INSERT INTO admin_dict ( INSERT INTO admin_dict (
id, id,
dict_code, dict_code,

View File

@ -2,6 +2,7 @@ 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_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 '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_port_forward' AS table_name, COUNT(*) AS row_count FROM network_port_forward;
SELECT 'network_ddns_record' AS table_name, COUNT(*) AS row_count FROM network_ddns_record;
SELECT 'network_agent_state' AS table_name, COUNT(*) AS row_count FROM network_agent_state; 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 '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 'platform_setting' AS table_name, COUNT(*) AS row_count FROM platform_setting;
@ -44,12 +45,45 @@ FROM network_agent_state
WHERE agent_id = 'nas-main' WHERE agent_id = 'nas-main'
AND target_ipv4 = '192.168.31.224'; AND target_ipv4 = '192.168.31.224';
SELECT 'column_network_agent_state_current_public_ipv6' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'network_agent_state'
AND column_name = 'current_public_ipv6'
AND column_type = 'varchar(45)';
SELECT 'column_network_agent_state_current_ipv6_observed_at' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'network_agent_state'
AND column_name = 'current_ipv6_observed_at'
AND column_type = 'datetime(3)';
SELECT 'index_network_port_forward_active_key' AS check_name, COUNT(*) AS matched_rows SELECT 'index_network_port_forward_active_key' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics FROM information_schema.statistics
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
AND table_name = 'network_port_forward' AND table_name = 'network_port_forward'
AND index_name = 'uk_network_port_forward_active_key'; AND index_name = 'uk_network_port_forward_active_key';
SELECT 'index_network_ddns_record_active_key' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'network_ddns_record'
AND index_name = 'uk_network_ddns_record_active_key'
AND non_unique = 0;
SELECT 'index_network_ddns_record_status' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'network_ddns_record'
AND index_name = 'idx_network_ddns_record_status';
SELECT 'index_network_ddns_record_port_forward' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'network_ddns_record'
AND index_name = 'idx_network_ddns_record_port_forward';
SELECT 'index_network_endpoint_history_event_id' AS check_name, COUNT(*) AS matched_rows SELECT 'index_network_endpoint_history_event_id' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics FROM information_schema.statistics
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()
@ -112,6 +146,48 @@ SELECT 'seed_qqbot_account_webui_permission' AS check_name, COUNT(*) AS matched_
FROM admin_menu FROM admin_menu
WHERE auth_code = 'QqBot:Account:WebUI'; WHERE auth_code = 'QqBot:Account:WebUI';
SELECT 'seed_network_ddns_permissions' AS check_name, COUNT(*) AS matched_rows
FROM admin_menu
WHERE auth_code IN (
'System:Network:Ddns:List',
'System:Network:Ddns:Create',
'System:Network:Ddns:Update',
'System:Network:Ddns:Delete',
'System:Network:Ddns:Retry'
)
AND status = 1
AND is_deleted = 0;
SELECT 'seed_network_ddns_super_permissions' AS check_name, COUNT(*) AS matched_rows
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 role.status = 1
AND role.is_deleted = 0
AND menu.auth_code IN (
'System:Network:Ddns:List',
'System:Network:Ddns:Create',
'System:Network:Ddns:Update',
'System:Network:Ddns:Delete',
'System:Network:Ddns:Retry'
)
AND menu.status = 1
AND menu.is_deleted = 0;
SELECT 'network_ddns_non_super_permissions_should_be_zero' AS check_name, COUNT(*) AS matched_rows
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.auth_code IN (
'System:Network:Ddns:List',
'System:Network:Ddns:Create',
'System:Network:Ddns:Update',
'System:Network:Ddns:Delete',
'System:Network:Ddns:Retry'
);
SELECT 'index_admin_user_username' AS check_name, COUNT(*) AS matched_rows SELECT 'index_admin_user_username' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics FROM information_schema.statistics
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()

View File

@ -248,6 +248,11 @@ VALUES
(2041700000000120219, 2041700000000100207, 'SystemNetworkPortForwardKeeper', NULL, NULL, NULL, 'System:Network:PortForward:Keeper', 'button', '{"title":"system.network.keeper"}', 1, 6), (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), (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), (2041700000000120221, 2041700000000100207, 'SystemNetworkPortForwardHistory', NULL, NULL, NULL, 'System:Network:PortForward:History', 'button', '{"title":"system.network.history"}', 1, 8),
(2041700000000120222, 2041700000000100207, 'SystemNetworkDdnsList', NULL, NULL, NULL, 'System:Network:Ddns:List', 'button', '{"title":"common.list"}', 1, 9),
(2041700000000120223, 2041700000000100207, 'SystemNetworkDdnsCreate', NULL, NULL, NULL, 'System:Network:Ddns:Create', 'button', '{"title":"common.create"}', 1, 10),
(2041700000000120224, 2041700000000100207, 'SystemNetworkDdnsUpdate', NULL, NULL, NULL, 'System:Network:Ddns:Update', 'button', '{"title":"common.edit"}', 1, 11),
(2041700000000120225, 2041700000000100207, 'SystemNetworkDdnsDelete', NULL, NULL, NULL, 'System:Network:Ddns:Delete', 'button', '{"title":"common.delete"}', 1, 12),
(2041700000000120226, 2041700000000100207, 'SystemNetworkDdnsRetry', NULL, NULL, NULL, 'System:Network:Ddns:Retry', 'button', '{"title":"common.retry"}', 1, 13),
(2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100), (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), (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), (2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0),
@ -375,7 +380,12 @@ WHERE `is_deleted` = 0
'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardRetry',
'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardKeeper',
'SystemNetworkPortForwardProbe', 'SystemNetworkPortForwardProbe',
'SystemNetworkPortForwardHistory' 'SystemNetworkPortForwardHistory',
'SystemNetworkDdnsList',
'SystemNetworkDdnsCreate',
'SystemNetworkDdnsUpdate',
'SystemNetworkDdnsDelete',
'SystemNetworkDdnsRetry'
); );
INSERT INTO `admin_role_menu` (`role_id`, `menu_id`) INSERT INTO `admin_role_menu` (`role_id`, `menu_id`)

View File

@ -10,6 +10,9 @@ import { AdminNoticeController } from '@/modules/admin/platform-config/notice/ad
import { NoticeModule } from '@/modules/admin/platform-config/notice/notice.module'; import { NoticeModule } from '@/modules/admin/platform-config/notice/notice.module';
import { NetworkAgentMqttService } from '@/modules/admin/platform-config/network-management/network-agent-mqtt.service'; 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 { NetworkAgentState } from '@/modules/admin/platform-config/network-management/network-agent-state.entity';
import { NetworkDdnsRecord } from '@/modules/admin/platform-config/network-management/network-ddns.entity';
import { NetworkDdnsService } from '@/modules/admin/platform-config/network-management/network-ddns.service';
import { NetworkDnsPodClient } from '@/modules/admin/platform-config/network-management/network-dnspod.client';
import { NetworkEndpointHistory } from '@/modules/admin/platform-config/network-management/network-endpoint-history.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 { NetworkManagementController } from '@/modules/admin/platform-config/network-management/network-management.controller';
import { NetworkManagementEventStreamService } from '@/modules/admin/platform-config/network-management/network-management-event-stream.service'; import { NetworkManagementEventStreamService } from '@/modules/admin/platform-config/network-management/network-management-event-stream.service';
@ -83,6 +86,8 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
EnvironmentEventStreamService, EnvironmentEventStreamService,
NetworkManagementService, NetworkManagementService,
NetworkManagementEventStreamService, NetworkManagementEventStreamService,
NetworkDnsPodClient,
NetworkDdnsService,
NetworkAgentMqttService, NetworkAgentMqttService,
]; ];
@ -94,6 +99,7 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
NetworkPortForward, NetworkPortForward,
NetworkAgentState, NetworkAgentState,
NetworkEndpointHistory, NetworkEndpointHistory,
NetworkDdnsRecord,
]), ]),
AdminAuthGuardModule, AdminAuthGuardModule,
DictModule, DictModule,

View File

@ -13,6 +13,7 @@ import type { IClientOptions, MqttClient } from 'mqtt';
import { KtDateTime } from '@/common'; import { KtDateTime } from '@/common';
import { NetworkAgentState } from './network-agent-state.entity'; import { NetworkAgentState } from './network-agent-state.entity';
import { NetworkEndpointHistory } from './network-endpoint-history.entity'; import { NetworkEndpointHistory } from './network-endpoint-history.entity';
import { NetworkDdnsService } from './network-ddns.service';
import { NetworkManagementEventStreamService } from './network-management-event-stream.service'; import { NetworkManagementEventStreamService } from './network-management-event-stream.service';
import { NetworkPortForward } from './network-management.entity'; import { NetworkPortForward } from './network-management.entity';
import { import {
@ -60,6 +61,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
* @param dataSource - Transaction boundary for publish acknowledgements and inbound state. * @param dataSource - Transaction boundary for publish acknowledgements and inbound state.
* @param eventStream - SSE fan-out notified only after accepted inbound commits. * @param eventStream - SSE fan-out notified only after accepted inbound commits.
* @param clientFactory - Optional deterministic MQTT client factory used by tests. * @param clientFactory - Optional deterministic MQTT client factory used by tests.
* @param ddnsService - Optional automatic-DDNS reconciler notified after address semantics commit.
*/ */
constructor( constructor(
private readonly configService: ConfigService, private readonly configService: ConfigService,
@ -68,6 +70,8 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
@Optional() @Optional()
@Inject(NETWORK_MQTT_CLIENT_FACTORY) @Inject(NETWORK_MQTT_CLIENT_FACTORY)
private readonly clientFactory?: NetworkMqttClientFactory, private readonly clientFactory?: NetworkMqttClientFactory,
@Optional()
private readonly ddnsService?: NetworkDdnsService,
) {} ) {}
/** Opens a persistent MQTT 5 session and schedules convergence publication. */ /** Opens a persistent MQTT 5 session and schedules convergence publication. */
@ -175,13 +179,18 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
} }
let changed = false; let changed = false;
let ddnsSourceChanged = false;
let source: NetworkStateChangeSource; let source: NetworkStateChangeSource;
if (topic === this.topic('reported')) { if (topic === this.topic('reported')) {
source = 'reported'; source = 'reported';
changed = await this.applyReported(parseReportedSnapshot(parsed)); const result = await this.applyReported(parseReportedSnapshot(parsed));
changed = result.visibleStateChanged;
ddnsSourceChanged = result.ddnsSourceChanged;
} else if (topic === this.topic('status')) { } else if (topic === this.topic('status')) {
source = 'status'; source = 'status';
changed = await this.applyStatus(parseStatusSnapshot(parsed)); const result = await this.applyStatus(parseStatusSnapshot(parsed));
changed = result.visibleStateChanged;
ddnsSourceChanged = result.ddnsSourceChanged;
} else if (topic === this.topic('events')) { } else if (topic === this.topic('events')) {
source = 'events'; source = 'events';
changed = await this.appendEndpointEvent(parseEndpointEvent(parsed)); changed = await this.appendEndpointEvent(parseEndpointEvent(parsed));
@ -189,6 +198,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
throw new NetworkMessageValidationError('Unexpected network MQTT topic'); throw new NetworkMessageValidationError('Unexpected network MQTT topic');
} }
if (changed) this.eventStream.publishCommitted(source); if (changed) this.eventStream.publishCommitted(source);
if (ddnsSourceChanged) this.ddnsService?.requestReconcile();
} }
/** Restores exact subscriptions and one retained desired republish after each connection. */ /** Restores exact subscriptions and one retained desired republish after each connection. */
@ -315,11 +325,12 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
/** /**
* Applies a full reported snapshot transactionally without creating desired rows. * Applies a full reported snapshot transactionally without creating desired rows.
* @param report - Strict Agent report parsed from the retained topic. * @param report - Strict Agent report parsed from the retained topic.
* @returns True only when persisted Admin-visible state changed. * @returns Independent Admin-refresh and DDNS-address semantic changes.
*/ */
private async applyReported( private async applyReported(report: NetworkReportedSnapshot): Promise<{
report: NetworkReportedSnapshot, ddnsSourceChanged: boolean;
): Promise<boolean> { visibleStateChanged: boolean;
}> {
this.assertAgentId(report.agentId); this.assertAgentId(report.agentId);
const result = await this.dataSource.transaction(async (manager) => { const result = await this.dataSource.transaction(async (manager) => {
const stateRepository = manager.getRepository(NetworkAgentState); const stateRepository = manager.getRepository(NetworkAgentState);
@ -335,9 +346,14 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
throw new NetworkMessageValidationError('Invalid reported revision'); throw new NetworkMessageValidationError('Invalid reported revision');
} }
if (BigInt(report.appliedRevision) < BigInt(state.appliedRevision)) { if (BigInt(report.appliedRevision) < BigInt(state.appliedRevision)) {
return { desiredChanged: false, visibleStateChanged: false }; return {
ddnsSourceChanged: false,
desiredChanged: false,
visibleStateChanged: false,
};
} }
const stateBefore = this.reportedAgentStateFingerprint(state); const stateBefore = this.reportedAgentStateFingerprint(state);
let ddnsSourceChanged = false;
let visibleStateChanged = false; let visibleStateChanged = false;
const isCurrentRevision = const isCurrentRevision =
BigInt(report.appliedRevision) === BigInt(state.desiredRevision); BigInt(report.appliedRevision) === BigInt(state.desiredRevision);
@ -407,6 +423,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
this.reportedPersistedMappingStateFingerprint(mapping); this.reportedPersistedMappingStateFingerprint(mapping);
const refreshMappingBefore = const refreshMappingBefore =
this.reportedRefreshMappingStateFingerprint(mapping); this.reportedRefreshMappingStateFingerprint(mapping);
const publicIpv4Before = mapping.currentPublicIpv4;
mapping.reportedRevision = String(item.revision); mapping.reportedRevision = String(item.revision);
mapping.syncStatus = item.syncStatus; mapping.syncStatus = item.syncStatus;
mapping.keeperStatus = item.keeperStatus; mapping.keeperStatus = item.keeperStatus;
@ -418,6 +435,9 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
item.lastObservedEndpoint, item.lastObservedEndpoint,
report.reportedAt, report.reportedAt,
); );
if (mapping.currentPublicIpv4 !== publicIpv4Before) {
ddnsSourceChanged = true;
}
if ( if (
mapping.desiredPresence === 'absent' && mapping.desiredPresence === 'absent' &&
@ -471,12 +491,16 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
await stateRepository.save(state); await stateRepository.save(state);
} }
return { return {
ddnsSourceChanged,
desiredChanged: finalizedDeletion, desiredChanged: finalizedDeletion,
visibleStateChanged, visibleStateChanged,
}; };
}); });
if (result.desiredChanged) this.requestDesiredPublish(); if (result.desiredChanged) this.requestDesiredPublish();
return result.visibleStateChanged; return {
ddnsSourceChanged: result.ddnsSourceChanged,
visibleStateChanged: result.visibleStateChanged,
};
} }
/** /**
@ -531,9 +555,12 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
/** /**
* Applies retained Agent online status without changing mapping sync semantics. * Applies retained Agent online status without changing mapping sync semantics.
* @param status - Strict retained status or LWT snapshot. * @param status - Strict retained status or LWT snapshot.
* @returns True only when semantic Admin status changed; heartbeat time is still persisted. * @returns Independent Admin-refresh and DDNS-address semantic changes.
*/ */
private async applyStatus(status: NetworkStatusSnapshot): Promise<boolean> { private async applyStatus(status: NetworkStatusSnapshot): Promise<{
ddnsSourceChanged: boolean;
visibleStateChanged: boolean;
}> {
this.assertAgentId(status.agentId); this.assertAgentId(status.agentId);
return await this.dataSource.transaction(async (manager) => { return await this.dataSource.transaction(async (manager) => {
const repository = manager.getRepository(NetworkAgentState); const repository = manager.getRepository(NetworkAgentState);
@ -556,7 +583,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
currentStartedAt && currentStartedAt &&
incomingStartedAt.getTime() < currentStartedAt.getTime() incomingStartedAt.getTime() < currentStartedAt.getTime()
) { ) {
return false; return { ddnsSourceChanged: false, visibleStateChanged: false };
} }
const isSameSessionWill = const isSameSessionWill =
status.online === false && status.online === false &&
@ -568,10 +595,11 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
observedAt.getTime() < new Date(state.lastHeartbeatAt).getTime() && observedAt.getTime() < new Date(state.lastHeartbeatAt).getTime() &&
!isSameSessionWill !isSameSessionWill
) { ) {
return false; return { ddnsSourceChanged: false, visibleStateChanged: false };
} }
const persistedStateBefore = this.statusPersistedStateFingerprint(state); const persistedStateBefore = this.statusPersistedStateFingerprint(state);
const refreshStateBefore = this.statusRefreshStateFingerprint(state); const refreshStateBefore = this.statusRefreshStateFingerprint(state);
const publicIpv6Before = state.currentPublicIpv6 || null;
state.online = status.online; state.online = status.online;
state.version = status.version || null; state.version = status.version || null;
state.startedAt = incomingStartedAt state.startedAt = incomingStartedAt
@ -582,12 +610,21 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
} }
state.lastMqttErrorCode = status.errorCode || null; state.lastMqttErrorCode = status.errorCode || null;
state.lastMqttErrorMessage = status.errorMessage || null; state.lastMqttErrorMessage = status.errorMessage || null;
state.currentPublicIpv6 =
status.online && status.publicIpv6 ? status.publicIpv6 : null;
state.currentIpv6ObservedAt =
status.online && status.publicIpv6 ? new KtDateTime(observedAt) : null;
if ( if (
this.statusPersistedStateFingerprint(state) !== persistedStateBefore this.statusPersistedStateFingerprint(state) !== persistedStateBefore
) { ) {
await repository.save(state); await repository.save(state);
} }
return this.statusRefreshStateFingerprint(state) !== refreshStateBefore; return {
ddnsSourceChanged:
(state.currentPublicIpv6 || null) !== publicIpv6Before,
visibleStateChanged:
this.statusRefreshStateFingerprint(state) !== refreshStateBefore,
};
}); });
} }
@ -710,6 +747,8 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
private statusPersistedStateFingerprint(state: NetworkAgentState): string { private statusPersistedStateFingerprint(state: NetworkAgentState): string {
return JSON.stringify([ return JSON.stringify([
state.lastHeartbeatAt, state.lastHeartbeatAt,
state.currentIpv6ObservedAt,
state.currentPublicIpv6,
state.lastMqttErrorCode, state.lastMqttErrorCode,
state.lastMqttErrorMessage, state.lastMqttErrorMessage,
state.online, state.online,
@ -727,6 +766,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
return JSON.stringify([ return JSON.stringify([
state.lastMqttErrorCode, state.lastMqttErrorCode,
state.lastMqttErrorMessage, state.lastMqttErrorMessage,
state.currentPublicIpv6,
state.online, state.online,
state.startedAt, state.startedAt,
state.version, state.version,

View File

@ -42,6 +42,17 @@ export class NetworkAgentState {
}) })
lastHeartbeatAt?: KtDateTime | null; lastHeartbeatAt?: KtDateTime | null;
@Column({ length: 45, name: 'current_public_ipv6', nullable: true })
currentPublicIpv6?: string | null;
@KtDateTimeColumn({
name: 'current_ipv6_observed_at',
nullable: true,
precision: 3,
type: 'datetime',
})
currentIpv6ObservedAt?: KtDateTime | null;
@Column({ length: 64, name: 'last_mqtt_error_code', nullable: true }) @Column({ length: 64, name: 'last_mqtt_error_code', nullable: true })
lastMqttErrorCode?: string | null; lastMqttErrorCode?: string | null;

View File

@ -0,0 +1,117 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
import type {
NetworkDdnsRecordType,
NetworkDdnsSourceType,
NetworkDdnsSyncStatus,
} from './network-management.types';
@Entity('network_ddns_record')
@Index('uk_network_ddns_record_active_key', ['activeKey'], { unique: true })
@Index('idx_network_ddns_record_status', [
'isDeleted',
'enabled',
'syncStatus',
'nextRetryAt',
])
@Index('idx_network_ddns_record_port_forward', ['portForwardId'])
export class NetworkDdnsRecord {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ length: 100 })
name: string;
@Column({ nullable: true, type: 'text' })
remark?: null | string;
@Column({ length: 8, name: 'record_type' })
recordType: NetworkDdnsRecordType;
@Column({ length: 32, name: 'source_type' })
sourceType: NetworkDdnsSourceType;
@Column({ name: 'port_forward_id', nullable: true, type: 'bigint' })
portForwardId?: null | string;
@Column({ length: 253 })
domain: string;
@Column({ length: 253, name: 'sub_domain' })
subDomain: string;
@Column({ length: 300, name: 'active_key', nullable: true })
activeKey?: null | string;
@Column({ default: false, type: 'boolean' })
enabled: boolean;
@Column({ default: 'disabled', length: 32, name: 'sync_status' })
syncStatus: NetworkDdnsSyncStatus;
@Column({ length: 32, name: 'provider_record_id', nullable: true })
providerRecordId?: null | string;
@Column({ length: 45, name: 'source_address', nullable: true })
sourceAddress?: null | string;
@Column({ length: 45, name: 'applied_address', nullable: true })
appliedAddress?: null | string;
@Column({ default: 0, name: 'retry_count', type: 'int', unsigned: true })
retryCount: number;
@KtDateTimeColumn({
name: 'next_retry_at',
nullable: true,
precision: 3,
type: 'datetime',
})
nextRetryAt?: KtDateTime | null;
@KtDateTimeColumn({
name: 'last_attempt_at',
nullable: true,
precision: 3,
type: 'datetime',
})
lastAttemptAt?: KtDateTime | null;
@KtDateTimeColumn({
name: 'last_synced_at',
nullable: true,
precision: 3,
type: 'datetime',
})
lastSyncedAt?: KtDateTime | null;
@Column({ length: 64, name: 'last_error_code', nullable: true })
lastErrorCode?: null | string;
@Column({ length: 512, name: 'last_error_message', nullable: true })
lastErrorMessage?: null | string;
@Column({ default: false, name: 'is_deleted', type: 'boolean' })
isDeleted: boolean;
@KtCreateDateColumn({ name: 'create_time', precision: 3, type: 'datetime' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time', precision: 3, type: 'datetime' })
updateTime: KtDateTime;
/**
* Assigns a Snowflake string before the local automatic updater is persisted.
* @returns The stable DDNS binding identifier.
*/
@BeforeInsert()
createId(): string {
return ensureSnowflakeId(this);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,635 @@
import { Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { isIP } from 'node:net';
import type { ClientConfig } from 'tencentcloud-sdk-nodejs/tencentcloud/common/interface';
import { dnspod } from 'tencentcloud-sdk-nodejs/tencentcloud/services/dnspod';
import type { DescribeRecordFilterListRequest as TencentDescribeRecordFilterListRequest } from 'tencentcloud-sdk-nodejs/tencentcloud/services/dnspod/v20210323/dnspod_models';
export type NetworkDnsPodProviderStatus = {
configured: boolean;
enabled: boolean;
provider: 'dnspod';
};
export type NetworkDnsPodReconcileInput = {
domain: string;
expectedRecordId?: null | string;
recordType: 'A' | 'AAAA';
subDomain: string;
targetAddress: string;
};
export type NetworkDnsPodReconcileResult = {
appliedAddress: string;
changed: boolean;
providerRecordId: string;
};
type DnsPodRecord = {
Line?: string;
LineId?: string;
Name?: string;
RecordId?: number;
Status?: string;
TTL?: number;
Type?: string;
Value?: string;
};
type DescribeRecordFilterListRequest =
TencentDescribeRecordFilterListRequest & {
Domain: string;
IsExactSubDomain: true;
Limit: 2;
Offset: 0;
RecordLine: string[];
RecordType: Array<'A' | 'AAAA'>;
SubDomain: string;
};
type ModifyDynamicDNSRequest = {
Domain: string;
RecordId: number;
RecordLine: string;
RecordLineId: string;
SubDomain: string;
Ttl: number;
Value: string;
};
interface DnsPodSdkClient {
DescribeRecordFilterList(request: DescribeRecordFilterListRequest): Promise<{
RecordList?: DnsPodRecord[];
}>;
ModifyDynamicDNS(
request: ModifyDynamicDNSRequest,
): Promise<Record<string, unknown>>;
}
export type NetworkDnsPodClientFactory = (
clientConfig: ClientConfig,
) => DnsPodSdkClient;
type NormalizedReconcileInput = {
domain: string;
expectedRecordId: null | string;
recordType: 'A' | 'AAAA';
subDomain: string;
targetAddress: string;
};
type ValidatedRecord = {
address: string;
line: string;
lineId: string;
recordId: number;
recordIdText: string;
ttl: number;
};
const DNSPOD_ENDPOINT = 'dnspod.tencentcloudapi.com';
const DNSPOD_REQUEST_TIMEOUT_SECONDS = 10;
const DNSPOD_DEFAULT_LINE = '默认';
const MAX_DNSPOD_TTL = 604_800;
const MAX_SAFE_RECORD_ID = BigInt(Number.MAX_SAFE_INTEGER);
const RETRYABLE_PROVIDER_CODES = [
'InternalError',
'ServerUnavailable',
'ServiceUnavailable',
];
const PROVIDER_AUTH_CODES = ['AuthFailure'];
const PROVIDER_PERMISSION_CODES = [
'AuthFailure.UnauthorizedOperation',
'FailedOperation.NoPermission',
'OperationDenied',
'UnauthorizedOperation',
];
const RETRYABLE_NETWORK_CODES = new Set([
'ECONNABORTED',
'ECONNREFUSED',
'ECONNRESET',
'EHOSTUNREACH',
'ENETDOWN',
'ENETUNREACH',
'ESOCKETTIMEDOUT',
'ETIMEDOUT',
'EAI_AGAIN',
]);
/**
* Represents a stable, redacted error at the DNSPod provider boundary.
*/
export class NetworkDnsPodClientError extends Error {
/**
* Creates a safe provider-boundary error without retaining the raw SDK error.
* @param code - Stable application error code.
* @param message - Redacted operator-facing message.
* @param retryable - Whether a later retry may recover automatically.
*/
constructor(
public readonly code: string,
message: string,
public readonly retryable: boolean,
) {
super(message);
this.name = 'NetworkDnsPodClientError';
}
}
/**
* Creates the official DNSPod SDK client used in production.
* @param clientConfig - Credential and bounded HTTP profile.
* @returns DNSPod client restricted to the two operations required by DDNS.
*/
function createDnsPodSdkClient(clientConfig: ClientConfig): DnsPodSdkClient {
return new dnspod.v20210323.Client(
clientConfig,
) as unknown as DnsPodSdkClient;
}
/**
* Validates a DNS label without accepting wildcard or URL syntax.
* @param value - Candidate DNS label.
* @returns True when the label is safe for an exact DNSPod query.
*/
function isValidDnsLabel(value: string): boolean {
return (
value.length >= 1 &&
value.length <= 63 &&
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value)
);
}
/**
* Validates a dot-separated ASCII DNS name.
* @param value - Candidate domain or host-record name.
* @param requireMultipleLabels - Whether a public zone-style name is required.
* @returns True when every label is valid and the total length is bounded.
*/
function isValidDnsName(
value: string,
requireMultipleLabels: boolean,
): boolean {
if (value.length > 253) return false;
const labels = value.split('.');
if (requireMultipleLabels && labels.length < 2) return false;
return labels.every(isValidDnsLabel);
}
/**
* Canonicalizes and validates an address for a DNS record family.
* @param address - Raw address supplied by configuration or DNSPod.
* @param recordType - DNS address record family.
* @returns Canonical address, or null when the value is unsafe or mismatched.
*/
function normalizeAddress(
address: unknown,
recordType: 'A' | 'AAAA',
): null | string {
if (
typeof address !== 'string' ||
address.length === 0 ||
address !== address.trim()
) {
return null;
}
if (recordType === 'A') {
return isIP(address) === 4 ? address : null;
}
if (isIP(address) !== 6) return null;
let canonicalAddress: string;
try {
const hostname = new URL(`http://[${address}]/`).hostname;
canonicalAddress = hostname.slice(1, -1).toLowerCase();
} catch {
return null;
}
const firstHextet = Number.parseInt(canonicalAddress.split(':', 1)[0], 16);
if (
!Number.isInteger(firstHextet) ||
firstHextet < 0x2000 ||
firstHextet > 0x3fff
) {
return null;
}
return canonicalAddress;
}
/**
* Converts an optional persisted record ID into a safe comparison value.
* @param value - Expected DNSPod record ID from persisted configuration.
* @returns Canonical decimal ID or null when no expectation was supplied.
*/
function normalizeExpectedRecordId(
value: NetworkDnsPodReconcileInput['expectedRecordId'],
): null | string {
if (value === null || value === undefined) return null;
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
throw new NetworkDnsPodClientError(
'DNSPOD_INVALID_INPUT',
'DNSPod reconcile input is invalid',
false,
);
}
const numericValue = BigInt(value);
if (numericValue > MAX_SAFE_RECORD_ID) {
throw new NetworkDnsPodClientError(
'DNSPOD_INVALID_INPUT',
'DNSPod reconcile input is invalid',
false,
);
}
return numericValue.toString();
}
/**
* Normalizes and validates all reconcile input before any SDK client is created.
* @param input - Reconcile request from the DDNS service.
* @returns Safe normalized values for exact provider requests.
*/
function normalizeInput(
input: NetworkDnsPodReconcileInput,
): NormalizedReconcileInput {
const domain =
typeof input?.domain === 'string' ? input.domain.toLowerCase() : '';
const subDomain =
typeof input?.subDomain === 'string' ? input.subDomain.toLowerCase() : '';
const recordType = input?.recordType;
const validDomain =
domain === input?.domain &&
isValidDnsName(domain, true) &&
!domain.endsWith('.');
const validSubDomain =
subDomain === input?.subDomain &&
(subDomain === '@' || isValidDnsName(subDomain, false));
const fqdnLength =
subDomain === '@' ? domain.length : `${subDomain}.${domain}`.length;
const targetAddress =
recordType === 'A' || recordType === 'AAAA'
? normalizeAddress(input.targetAddress, recordType)
: null;
if (!validDomain || !validSubDomain || fqdnLength > 253 || !targetAddress) {
throw new NetworkDnsPodClientError(
'DNSPOD_INVALID_INPUT',
'DNSPod reconcile input is invalid',
false,
);
}
return {
domain,
expectedRecordId: normalizeExpectedRecordId(input.expectedRecordId),
recordType,
subDomain,
targetAddress,
};
}
/**
* Extracts a string property from an unknown provider error.
* @param value - Unknown SDK error.
* @param key - Property name to inspect.
* @returns String property when present.
*/
function errorString(value: unknown, key: string): string {
if (!value || typeof value !== 'object') return '';
const property = (value as Record<string, unknown>)[key];
return typeof property === 'string' ? property : '';
}
/**
* Extracts an HTTP status from common SDK error shapes.
* @param value - Unknown SDK error.
* @returns HTTP status or zero when unavailable.
*/
function errorHttpStatus(value: unknown): number {
if (!value || typeof value !== 'object') return 0;
const record = value as Record<string, unknown>;
for (const key of ['statusCode', 'status', 'httpCode']) {
const status = record[key];
if (typeof status === 'number') return status;
}
const response = record.response;
if (response && typeof response === 'object') {
const status = (response as Record<string, unknown>).status;
if (typeof status === 'number') return status;
}
return 0;
}
/**
* Determines whether the SDK failure belongs to an explicitly retryable class.
* @param error - Unknown raw SDK error.
* @returns True only for rate limit, internal/service, HTTP 429/5xx, timeout, or network errors.
*/
function isRetryableProviderError(error: unknown): boolean {
const code = errorString(error, 'code');
const name = errorString(error, 'name');
const status = errorHttpStatus(error);
return (
RETRYABLE_PROVIDER_CODES.some(
(prefix) => code === prefix || code.startsWith(`${prefix}.`),
) ||
RETRYABLE_NETWORK_CODES.has(code.toUpperCase()) ||
name === 'AbortError' ||
name === 'TimeoutError' ||
status === 429 ||
status >= 500
);
}
/**
* Matches one provider error code against exact or dotted-prefix categories.
* @param code - SDK error code without using its raw message.
* @param categories - Stable provider code families.
* @returns True when the code belongs to one category.
*/
function matchesProviderCode(code: string, categories: string[]): boolean {
return categories.some(
(category) => code === category || code.startsWith(`${category}.`),
);
}
/**
* Maps an unknown SDK failure to a stable error without retaining provider details.
* @param error - Raw SDK error used only for retry classification.
* @returns Redacted provider-boundary error.
*/
function mapProviderError(error: unknown): NetworkDnsPodClientError {
const code = errorString(error, 'code');
const status = errorHttpStatus(error);
if (matchesProviderCode(code, ['RequestLimitExceeded']) || status === 429) {
return new NetworkDnsPodClientError(
'DNSPOD_RATE_LIMITED',
'DNSPod provider request was rate limited',
true,
);
}
if (matchesProviderCode(code, PROVIDER_PERMISSION_CODES)) {
return new NetworkDnsPodClientError(
'DNSPOD_PERMISSION_DENIED',
'DNSPod provider permission was denied',
false,
);
}
if (matchesProviderCode(code, PROVIDER_AUTH_CODES)) {
return new NetworkDnsPodClientError(
'DNSPOD_AUTH_FAILED',
'DNSPod provider authentication failed',
false,
);
}
if (isRetryableProviderError(error)) {
return new NetworkDnsPodClientError(
'DNSPOD_PROVIDER_RETRYABLE',
'DNSPod provider request failed temporarily',
true,
);
}
return new NetworkDnsPodClientError(
'DNSPOD_PROVIDER_REJECTED',
'DNSPod provider request was rejected',
false,
);
}
/**
* Validates a single exact DNSPod record and protects subsequent mutation metadata.
* @param records - Provider records returned by the exact list request.
* @param input - Normalized reconcile request.
* @returns Safe record metadata and canonical current address.
*/
function validateRecord(
records: DnsPodRecord[] | undefined,
input: NormalizedReconcileInput,
): ValidatedRecord {
if (!Array.isArray(records) || records.length === 0) {
throw new NetworkDnsPodClientError(
'DNSPOD_RECORD_NOT_FOUND',
'DNSPod address record was not found',
false,
);
}
if (records.length !== 1) {
throw new NetworkDnsPodClientError(
'DNSPOD_RECORD_AMBIGUOUS',
'DNSPod address record is ambiguous',
false,
);
}
const record = records[0];
if (record.Status !== 'ENABLE') {
throw new NetworkDnsPodClientError(
'DNSPOD_RECORD_DISABLED',
'DNSPod address record is disabled',
false,
);
}
const recordId = record.RecordId;
const recordIdIsSafe =
typeof recordId === 'number' &&
Number.isSafeInteger(recordId) &&
recordId > 0;
const ttlIsSafe =
typeof record.TTL === 'number' &&
Number.isSafeInteger(record.TTL) &&
record.TTL >= 1 &&
record.TTL <= MAX_DNSPOD_TTL;
const lineIdIsSafe =
typeof record.LineId === 'string' &&
/^[A-Za-z0-9=_:-]{1,64}$/.test(record.LineId);
const nameMatches =
typeof record.Name === 'string' &&
record.Name.toLowerCase() === input.subDomain;
const address = normalizeAddress(record.Value, input.recordType);
if (
!recordIdIsSafe ||
!ttlIsSafe ||
!lineIdIsSafe ||
!nameMatches ||
record.Type !== input.recordType ||
record.Line !== DNSPOD_DEFAULT_LINE ||
!address
) {
throw new NetworkDnsPodClientError(
'DNSPOD_RECORD_INVALID',
'DNSPod address record metadata is invalid',
false,
);
}
const recordIdText = String(recordId);
if (
input.expectedRecordId !== null &&
input.expectedRecordId !== recordIdText
) {
throw new NetworkDnsPodClientError(
'DNSPOD_RECORD_MISMATCH',
'DNSPod address record identity changed',
false,
);
}
return {
address,
line: record.Line,
lineId: record.LineId,
recordId,
recordIdText,
ttl: record.TTL,
};
}
@Injectable()
export class NetworkDnsPodClient {
private readonly createClient: NetworkDnsPodClientFactory;
/**
* Initializes the DNSPod boundary with lazy SDK client creation.
* @param config - Runtime configuration reader.
* @param createClient - Optional factory used by isolated tests.
*/
constructor(
private readonly config: ConfigService,
@Optional() createClient?: NetworkDnsPodClientFactory,
) {
this.createClient = createClient || createDnsPodSdkClient;
}
/**
* Reports explicit provider enablement and credential readiness.
* @returns DNSPod provider status without creating an SDK client.
*/
getStatus(): NetworkDnsPodProviderStatus {
return {
configured:
this.configValue('NETWORK_DDNS_DNSPOD_SECRET_ID').length > 0 &&
this.configValue('NETWORK_DDNS_DNSPOD_SECRET_KEY').length > 0,
enabled:
this.configValue('NETWORK_DDNS_DNSPOD_ENABLED').toLowerCase() ===
'true',
provider: 'dnspod',
};
}
/**
* Reconciles one existing DNSPod A or AAAA record and verifies provider read-back.
* @param input - Exact zone, host record, family, target, and optional record identity.
* @returns Applied canonical address and provider record identity.
*/
async reconcile(
input: NetworkDnsPodReconcileInput,
): Promise<NetworkDnsPodReconcileResult> {
const normalizedInput = normalizeInput(input);
const status = this.getStatus();
if (!status.enabled) {
throw new NetworkDnsPodClientError(
'DNSPOD_DISABLED',
'DNSPod provider is disabled',
false,
);
}
if (!status.configured) {
throw new NetworkDnsPodClientError(
'DNSPOD_NOT_CONFIGURED',
'DNSPod provider is not configured',
false,
);
}
const client = this.createClient(this.clientConfig());
try {
const current = await this.describeExactRecord(client, normalizedInput);
if (current.address === normalizedInput.targetAddress) {
return {
appliedAddress: normalizedInput.targetAddress,
changed: false,
providerRecordId: current.recordIdText,
};
}
await client.ModifyDynamicDNS({
Domain: normalizedInput.domain,
RecordId: current.recordId,
RecordLine: current.line,
RecordLineId: current.lineId,
SubDomain: normalizedInput.subDomain,
Ttl: current.ttl,
Value: normalizedInput.targetAddress,
});
const verified = await this.describeExactRecord(client, {
...normalizedInput,
expectedRecordId: current.recordIdText,
});
if (verified.address !== normalizedInput.targetAddress) {
throw new NetworkDnsPodClientError(
'DNSPOD_VERIFICATION_FAILED',
'DNSPod address record verification failed',
false,
);
}
return {
appliedAddress: normalizedInput.targetAddress,
changed: true,
providerRecordId: current.recordIdText,
};
} catch (error) {
if (error instanceof NetworkDnsPodClientError) throw error;
throw mapProviderError(error);
}
}
/**
* Reads one exact address record and validates all provider metadata.
* @param client - Initialized DNSPod SDK boundary.
* @param input - Normalized exact-record query.
* @returns Validated record safe for comparison or mutation.
*/
private async describeExactRecord(
client: DnsPodSdkClient,
input: NormalizedReconcileInput,
): Promise<ValidatedRecord> {
const request: DescribeRecordFilterListRequest = {
Domain: input.domain,
IsExactSubDomain: true,
Limit: 2,
Offset: 0,
RecordLine: ['0'],
RecordType: [input.recordType],
SubDomain: input.subDomain,
};
const response = await client.DescribeRecordFilterList(request);
return validateRecord(response.RecordList, input);
}
/**
* Builds the official SDK configuration with bounded HTTP behavior.
* @returns DNSPod client configuration; region is intentionally omitted.
*/
private clientConfig(): ClientConfig {
return {
credential: {
secretId: this.configValue('NETWORK_DDNS_DNSPOD_SECRET_ID'),
secretKey: this.configValue('NETWORK_DDNS_DNSPOD_SECRET_KEY'),
},
profile: {
httpProfile: {
endpoint: DNSPOD_ENDPOINT,
reqTimeout: DNSPOD_REQUEST_TIMEOUT_SECONDS,
},
},
};
}
/**
* Reads one configuration value as a trimmed string.
* @param key - Runtime configuration key.
* @returns String value or an empty string when absent.
*/
private configValue(key: string): string {
const value = this.config.get<unknown>(key);
return typeof value === 'string' ? value.trim() : '';
}
}

View File

@ -22,11 +22,15 @@ import { vbenPage, vbenSuccess } from '@/common';
import { AdminSuperGuard } from '@/modules/admin/identity/auth/admin-super.guard'; import { AdminSuperGuard } from '@/modules/admin/identity/auth/admin-super.guard';
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
import { import {
NetworkDdnsListQueryDto,
NetworkDdnsRecordInputDto,
NetworkDdnsSourceOptionsQueryDto,
NetworkEndpointHistoryQueryDto, NetworkEndpointHistoryQueryDto,
NetworkPortForwardCreateDto, NetworkPortForwardCreateDto,
NetworkPortForwardListQueryDto, NetworkPortForwardListQueryDto,
NetworkPortForwardUpdateDto, NetworkPortForwardUpdateDto,
} from './network-management.dto'; } from './network-management.dto';
import { NetworkDdnsService } from './network-ddns.service';
import { NetworkManagementService } from './network-management.service'; import { NetworkManagementService } from './network-management.service';
import { NetworkManagementEventStreamService } from './network-management-event-stream.service'; import { NetworkManagementEventStreamService } from './network-management-event-stream.service';
@ -44,10 +48,12 @@ export class NetworkManagementController {
/** /**
* Creates the super-admin-only network desired-state controller. * Creates the super-admin-only network desired-state controller.
* @param service - Persisted port-forward and Agent state service. * @param service - Persisted port-forward and Agent state service.
* @param ddnsService - Persisted Tencent Cloud DNS automatic-update service.
* @param eventStream - Committed MQTT change stream exposed to Admin through SSE. * @param eventStream - Committed MQTT change stream exposed to Admin through SSE.
*/ */
constructor( constructor(
private readonly service: NetworkManagementService, private readonly service: NetworkManagementService,
private readonly ddnsService: NetworkDdnsService,
private readonly eventStream: NetworkManagementEventStreamService, private readonly eventStream: NetworkManagementEventStreamService,
) {} ) {}
@ -181,6 +187,85 @@ export class NetworkManagementController {
return vbenSuccess(await this.service.agentStatus()); return vbenSuccess(await this.service.agentStatus());
} }
/** Lists persisted automatic-DDNS bindings independently from port forwards. */
@Get('ddns/list')
@ApiOperation({ summary: '分页查询自动 DDNS' })
async listDdns(
@Query() query: NetworkDdnsListQueryDto,
@Res({ passthrough: true }) response: Response,
) {
this.noStore(response);
const page = await this.ddnsService.list(query);
return vbenPage(page.items, page.total);
}
/** Returns server-evaluated IPv4 or IPv6 source choices for one record type. */
@Get('ddns/source-options')
@ApiOperation({ summary: '查询自动 DDNS 地址来源' })
async ddnsSourceOptions(
@Query() query: NetworkDdnsSourceOptionsQueryDto,
@Res({ passthrough: true }) response: Response,
) {
this.noStore(response);
return vbenSuccess({
items: await this.ddnsService.sourceOptions(query),
});
}
/** Returns redacted Tencent Cloud DNS readiness without any credential value. */
@Get('ddns/provider-status')
@ApiOperation({ summary: '查询腾讯云云解析 DNS 状态' })
async ddnsProviderStatus(@Res({ passthrough: true }) response: Response) {
this.noStore(response);
return vbenSuccess(this.ddnsService.getProviderStatus());
}
/** Persists one asynchronous A or AAAA automatic-update binding. */
@Post('ddns')
@ApiOperation({ summary: '新增自动 DDNS' })
async createDdns(
@Body() body: NetworkDdnsRecordInputDto,
@Res({ passthrough: true }) response: Response,
) {
this.noStore(response);
return vbenSuccess(await this.ddnsService.create(body));
}
/** Replaces the editable identity and source of one DDNS binding. */
@Put('ddns/:id')
@ApiOperation({ summary: '修改自动 DDNS' })
async updateDdns(
@Param('id') id: string,
@Body() body: NetworkDdnsRecordInputDto,
@Res({ passthrough: true }) response: Response,
) {
this.noStore(response);
return vbenSuccess(await this.ddnsService.update(id, body));
}
/** Deletes only the local updater while leaving the Tencent Cloud DNS record intact. */
@Delete('ddns/:id')
@ApiOperation({ summary: '删除本地自动 DDNS' })
async removeDdns(
@Param('id') id: string,
@Res({ passthrough: true }) response: Response,
) {
this.noStore(response);
return vbenSuccess(await this.ddnsService.remove(id));
}
/** Requeues one enabled DDNS binding for immediate provider reconciliation. */
@Post('ddns/:id/retry')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '重试自动 DDNS 同步' })
async retryDdns(
@Param('id') id: string,
@Res({ passthrough: true }) response: Response,
) {
this.noStore(response);
return vbenSuccess(await this.ddnsService.retry(id));
}
/** /**
* Marks all dynamic network state responses as non-cacheable. * Marks all dynamic network state responses as non-cacheable.
* @param response - Express response receiving the fixed directive. * @param response - Express response receiving the fixed directive.

View File

@ -1,5 +1,6 @@
import { Type } from 'class-transformer'; import { Transform, Type, type TransformFnParams } from 'class-transformer';
import { import {
IsBoolean,
IsIn, IsIn,
IsInt, IsInt,
IsOptional, IsOptional,
@ -9,15 +10,54 @@ import {
Max, Max,
MaxLength, MaxLength,
Min, Min,
Validate,
ValidateIf, ValidateIf,
type ValidationArguments,
ValidatorConstraint,
type ValidatorConstraintInterface,
} from 'class-validator'; } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import type { import type {
NetworkDdnsRecordType,
NetworkDdnsSourceType,
NetworkDdnsSyncStatus,
KeeperStatus, KeeperStatus,
PortForwardProtocol, PortForwardProtocol,
PortForwardSyncStatus, PortForwardSyncStatus,
} from './network-management.types'; } from './network-management.types';
const DNS_DOMAIN_PATTERN =
/^(?=.{1,253}\.?$)(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.?$/i;
const DNS_SUB_DOMAIN_PATTERN =
/^(?:@|[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?(?:\.[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?)*)$/i;
const DECIMAL_ID_PATTERN = /^\d{1,24}$/;
@ValidatorConstraint({ async: false, name: 'networkDdnsPortForwardId' })
class NetworkDdnsPortForwardIdConstraint implements ValidatorConstraintInterface {
/**
* Enforces the A-required and AAAA-forbidden source-ID discriminated union.
* @param value - Candidate port-forward identifier.
* @param args - Validation context containing the sibling record type.
* @returns True only for an A decimal string or an omitted AAAA field.
*/
validate(value: unknown, args: ValidationArguments): boolean {
const input = args.object as { recordType?: unknown };
if (input.recordType === 'A') {
return typeof value === 'string' && DECIMAL_ID_PATTERN.test(value);
}
if (input.recordType === 'AAAA') return value === undefined;
return true;
}
/**
* Returns a stable request-contract error without echoing the supplied value.
* @returns Safe validation message.
*/
defaultMessage(): string {
return 'portForwardId is required for A and forbidden for AAAA';
}
}
export class NetworkPortForwardCreateDto { export class NetworkPortForwardCreateDto {
@ApiProperty({ maxLength: 100 }) @ApiProperty({ maxLength: 100 })
@IsString() @IsString()
@ -134,6 +174,109 @@ export class NetworkEndpointHistoryQueryDto {
pageSize?: number; pageSize?: number;
} }
export class NetworkDdnsRecordInputDto {
@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: ['A', 'AAAA'] })
@IsIn(['A', 'AAAA'])
recordType: NetworkDdnsRecordType;
@ApiProperty({ enum: ['agent_ipv6', 'port_forward_ipv4'] })
@IsIn(['agent_ipv6', 'port_forward_ipv4'])
sourceType: NetworkDdnsSourceType;
@ApiPropertyOptional({ description: 'A 记录使用的端口转发 Snowflake ID' })
@Validate(NetworkDdnsPortForwardIdConstraint)
portForwardId?: string;
@ApiProperty({ maxLength: 253 })
@IsString()
@Length(1, 253)
@Matches(DNS_DOMAIN_PATTERN)
domain: string;
@ApiProperty({ maxLength: 253 })
@IsString()
@Length(1, 253)
@Matches(DNS_SUB_DOMAIN_PATTERN)
subDomain: string;
@ApiProperty()
@IsBoolean()
enabled: boolean;
}
export class NetworkDdnsListQueryDto {
@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: ['A', 'AAAA'] })
@IsOptional()
@IsIn(['A', 'AAAA'])
recordType?: NetworkDdnsRecordType;
@ApiPropertyOptional({
enum: [
'disabled',
'failed',
'pending',
'synced',
'syncing',
'waiting_source',
],
})
@IsOptional()
@IsIn([
'disabled',
'failed',
'pending',
'synced',
'syncing',
'waiting_source',
])
syncStatus?: NetworkDdnsSyncStatus;
@ApiPropertyOptional()
@IsOptional()
@Transform(parseBooleanQuery)
@IsBoolean()
enabled?: boolean;
}
export class NetworkDdnsSourceOptionsQueryDto {
@ApiProperty({ enum: ['A', 'AAAA'] })
@IsIn(['A', 'AAAA'])
recordType: NetworkDdnsRecordType;
}
export class NetworkPortForwardResponseDto { export class NetworkPortForwardResponseDto {
@ApiProperty() @ApiProperty()
id: string; id: string;
@ -186,3 +329,14 @@ export class NetworkPortForwardResponseDto {
function isProvided(_object: object, value: unknown): boolean { function isProvided(_object: object, value: unknown): boolean {
return value !== undefined; return value !== undefined;
} }
/**
* Converts literal HTTP query booleans without treating every non-empty string as true.
* @param params - class-transformer field input.
* @returns Parsed boolean or the original invalid value for class-validator to reject.
*/
function parseBooleanQuery({ value }: TransformFnParams): unknown {
if (value === 'true' || value === true) return true;
if (value === 'false' || value === false) return false;
return value;
}

View File

@ -304,6 +304,8 @@ export class NetworkManagementService {
lastErrorCode: null, lastErrorCode: null,
lastErrorMessage: null, lastErrorMessage: null,
lastHeartbeatAt: null, lastHeartbeatAt: null,
currentIpv6ObservedAt: null,
currentPublicIpv6: null,
online: false, online: false,
publishedRevision: '0', publishedRevision: '0',
targetIpv4: this.targetIpv4(), targetIpv4: this.targetIpv4(),
@ -323,6 +325,8 @@ export class NetworkManagementService {
lastMqttErrorMessage: state.lastMqttErrorMessage || null, lastMqttErrorMessage: state.lastMqttErrorMessage || null,
lastReconcileErrorCode: state.lastReconcileErrorCode || null, lastReconcileErrorCode: state.lastReconcileErrorCode || null,
lastReconcileErrorMessage: state.lastReconcileErrorMessage || null, lastReconcileErrorMessage: state.lastReconcileErrorMessage || null,
currentIpv6ObservedAt: state.currentIpv6ObservedAt || null,
currentPublicIpv6: state.currentPublicIpv6 || null,
online: state.online, online: state.online,
publishedRevision: state.publishedRevision, publishedRevision: state.publishedRevision,
startedAt: state.startedAt || null, startedAt: state.startedAt || null,

View File

@ -1,4 +1,6 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { isIP } from 'node:net';
import type { KtDateTime } from '@/common';
import type { NetworkAgentState } from './network-agent-state.entity'; import type { NetworkAgentState } from './network-agent-state.entity';
import type { NetworkPortForward } from './network-management.entity'; import type { NetworkPortForward } from './network-management.entity';
@ -28,7 +30,11 @@ export type EndpointEventType =
| 'published' | 'published'
| 'restored' | 'restored'
| 'withdrawn'; | 'withdrawn';
export type NetworkStateChangeSource = 'events' | 'reported' | 'status'; export type NetworkStateChangeSource =
| 'ddns'
| 'events'
| 'reported'
| 'status';
export type NetworkStateChangeEvent = { export type NetworkStateChangeEvent = {
eventId: string; eventId: string;
@ -36,6 +42,51 @@ export type NetworkStateChangeEvent = {
source: NetworkStateChangeSource; source: NetworkStateChangeSource;
}; };
export type NetworkDdnsRecordType = 'A' | 'AAAA';
export type NetworkDdnsSourceType = 'agent_ipv6' | 'port_forward_ipv4';
export type NetworkDdnsSyncStatus =
| 'disabled'
| 'failed'
| 'pending'
| 'synced'
| 'syncing'
| 'waiting_source';
export type NetworkDdnsRecordInput = {
domain: string;
enabled: boolean;
name: string;
portForwardId?: string;
recordType: NetworkDdnsRecordType;
remark?: string;
sourceType: NetworkDdnsSourceType;
subDomain: string;
};
export type NetworkDdnsRecordUpdateInput = Partial<NetworkDdnsRecordInput>;
export type NetworkDdnsListQuery = {
enabled?: boolean;
name?: string;
pageNo?: number;
pageSize?: number;
recordType?: NetworkDdnsRecordType;
syncStatus?: NetworkDdnsSyncStatus;
};
export type NetworkDdnsSourceOption = {
currentAddress: null | string;
disabledReasonCode: null | string;
eligible: boolean;
externalPort?: number;
id: string;
name: string;
observedAt: null | KtDateTime;
protocol?: PortForwardProtocol;
sourceType: NetworkDdnsSourceType;
validUntil: null | KtDateTime;
};
export type NetworkDesiredMapping = { export type NetworkDesiredMapping = {
externalPort: number; externalPort: number;
id: string; id: string;
@ -98,6 +149,7 @@ export type NetworkStatusSnapshot = {
errorMessage?: string | null; errorMessage?: string | null;
observedAt: string; observedAt: string;
online: boolean; online: boolean;
publicIpv6?: string | null;
schemaVersion: typeof NETWORK_AGENT_SCHEMA_VERSION; schemaVersion: typeof NETWORK_AGENT_SCHEMA_VERSION;
startedAt?: string | null; startedAt?: string | null;
version?: string | null; version?: string | null;
@ -290,23 +342,53 @@ export function parseStatusSnapshot(value: unknown): NetworkStatusSnapshot {
const record = exactRecord( const record = exactRecord(
value, value,
['agentId', 'observedAt', 'online', 'schemaVersion'], ['agentId', 'observedAt', 'online', 'schemaVersion'],
['errorCode', 'errorMessage', 'startedAt', 'version'], ['errorCode', 'errorMessage', 'publicIpv6', 'startedAt', 'version'],
'status', 'status',
); );
assertSchema(record.schemaVersion); assertSchema(record.schemaVersion);
if (typeof record.online !== 'boolean') invalid('status.online'); if (typeof record.online !== 'boolean') invalid('status.online');
const publicIpv6 = optionalGlobalIpv6(record.publicIpv6, 'status.publicIpv6');
if (!record.online && publicIpv6) invalid('status.publicIpv6');
return { return {
agentId: boundedString(record.agentId, 'agentId', 64), agentId: boundedString(record.agentId, 'agentId', 64),
errorCode: optionalString(record.errorCode, 'errorCode', 64), errorCode: optionalString(record.errorCode, 'errorCode', 64),
errorMessage: optionalString(record.errorMessage, 'errorMessage', 500), errorMessage: optionalString(record.errorMessage, 'errorMessage', 500),
observedAt: isoString(record.observedAt, 'observedAt'), observedAt: isoString(record.observedAt, 'observedAt'),
online: record.online, online: record.online,
publicIpv6,
schemaVersion: NETWORK_AGENT_SCHEMA_VERSION, schemaVersion: NETWORK_AGENT_SCHEMA_VERSION,
startedAt: optionalIsoString(record.startedAt, 'startedAt'), startedAt: optionalIsoString(record.startedAt, 'startedAt'),
version: optionalString(record.version, 'version', 64), version: optionalString(record.version, 'version', 64),
}; };
} }
/**
* Normalizes an optional globally routable IPv6 string from the Agent.
* @param value - Omitted value or untrusted status field.
* @param label - Stable validation label used without echoing the address.
* @returns Canonical lowercase IPv6, or undefined when the field is omitted.
*/
function optionalGlobalIpv6(value: unknown, label: string): string | undefined {
if (value === undefined || value === null || value === '') return undefined;
if (typeof value !== 'string' || isIP(value) !== 6) invalid(label);
let normalized: string;
try {
const hostname = new URL(`http://[${value}]/`).hostname;
normalized = hostname.slice(1, -1).toLowerCase();
} catch {
invalid(label);
}
const firstHextet = Number.parseInt(normalized.split(':', 1)[0], 16);
if (
!Number.isInteger(firstHextet) ||
firstHextet < 0x2000 ||
firstHextet > 0x3fff
) {
invalid(label);
}
return normalized;
}
/** /**
* Parses the exact Go schema-v1 append-only endpoint event. * Parses the exact Go schema-v1 append-only endpoint event.
* @param value - Untrusted MQTT JSON value. * @param value - Untrusted MQTT JSON value.

View File

@ -31,6 +31,11 @@ const REQUIRED_CONFIG_KEYS = [
const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [ const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
'DB_TIMEZONE', 'DB_TIMEZONE',
'NETWORK_DDNS_DNSPOD_ENABLED',
'NETWORK_DDNS_DNSPOD_SECRET_ID',
'NETWORK_DDNS_DNSPOD_SECRET_KEY',
'NETWORK_DDNS_RECONCILE_INTERVAL_MS',
'NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS',
'MINIO_ENDPOINT', 'MINIO_ENDPOINT',
'MINIO_PORT', 'MINIO_PORT',
'MINIO_ACCESS_KEY', 'MINIO_ACCESS_KEY',

View File

@ -24,6 +24,7 @@ type MqttHarness = {
publishCommitted: jest.Mock; publishCommitted: jest.Mock;
mapping: NetworkPortForward; mapping: NetworkPortForward;
publishCallback: () => (error?: Error) => void; publishCallback: () => (error?: Error) => void;
requestDdnsReconcile: jest.Mock;
service: NetworkAgentMqttService; service: NetworkAgentMqttService;
state: NetworkAgentState; state: NetworkAgentState;
stateSave: jest.Mock; stateSave: jest.Mock;
@ -137,11 +138,13 @@ function createHarness(): MqttHarness {
const eventStream = { const eventStream = {
publishCommitted, publishCommitted,
} as unknown as NetworkManagementEventStreamService; } as unknown as NetworkManagementEventStreamService;
const requestDdnsReconcile = jest.fn();
const service = new NetworkAgentMqttService( const service = new NetworkAgentMqttService(
configService, configService,
dataSource, dataSource,
eventStream, eventStream,
factory, factory,
{ requestReconcile: requestDdnsReconcile } as never,
); );
return { return {
client, client,
@ -151,6 +154,7 @@ function createHarness(): MqttHarness {
mapping, mapping,
publishCallback: () => publishAck, publishCallback: () => publishAck,
publishCommitted, publishCommitted,
requestDdnsReconcile,
service, service,
state, state,
stateSave, stateSave,
@ -498,6 +502,7 @@ describe('NetworkAgentMqttService', () => {
expect(harness.stateSave).toHaveBeenCalledTimes(stateSavesAfterFirstReport); expect(harness.stateSave).toHaveBeenCalledTimes(stateSavesAfterFirstReport);
expect(harness.publishCommitted).toHaveBeenCalledTimes(1); expect(harness.publishCommitted).toHaveBeenCalledTimes(1);
expect(harness.publishCommitted).toHaveBeenCalledWith('reported'); expect(harness.publishCommitted).toHaveBeenCalledWith('reported');
expect(harness.requestDdnsReconcile).toHaveBeenCalledTimes(1);
}); });
/** Proves lease renewal is persisted without turning timestamps into page reloads. */ /** Proves lease renewal is persisted without turning timestamps into page reloads. */
@ -507,6 +512,7 @@ describe('NetworkAgentMqttService', () => {
await harness.service.consumeMessage(topic, reported(harness, 7)); await harness.service.consumeMessage(topic, reported(harness, 7));
harness.publishCommitted.mockClear(); harness.publishCommitted.mockClear();
harness.requestDdnsReconcile.mockClear();
const savesBeforeRenewal = harness.mappingSave.mock.calls.length; const savesBeforeRenewal = harness.mappingSave.mock.calls.length;
await harness.service.consumeMessage( await harness.service.consumeMessage(
topic, topic,
@ -534,6 +540,7 @@ describe('NetworkAgentMqttService', () => {
); );
expect(harness.mappingSave).toHaveBeenCalledTimes(savesBeforeRenewal + 1); expect(harness.mappingSave).toHaveBeenCalledTimes(savesBeforeRenewal + 1);
expect(harness.publishCommitted).not.toHaveBeenCalled(); expect(harness.publishCommitted).not.toHaveBeenCalled();
expect(harness.requestDdnsReconcile).not.toHaveBeenCalled();
await harness.service.consumeMessage( await harness.service.consumeMessage(
topic, topic,
@ -548,6 +555,7 @@ describe('NetworkAgentMqttService', () => {
); );
expect(harness.publishCommitted).toHaveBeenCalledTimes(1); expect(harness.publishCommitted).toHaveBeenCalledTimes(1);
expect(harness.publishCommitted).toHaveBeenCalledWith('reported'); expect(harness.publishCommitted).toHaveBeenCalledWith('reported');
expect(harness.requestDdnsReconcile).not.toHaveBeenCalled();
}); });
it('does not let an out-of-order same-revision withdrawal erase a newer lease', async () => { it('does not let an out-of-order same-revision withdrawal erase a newer lease', async () => {
@ -786,6 +794,7 @@ describe('NetworkAgentMqttService', () => {
); );
expect(harness.stateSave).toHaveBeenCalledTimes(savesBeforeHeartbeat + 1); expect(harness.stateSave).toHaveBeenCalledTimes(savesBeforeHeartbeat + 1);
expect(harness.publishCommitted).not.toHaveBeenCalled(); expect(harness.publishCommitted).not.toHaveBeenCalled();
expect(harness.requestDdnsReconcile).not.toHaveBeenCalled();
await harness.service.consumeMessage( await harness.service.consumeMessage(
topic, topic,
@ -793,6 +802,44 @@ describe('NetworkAgentMqttService', () => {
); );
expect(harness.publishCommitted).toHaveBeenCalledTimes(1); expect(harness.publishCommitted).toHaveBeenCalledTimes(1);
expect(harness.publishCommitted).toHaveBeenCalledWith('status'); expect(harness.publishCommitted).toHaveBeenCalledWith('status');
expect(harness.requestDdnsReconcile).not.toHaveBeenCalled();
});
/** Proves only public IPv6 semantic changes wake automatic DDNS. */
it('requests DDNS reconciliation for IPv6 changes but not identical heartbeats', async () => {
const harness = createHarness();
const topic = 'kt/network/v1/agents/nas-main/status';
const status = (observedAt: string, publicIpv6: string) =>
Buffer.from(
JSON.stringify({
agentId: 'nas-main',
observedAt,
online: true,
publicIpv6,
schemaVersion: 1,
startedAt: '2026-07-22T01:00:00.000Z',
version: '0.1.0',
}),
);
await harness.service.consumeMessage(
topic,
status('2026-07-22T01:01:00.000Z', '2409:8a31::1'),
);
expect(harness.requestDdnsReconcile).toHaveBeenCalledTimes(1);
harness.requestDdnsReconcile.mockClear();
await harness.service.consumeMessage(
topic,
status('2026-07-22T01:02:00.000Z', '2409:8a31::1'),
);
expect(harness.requestDdnsReconcile).not.toHaveBeenCalled();
await harness.service.consumeMessage(
topic,
status('2026-07-22T01:03:00.000Z', '2409:8a31::2'),
);
expect(harness.requestDdnsReconcile).toHaveBeenCalledTimes(1);
}); });
it('accepts a same-instance LWT without regressing heartbeat and ignores an old-instance LWT', async () => { it('accepts a same-instance LWT without regressing heartbeat and ignores an old-instance LWT', async () => {

View File

@ -0,0 +1,200 @@
import { readFileSync } from 'fs';
import { resolve } from 'path';
const REPO_ROOT = resolve(__dirname, '../../..');
/**
* Reads one repository SQL artifact as normalized lowercase text.
* @param relativePath - Path relative to the API repository root.
* @returns SQL text with identifier quotes and repeated whitespace removed.
*/
function readNormalizedSql(relativePath: string): string {
return readFileSync(resolve(REPO_ROOT, relativePath), 'utf8')
.replace(/`/g, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
/**
* Extracts one CREATE TABLE body from normalized SQL.
* @param sql - Normalized SQL text.
* @param tableName - Table whose declaration is required.
* @returns CREATE TABLE body used by focused schema assertions.
*/
function extractCreateTable(sql: string, tableName: string): string {
const match = sql.match(
new RegExp(
`create table if not exists ${tableName} \\(([\\s\\S]*?)\\) engine=innodb`,
),
);
expect(match).not.toBeNull();
return match?.[1] ?? '';
}
/**
* Asserts the complete DDNS table contract shared by incremental and bootstrap SQL.
* @param sql - Normalized SQL containing the network DDNS table.
* @returns Nothing; Jest assertions fail when one schema fragment drifts.
*/
function expectDdnsTableContract(sql: string): void {
const table = extractCreateTable(sql, 'network_ddns_record');
const requiredColumns = [
'id bigint not null',
'name varchar(100) not null',
'remark text null',
'record_type varchar(8) not null',
'source_type varchar(32) not null',
'port_forward_id bigint null',
'domain varchar(253) not null',
'sub_domain varchar(253) not null',
'active_key varchar(300) null',
'enabled tinyint(1) not null default 0',
"sync_status varchar(32) not null default 'disabled'",
'provider_record_id varchar(32) null',
'source_address varchar(45) null',
'applied_address varchar(45) null',
'retry_count int unsigned not null default 0',
'next_retry_at datetime(3) null',
'last_attempt_at datetime(3) null',
'last_synced_at datetime(3) null',
'last_error_code varchar(64) null',
'last_error_message varchar(512) null',
'is_deleted tinyint(1) not null default 0',
'create_time datetime(3) not null default current_timestamp(3)',
'update_time datetime(3) not null default current_timestamp(3) on update current_timestamp(3)',
];
for (const column of requiredColumns) {
expect(table).toContain(column);
}
expect(table).toContain('primary key');
expect(table).toContain(
'unique key uk_network_ddns_record_active_key (active_key)',
);
expect(table).toContain(
'key idx_network_ddns_record_status (is_deleted, enabled, sync_status, next_retry_at)',
);
expect(table).toContain(
'key idx_network_ddns_record_port_forward (port_forward_id)',
);
}
describe('Network DDNS SQL contract', () => {
it('keeps the DDNS table aligned in incremental and refactor-v3 bootstrap schemas', () => {
expectDdnsTableContract(
readNormalizedSql('sql/network-management-init.sql'),
);
expectDdnsTableContract(
readNormalizedSql('sql/refactor-v3/00-full-schema.sql'),
);
});
it('adds idempotent Agent IPv6 columns and verifies the schema indexes', () => {
const incremental = readNormalizedSql('sql/network-management-init.sql');
const fullSchema = readNormalizedSql('sql/refactor-v3/00-full-schema.sql');
const verify = readNormalizedSql('sql/refactor-v3/99-verify.sql');
for (const sql of [incremental, fullSchema]) {
const agentState = extractCreateTable(sql, 'network_agent_state');
expect(agentState).toContain('current_public_ipv6 varchar(45) null');
expect(agentState).toContain('current_ipv6_observed_at datetime(3) null');
}
expect(incremental).toContain(
'alter table network_agent_state add column current_public_ipv6 varchar(45) null',
);
expect(incremental).toContain(
'alter table network_agent_state add column current_ipv6_observed_at datetime(3) null',
);
expect(incremental).toContain(
"table_name = 'network_agent_state' and column_name = 'current_public_ipv6'",
);
expect(incremental).toContain(
"table_name = 'network_agent_state' and column_name = 'current_ipv6_observed_at'",
);
expect(verify).toContain(
"select 'network_ddns_record' as table_name, count(*) as row_count from network_ddns_record",
);
expect(verify).toContain(
"column_name = 'current_public_ipv6' and column_type = 'varchar(45)'",
);
expect(verify).toContain(
"column_name = 'current_ipv6_observed_at' and column_type = 'datetime(3)'",
);
expect(verify).toContain(
"index_name = 'uk_network_ddns_record_active_key'",
);
expect(verify).toContain("index_name = 'idx_network_ddns_record_status'");
expect(verify).toContain(
"index_name = 'idx_network_ddns_record_port_forward'",
);
});
it('mirrors stable DDNS permissions and restricts grants to enabled super roles', () => {
const menu = readNormalizedSql('sql/network-management-menu.sql');
const seed = readNormalizedSql('sql/refactor-v3/01-seed-core.sql');
const vben = readNormalizedSql('sql/vben-admin-init.sql');
const incrementalNonSuperBlock = menu.match(
/where role\.role_code <> 'super' and menu\.name in \(([\s\S]*?)\);/,
)?.[1];
const incrementalSuperBlock = menu.match(
/join admin_menu menu on menu\.name in \(([\s\S]*?)\) where role\.role_code = 'super'/,
)?.[1];
const vbenNonSuperBlock = vben.match(
/and name not in \(([\s\S]*?)\);/,
)?.[1];
const permissions = [
{
code: 'system:network:ddns:list',
id: '2041700000000120222',
name: 'systemnetworkddnslist',
},
{
code: 'system:network:ddns:create',
id: '2041700000000120223',
name: 'systemnetworkddnscreate',
},
{
code: 'system:network:ddns:update',
id: '2041700000000120224',
name: 'systemnetworkddnsupdate',
},
{
code: 'system:network:ddns:delete',
id: '2041700000000120225',
name: 'systemnetworkddnsdelete',
},
{
code: 'system:network:ddns:retry',
id: '2041700000000120226',
name: 'systemnetworkddnsretry',
},
];
for (const sql of [menu, seed, vben]) {
for (const permission of permissions) {
expect(sql).toContain(permission.id);
expect(sql).toContain(permission.name);
expect(sql).toContain(permission.code);
}
}
expect(menu).toContain("where role.role_code <> 'super'");
expect(menu).toContain("where role.role_code = 'super'");
expect(menu).toContain('and role.status = 1');
expect(menu).toContain('and role.is_deleted = 0');
expect(incrementalNonSuperBlock).toBeDefined();
expect(incrementalSuperBlock).toBeDefined();
expect(vbenNonSuperBlock).toBeDefined();
for (const permission of permissions) {
expect(incrementalNonSuperBlock).toContain(permission.name);
expect(incrementalSuperBlock).toContain(permission.name);
expect(vbenNonSuperBlock).toContain(permission.name);
}
});
});

View File

@ -0,0 +1,604 @@
import { HttpException } from '@nestjs/common';
import type { ConfigService } from '@nestjs/config';
import type { Repository } from 'typeorm';
import { KtDateTime } from '../../../src/common';
import { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity';
import { NetworkDdnsRecord } from '../../../src/modules/admin/platform-config/network-management/network-ddns.entity';
import { NetworkDdnsService } from '../../../src/modules/admin/platform-config/network-management/network-ddns.service';
import {
NetworkDnsPodClient,
NetworkDnsPodClientError,
} from '../../../src/modules/admin/platform-config/network-management/network-dnspod.client';
import type { NetworkManagementEventStreamService } from '../../../src/modules/admin/platform-config/network-management/network-management-event-stream.service';
import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity';
type Harness = {
client: jest.Mocked<Pick<NetworkDnsPodClient, 'getStatus' | 'reconcile'>>;
mapping: NetworkPortForward;
recordUpdate: jest.Mock;
records: NetworkDdnsRecord[];
service: NetworkDdnsService;
state: NetworkAgentState;
};
/**
* Clones one entity to model TypeORM snapshots instead of shared object identity.
* @param record - In-memory database row.
* @returns Detached entity snapshot.
*/
function cloneRecord(record: NetworkDdnsRecord): NetworkDdnsRecord {
return Object.assign(new NetworkDdnsRecord(), record);
}
/**
* Matches a repository update criterion, including TypeORM's IsNull operator.
* @param actual - Stored entity field value.
* @param expected - Plain value or FindOperator-like criterion.
* @returns True when the in-memory row satisfies the criterion.
*/
function matchesUpdateCriterion(actual: unknown, expected: unknown): boolean {
if (
expected &&
typeof expected === 'object' &&
(expected as { _type?: string })._type === 'isNull'
) {
return actual === null || actual === undefined;
}
if (actual instanceof Date && expected instanceof Date) {
return actual.getTime() === expected.getTime();
}
return actual === expected;
}
/** Creates an in-memory repository boundary around the real DDNS service. */
function createHarness(): Harness {
const records: NetworkDdnsRecord[] = [];
const mapping = Object.assign(new NetworkPortForward(), {
currentObservedAt: new KtDateTime('2026-07-23T01:00:00.000Z'),
currentPublicIpv4: '8.8.8.8',
currentPublicPort: 45_678,
currentValidUntil: new KtDateTime('2026-07-23T02:00:00.000Z'),
desiredPresence: 'present',
externalPort: 45_678,
id: '100',
internalPort: 45_678,
isDeleted: false,
keeperDesiredEnabled: true,
name: 'Public UDP',
protocol: 'udp',
});
const state = Object.assign(new NetworkAgentState(), {
agentId: 'nas-main',
currentIpv6ObservedAt: new KtDateTime('2026-07-23T01:00:00.000Z'),
currentPublicIpv6: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
lastHeartbeatAt: new KtDateTime('2026-07-23T01:00:00.000Z'),
online: true,
});
const recordUpdate = jest.fn(
async (
criteria: Record<string, unknown>,
patch: Partial<NetworkDdnsRecord>,
) => {
const index = records.findIndex((record) =>
Object.entries(criteria).every(([key, expected]) =>
matchesUpdateCriterion(
(record as unknown as Record<string, unknown>)[key],
expected,
),
),
);
if (index < 0) {
return { affected: 0, generatedMaps: [], raw: [] };
}
records[index] = Object.assign(
new NetworkDdnsRecord(),
records[index],
patch,
);
return { affected: 1, generatedMaps: [], raw: [] };
},
);
const recordRepository = {
count: async () => records.filter((record) => !record.isDeleted).length,
create: (input) =>
Object.assign(
new NetworkDdnsRecord(),
{ id: String(200 + records.length) },
input,
),
createQueryBuilder: () => createListBuilder(records),
find: async ({ where } = {} as any) =>
records
.filter((record) =>
Object.entries(where || {}).every(
([key, value]) => record[key] === value,
),
)
.map(cloneRecord),
findOne: async ({ where }) => {
const record = records.find((item) =>
Object.entries(where).every(([key, value]) => item[key] === value),
);
return record ? cloneRecord(record) : null;
},
save: async (record) => {
const now = new KtDateTime();
record.createTime ||= now;
record.updateTime = now;
const index = records.findIndex((item) => item.id === record.id);
if (index >= 0) records[index] = cloneRecord(record);
else records.push(cloneRecord(record));
return record;
},
update: recordUpdate,
} as unknown as Repository<NetworkDdnsRecord>;
const mappingRepository = {
find: async () => [mapping],
findOne: async ({ where }) =>
where.id === mapping.id && !mapping.isDeleted ? mapping : null,
} as unknown as Repository<NetworkPortForward>;
const stateRepository = {
findOne: async () => state,
} as unknown as Repository<NetworkAgentState>;
const config = {
get: (key: string) => {
const values = {
NETWORK_AGENT_ID: 'nas-main',
NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS: '60000',
NETWORK_DDNS_RECONCILE_INTERVAL_MS: '60000',
};
return values[key];
},
} as ConfigService;
const client = {
getStatus: jest.fn(() => ({
configured: true,
enabled: true,
provider: 'dnspod' as const,
})),
reconcile: jest.fn(),
};
const eventStream = {
publishCommitted: jest.fn(),
} as unknown as NetworkManagementEventStreamService;
const service = new NetworkDdnsService(
recordRepository,
mappingRepository,
stateRepository,
config,
client as unknown as NetworkDnsPodClient,
eventStream,
);
return { client, mapping, recordUpdate, records, service, state };
}
/** Creates the fluent list query subset used by the service. */
function createListBuilder(records: NetworkDdnsRecord[]) {
const builder = {
andWhere: () => builder,
getManyAndCount: async () => [
records.filter((record) => !record.isDeleted),
records.filter((record) => !record.isDeleted).length,
],
orderBy: () => builder,
skip: () => builder,
take: () => builder,
where: () => builder,
};
return builder;
}
/** Reads a Nest HTTP status from one rejected service operation. */
function errorStatus(error: unknown): number {
return error instanceof HttpException ? error.getStatus() : 0;
}
describe('NetworkDdnsService', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date('2026-07-23T01:00:30.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('returns server-classified IPv4 and Agent IPv6 source options', async () => {
await expect(
createHarness().service.sourceOptions({ recordType: 'A' }),
).resolves.toEqual([
expect.objectContaining({
currentAddress: '8.8.8.8',
eligible: true,
id: '100',
sourceType: 'port_forward_ipv4',
}),
]);
await expect(
createHarness().service.sourceOptions({ recordType: 'AAAA' }),
).resolves.toEqual([
expect.objectContaining({
currentAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
eligible: true,
id: 'agent-ipv6',
sourceType: 'agent_ipv6',
}),
]);
});
it('never exposes a residual lease from an ineligible IPv4 source', async () => {
const harness = createHarness();
harness.mapping.keeperDesiredEnabled = false;
await expect(
harness.service.sourceOptions({ recordType: 'A' }),
).resolves.toEqual([
expect.objectContaining({
currentAddress: null,
disabledReasonCode: 'KEEPER_DISABLED',
eligible: false,
observedAt: null,
validUntil: null,
}),
]);
});
it('normalizes one disabled A binding without exposing provider identity input', async () => {
const harness = createHarness();
await expect(
harness.service.create({
domain: ' KWITSUKASA.TOP. ',
enabled: false,
name: ' Pal A ',
portForwardId: '100',
recordType: 'A',
remark: ' game ',
sourceType: 'port_forward_ipv4',
subDomain: ' PAL ',
}),
).resolves.toMatchObject({
domain: 'kwitsukasa.top',
enabled: false,
fqdn: 'pal.kwitsukasa.top',
name: 'Pal A',
recordType: 'A',
sourceType: 'port_forward_ipv4',
syncStatus: 'disabled',
});
expect(harness.records[0].providerRecordId).toBeNull();
expect(harness.client.reconcile).not.toHaveBeenCalled();
});
it('rejects record/source family mismatches before persistence', async () => {
const harness = createHarness();
await harness.service
.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'bad',
portForwardId: '100',
recordType: 'AAAA',
sourceType: 'port_forward_ipv4',
subDomain: 'nas6',
})
.catch((error) => expect(errorStatus(error)).toBe(400));
expect(harness.records).toHaveLength(0);
});
it('reconciles A and AAAA addresses without ever using a port', async () => {
const harness = createHarness();
harness.client.reconcile
.mockResolvedValueOnce({
appliedAddress: '8.8.8.8',
changed: true,
providerRecordId: '300',
})
.mockResolvedValueOnce({
appliedAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
changed: false,
providerRecordId: '301',
});
await harness.service.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'Pal A',
portForwardId: '100',
recordType: 'A',
sourceType: 'port_forward_ipv4',
subDomain: 'pal',
});
await harness.service.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'NAS AAAA',
recordType: 'AAAA',
sourceType: 'agent_ipv6',
subDomain: 'nas6',
});
harness.records[0].enabled = true;
harness.records[0].syncStatus = 'pending';
harness.records[1].enabled = true;
harness.records[1].syncStatus = 'pending';
await harness.service.reconcileNow(undefined, true);
expect(harness.client.reconcile).toHaveBeenNthCalledWith(
1,
expect.objectContaining({
domain: 'kwitsukasa.top',
recordType: 'A',
subDomain: 'pal',
targetAddress: '8.8.8.8',
}),
);
expect(harness.client.reconcile).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
recordType: 'AAAA',
targetAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
}),
);
expect(JSON.stringify(harness.client.reconcile.mock.calls)).not.toContain(
'45678',
);
expect(harness.records.map((record) => record.syncStatus)).toEqual([
'synced',
'synced',
]);
});
it('waits without calling DNSPod when the IPv6 source is stale', async () => {
const harness = createHarness();
harness.state.currentIpv6ObservedAt = new KtDateTime(
'2026-07-23T00:00:00.000Z',
);
await harness.service.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'NAS AAAA',
recordType: 'AAAA',
sourceType: 'agent_ipv6',
subDomain: 'nas6',
});
harness.records[0].enabled = true;
harness.records[0].syncStatus = 'pending';
await harness.service.reconcileNow('200', true);
expect(harness.records[0]).toMatchObject({
appliedAddress: null,
sourceAddress: null,
syncStatus: 'waiting_source',
});
expect(harness.client.reconcile).not.toHaveBeenCalled();
});
it('waits without calling the provider when an IPv4 source keeps an ineligible residual lease', async () => {
const harness = createHarness();
await harness.service.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'Pal A',
portForwardId: '100',
recordType: 'A',
sourceType: 'port_forward_ipv4',
subDomain: 'pal',
});
harness.records[0].enabled = true;
harness.records[0].syncStatus = 'pending';
harness.mapping.keeperDesiredEnabled = false;
await harness.service.reconcileNow('200', true);
expect(harness.records[0]).toMatchObject({
sourceAddress: null,
syncStatus: 'waiting_source',
});
expect(harness.client.reconcile).not.toHaveBeenCalled();
});
it('queues a fresh reconcile when the source changes during provider I/O', async () => {
const harness = createHarness();
let providerStarted!: () => void;
let resolveFirst!: (result: {
appliedAddress: string;
changed: boolean;
providerRecordId: string;
}) => void;
const started = new Promise<void>((resolve) => {
providerStarted = resolve;
});
const firstResult = new Promise<{
appliedAddress: string;
changed: boolean;
providerRecordId: string;
}>((resolve) => {
resolveFirst = resolve;
});
harness.client.reconcile
.mockImplementationOnce(() => {
providerStarted();
return firstResult;
})
.mockResolvedValueOnce({
appliedAddress: '9.9.9.9',
changed: true,
providerRecordId: '300',
});
await harness.service.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'Pal A',
portForwardId: '100',
recordType: 'A',
sourceType: 'port_forward_ipv4',
subDomain: 'pal',
});
harness.records[0].enabled = true;
harness.records[0].syncStatus = 'pending';
const firstReconcile = harness.service.reconcileNow('200', true);
await started;
harness.mapping.currentPublicIpv4 = '9.9.9.9';
resolveFirst({
appliedAddress: '8.8.8.8',
changed: true,
providerRecordId: '300',
});
await firstReconcile;
await harness.service.reconcileNow('200');
expect(harness.client.reconcile).toHaveBeenCalledTimes(2);
expect(harness.client.reconcile.mock.calls.map(([input]) => input)).toEqual(
[
expect.objectContaining({ targetAddress: '8.8.8.8' }),
expect.objectContaining({ targetAddress: '9.9.9.9' }),
],
);
expect(harness.records[0]).toMatchObject({
appliedAddress: '9.9.9.9',
sourceAddress: '9.9.9.9',
syncStatus: 'synced',
});
});
it('drops a stale CAS write and reconciles only the newly committed DNS identity', async () => {
const harness = createHarness();
harness.client.reconcile.mockResolvedValue({
appliedAddress: '8.8.8.8',
changed: true,
providerRecordId: '300',
});
await harness.service.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'Pal A',
portForwardId: '100',
recordType: 'A',
sourceType: 'port_forward_ipv4',
subDomain: 'pal',
});
harness.records[0].enabled = true;
harness.records[0].syncStatus = 'pending';
harness.recordUpdate.mockImplementationOnce(async () => {
harness.records[0].activeKey = 'a:pal2.kwitsukasa.top';
harness.records[0].subDomain = 'pal2';
harness.records[0].syncStatus = 'pending';
harness.records[0].updateTime = new KtDateTime(
harness.records[0].updateTime.getTime() + 10,
);
return { affected: 0, generatedMaps: [], raw: [] };
});
await harness.service.reconcileNow('200', true);
await harness.service.reconcileNow('200');
expect(harness.client.reconcile).toHaveBeenCalledTimes(1);
expect(harness.client.reconcile).toHaveBeenCalledWith(
expect.objectContaining({ subDomain: 'pal2' }),
);
expect(harness.records[0]).toMatchObject({
appliedAddress: '8.8.8.8',
subDomain: 'pal2',
syncStatus: 'synced',
});
});
it('serializes a concurrent delete behind provider I/O without resurrecting the row', async () => {
const harness = createHarness();
let providerStarted!: () => void;
let resolveProvider!: (result: {
appliedAddress: string;
changed: boolean;
providerRecordId: string;
}) => void;
const started = new Promise<void>((resolve) => {
providerStarted = resolve;
});
const providerResult = new Promise<{
appliedAddress: string;
changed: boolean;
providerRecordId: string;
}>((resolve) => {
resolveProvider = resolve;
});
harness.client.reconcile.mockImplementation(() => {
providerStarted();
return providerResult;
});
await harness.service.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'Pal A',
portForwardId: '100',
recordType: 'A',
sourceType: 'port_forward_ipv4',
subDomain: 'pal',
});
harness.records[0].enabled = true;
harness.records[0].syncStatus = 'pending';
const reconcile = harness.service.reconcileNow('200', true);
await started;
let deleteSettled = false;
const remove = harness.service.remove('200').then((result) => {
deleteSettled = true;
return result;
});
await Promise.resolve();
expect(deleteSettled).toBe(false);
resolveProvider({
appliedAddress: '8.8.8.8',
changed: true,
providerRecordId: '300',
});
await reconcile;
await remove;
expect(harness.records[0]).toMatchObject({
activeKey: null,
enabled: false,
isDeleted: true,
syncStatus: 'disabled',
});
expect(harness.client.reconcile).toHaveBeenCalledTimes(1);
});
it('persists a safe bounded retry after a retryable provider failure', async () => {
const harness = createHarness();
harness.client.reconcile.mockRejectedValue(
new NetworkDnsPodClientError(
'DNSPOD_RATE_LIMITED',
'DNSPod 请求受限,请稍后重试',
true,
),
);
await harness.service.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'Pal A',
portForwardId: '100',
recordType: 'A',
sourceType: 'port_forward_ipv4',
subDomain: 'pal',
});
harness.records[0].enabled = true;
harness.records[0].syncStatus = 'pending';
await harness.service.reconcileNow('200', true);
expect(harness.records[0]).toMatchObject({
lastErrorCode: 'provider_rate_limited',
retryCount: 1,
sourceAddress: '8.8.8.8',
syncStatus: 'failed',
});
expect(harness.records[0].nextRetryAt).toBeInstanceOf(Date);
expect(JSON.stringify(harness.records[0])).not.toMatch(
/secret|credential|raw provider/i,
);
});
});

View File

@ -0,0 +1,583 @@
import type { ConfigService } from '@nestjs/config';
import type { ClientConfig } from 'tencentcloud-sdk-nodejs/tencentcloud/common/interface';
import {
NetworkDnsPodClient,
NetworkDnsPodClientError,
type NetworkDnsPodReconcileInput,
} from '../../../src/modules/admin/platform-config/network-management/network-dnspod.client';
type DescribeRecordFilterListRequest = {
Domain: string;
IsExactSubDomain: boolean;
Limit: number;
Offset: number;
RecordLine: string[];
RecordType: string[];
SubDomain: string;
};
type ModifyDynamicDNSRequest = {
Domain: string;
RecordId: number;
RecordLine: string;
RecordLineId: string;
SubDomain: string;
Ttl: number;
Value: string;
};
type MockRecord = {
Line?: string;
LineId?: string;
Name?: string;
RecordId?: number;
Status?: string;
TTL?: number;
Type?: string;
Value?: string;
};
type MockDnsPodSdkClient = {
DescribeRecordFilterList: jest.Mock<
Promise<{ RecordList?: MockRecord[] }>,
[DescribeRecordFilterListRequest]
>;
ModifyDynamicDNS: jest.Mock<
Promise<Record<string, never>>,
[ModifyDynamicDNSRequest]
>;
};
const enabledConfig = {
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
NETWORK_DDNS_DNSPOD_SECRET_ID: 'test-secret-id',
NETWORK_DDNS_DNSPOD_SECRET_KEY: 'test-secret-key',
};
/**
* Creates a ConfigService-compatible readonly key/value reader.
* @param values - Runtime values exposed to the client under test.
* @returns Minimal ConfigService mock.
*/
function createConfig(
values: Record<string, string | undefined>,
): ConfigService {
return {
get: jest.fn((key: string) => values[key]),
} as unknown as ConfigService;
}
/**
* Creates a DNSPod SDK mock with successful defaults.
* @returns Mocked DescribeRecordFilterList and ModifyDynamicDNS methods.
*/
function createSdkClient(): MockDnsPodSdkClient {
return {
DescribeRecordFilterList: jest.fn(),
ModifyDynamicDNS: jest.fn().mockResolvedValue({}),
};
}
/**
* Creates a valid enabled DNSPod record.
* @param overrides - Record fields replaced for a focused test.
* @returns Provider record suitable for DescribeRecordFilterList.
*/
function createRecord(overrides: MockRecord = {}): MockRecord {
return {
Line: '默认',
LineId: '0',
Name: 'nas',
RecordId: 123,
Status: 'ENABLE',
TTL: 600,
Type: 'A',
Value: '198.51.100.10',
...overrides,
};
}
/**
* Creates an initialized client and captures SDK factory calls.
* @param sdkClient - SDK behavior used by the test.
* @param values - Runtime configuration overrides.
* @returns Client under test and its SDK factory mock.
*/
function createClient(
sdkClient = createSdkClient(),
values: Record<string, string | undefined> = enabledConfig,
) {
const factory = jest.fn<MockDnsPodSdkClient, [ClientConfig]>(() => sdkClient);
return {
client: new NetworkDnsPodClient(createConfig(values), factory),
factory,
sdkClient,
};
}
const ipv4Input: NetworkDnsPodReconcileInput = {
domain: 'kwitsukasa.top',
recordType: 'A',
subDomain: 'nas',
targetAddress: '198.51.100.10',
};
describe('NetworkDnsPodClient', () => {
it('reports disabled or missing credentials without creating an SDK client', async () => {
const disabled = createClient(createSdkClient(), {
...enabledConfig,
NETWORK_DDNS_DNSPOD_ENABLED: 'false',
});
expect(disabled.client.getStatus()).toEqual({
configured: true,
enabled: false,
provider: 'dnspod',
});
await expect(disabled.client.reconcile(ipv4Input)).rejects.toMatchObject({
code: 'DNSPOD_DISABLED',
retryable: false,
});
expect(disabled.factory).not.toHaveBeenCalled();
const missing = createClient(createSdkClient(), {
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
NETWORK_DDNS_DNSPOD_SECRET_ID: '',
NETWORK_DDNS_DNSPOD_SECRET_KEY: undefined,
});
expect(missing.client.getStatus()).toEqual({
configured: false,
enabled: true,
provider: 'dnspod',
});
await expect(missing.client.reconcile(ipv4Input)).rejects.toMatchObject({
code: 'DNSPOD_NOT_CONFIGURED',
retryable: false,
});
expect(missing.factory).not.toHaveBeenCalled();
});
it('uses the exact root-host IPv4 list request and a bounded SDK profile', async () => {
const { client, factory, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValue({
RecordList: [
createRecord({
Name: '@',
Value: '198.51.100.10',
}),
],
});
await expect(
client.reconcile({
...ipv4Input,
subDomain: '@',
}),
).resolves.toEqual({
appliedAddress: '198.51.100.10',
changed: false,
providerRecordId: '123',
});
expect(sdkClient.DescribeRecordFilterList).toHaveBeenCalledWith({
Domain: 'kwitsukasa.top',
IsExactSubDomain: true,
Limit: 2,
Offset: 0,
RecordLine: ['0'],
RecordType: ['A'],
SubDomain: '@',
});
expect(sdkClient.ModifyDynamicDNS).not.toHaveBeenCalled();
expect(factory).toHaveBeenCalledTimes(1);
const config = factory.mock.calls[0][0];
expect(config.region).toBeUndefined();
expect(config.profile?.httpProfile).toEqual({
endpoint: 'dnspod.tencentcloudapi.com',
reqTimeout: 10,
});
});
it('uses the exact normal-host IPv6 list request and canonicalizes equality', async () => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValue({
RecordList: [
createRecord({
Name: 'nas6',
Type: 'AAAA',
Value: '2409:8a31:05e1:6020:a5ea:838e:843f:be5e',
}),
],
});
await expect(
client.reconcile({
domain: 'kwitsukasa.top',
recordType: 'AAAA',
subDomain: 'nas6',
targetAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
}),
).resolves.toEqual({
appliedAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
changed: false,
providerRecordId: '123',
});
expect(sdkClient.DescribeRecordFilterList).toHaveBeenCalledWith({
Domain: 'kwitsukasa.top',
IsExactSubDomain: true,
Limit: 2,
Offset: 0,
RecordLine: ['0'],
RecordType: ['AAAA'],
SubDomain: 'nas6',
});
expect(sdkClient.ModifyDynamicDNS).not.toHaveBeenCalled();
});
it('updates IPv4 without changing record metadata and verifies the read-back', async () => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValueOnce({
RecordList: [
createRecord({
LineId: '10=1',
Value: '198.51.100.9',
}),
],
}).mockResolvedValueOnce({
RecordList: [
createRecord({
LineId: '10=1',
Value: '198.51.100.10',
}),
],
});
await expect(
client.reconcile({
...ipv4Input,
expectedRecordId: '123',
}),
).resolves.toEqual({
appliedAddress: '198.51.100.10',
changed: true,
providerRecordId: '123',
});
expect(sdkClient.ModifyDynamicDNS).toHaveBeenCalledTimes(1);
expect(sdkClient.ModifyDynamicDNS).toHaveBeenCalledWith({
Domain: 'kwitsukasa.top',
RecordId: 123,
RecordLine: '默认',
RecordLineId: '10=1',
SubDomain: 'nas',
Ttl: 600,
Value: '198.51.100.10',
});
expect(sdkClient.DescribeRecordFilterList).toHaveBeenCalledTimes(2);
});
it('updates IPv6 without changing record metadata and verifies the read-back', async () => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValueOnce({
RecordList: [
createRecord({
Name: 'nas6',
Type: 'AAAA',
Value: '2409:8a31:5e1:6020::1',
}),
],
}).mockResolvedValueOnce({
RecordList: [
createRecord({
Name: 'nas6',
Type: 'AAAA',
Value: '2409:8a31:5e1:6020::2',
}),
],
});
await expect(
client.reconcile({
domain: 'kwitsukasa.top',
expectedRecordId: '123',
recordType: 'AAAA',
subDomain: 'nas6',
targetAddress: '2409:8a31:5e1:6020::2',
}),
).resolves.toEqual({
appliedAddress: '2409:8a31:5e1:6020::2',
changed: true,
providerRecordId: '123',
});
expect(sdkClient.ModifyDynamicDNS).toHaveBeenCalledWith({
Domain: 'kwitsukasa.top',
RecordId: 123,
RecordLine: '默认',
RecordLineId: '0',
SubDomain: 'nas6',
Ttl: 600,
Value: '2409:8a31:5e1:6020::2',
});
});
it.each([
['missing record', [], 'DNSPOD_RECORD_NOT_FOUND'],
[
'ambiguous records',
[createRecord(), createRecord({ RecordId: 124 })],
'DNSPOD_RECORD_AMBIGUOUS',
],
[
'disabled record',
[createRecord({ Status: 'DISABLE' })],
'DNSPOD_RECORD_DISABLED',
],
[
'wrong record type',
[createRecord({ Type: 'CNAME' })],
'DNSPOD_RECORD_INVALID',
],
['wrong line', [createRecord({ Line: '境外' })], 'DNSPOD_RECORD_INVALID'],
[
'unsafe record ID',
[createRecord({ RecordId: Number.MAX_SAFE_INTEGER + 1 })],
'DNSPOD_RECORD_INVALID',
],
[
'different expected record ID',
[createRecord({ RecordId: 124 })],
'DNSPOD_RECORD_MISMATCH',
],
[
'wrong address family',
[createRecord({ Value: '2409:8a31:5e1:6020::1' })],
'DNSPOD_RECORD_INVALID',
],
])('fails closed for %s', async (_name, records, code) => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockResolvedValue({
RecordList: records,
});
await expect(
client.reconcile({
...ipv4Input,
expectedRecordId: code === 'DNSPOD_RECORD_MISMATCH' ? '123' : undefined,
}),
).rejects.toMatchObject({
code,
retryable: false,
});
expect(sdkClient.ModifyDynamicDNS).not.toHaveBeenCalled();
});
it.each([
[{ ...ipv4Input, domain: 'https://kwitsukasa.top' }],
[{ ...ipv4Input, domain: 'kwitsukasa.top:53' }],
[{ ...ipv4Input, subDomain: '' }],
[{ ...ipv4Input, subDomain: '*.nas' }],
[{ ...ipv4Input, targetAddress: '198.51.100.10:8211' }],
[
{
...ipv4Input,
recordType: 'A' as const,
targetAddress: '2409:8a31:5e1:6020::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: '198.51.100.10',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: '::',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: '::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: 'fe80::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: 'fd00::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: 'ff02::1',
},
],
[
{
...ipv4Input,
recordType: 'AAAA' as const,
targetAddress: '::ffff:198.51.100.10',
},
],
[{ ...ipv4Input, expectedRecordId: '9007199254740992' }],
[
{
...ipv4Input,
expectedRecordId: 123 as unknown as string,
},
],
])(
'rejects invalid input before creating the SDK client: %j',
async (input) => {
const { client, factory } = createClient();
await expect(client.reconcile(input)).rejects.toMatchObject({
code: 'DNSPOD_INVALID_INPUT',
retryable: false,
});
expect(factory).not.toHaveBeenCalled();
},
);
it.each([
[
{ code: 'RequestLimitExceeded', message: 'raw' },
'DNSPOD_RATE_LIMITED',
'DNSPod provider request was rate limited',
true,
],
[
{ code: 'InternalError', message: 'raw' },
'DNSPOD_PROVIDER_RETRYABLE',
'DNSPod provider request failed temporarily',
true,
],
[
{ code: 'ETIMEDOUT', message: 'raw' },
'DNSPOD_PROVIDER_RETRYABLE',
'DNSPod provider request failed temporarily',
true,
],
[
{ message: 'raw', statusCode: 503 },
'DNSPOD_PROVIDER_RETRYABLE',
'DNSPod provider request failed temporarily',
true,
],
[
{ code: 'AuthFailure.SecretIdNotFound', message: 'raw' },
'DNSPOD_AUTH_FAILED',
'DNSPod provider authentication failed',
false,
],
[
{ code: 'UnauthorizedOperation', message: 'raw' },
'DNSPOD_PERMISSION_DENIED',
'DNSPod provider permission was denied',
false,
],
[
{ code: 'AuthFailure.UnauthorizedOperation', message: 'raw' },
'DNSPOD_PERMISSION_DENIED',
'DNSPod provider permission was denied',
false,
],
])(
'maps one SDK failure to redacted stable code %s',
async (error, code, message, retryable) => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockRejectedValue(error);
await expect(client.reconcile(ipv4Input)).rejects.toMatchObject({
code,
message,
retryable,
});
},
);
it('maps uncategorized permanent SDK failures to one redacted stable error', async () => {
const { client, sdkClient } = createClient();
sdkClient.DescribeRecordFilterList.mockRejectedValue({
code: 'InvalidParameter',
message: 'the raw provider rejection must stay private',
requestId: 'provider-request-id',
});
await expect(client.reconcile(ipv4Input)).rejects.toMatchObject({
code: 'DNSPOD_PROVIDER_REJECTED',
message: 'DNSPod provider request was rejected',
retryable: false,
});
});
it('keeps serialized failures and results free of credentials, provider details, FQDN, and ports', async () => {
const secretId = 'sensitive-secret-id';
const secretKey = 'sensitive-secret-key';
const domain = 'private.example.com';
const subDomain = 'hidden';
const rawMessage = `provider leaked ${secretId} ${secretKey} ${subDomain}.${domain} 198.51.100.10:8211`;
const failing = createClient(createSdkClient(), {
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
NETWORK_DDNS_DNSPOD_SECRET_ID: secretId,
NETWORK_DDNS_DNSPOD_SECRET_KEY: secretKey,
});
failing.sdkClient.DescribeRecordFilterList.mockRejectedValue({
code: 'AuthFailure',
message: rawMessage,
});
let caught: unknown;
try {
await failing.client.reconcile({
domain,
recordType: 'A',
subDomain,
targetAddress: '198.51.100.10',
});
} catch (error) {
caught = error;
}
expect(caught).toBeInstanceOf(NetworkDnsPodClientError);
const serializedError = JSON.stringify({
...(caught as NetworkDnsPodClientError),
message: (caught as NetworkDnsPodClientError).message,
});
for (const forbidden of [
secretId,
secretKey,
rawMessage,
`${subDomain}.${domain}`,
'198.51.100.10:8211',
]) {
expect(serializedError).not.toContain(forbidden);
}
const successful = createClient();
successful.sdkClient.DescribeRecordFilterList.mockResolvedValue({
RecordList: [createRecord()],
});
const serializedResult = JSON.stringify(
await successful.client.reconcile(ipv4Input),
);
expect(serializedResult).not.toContain('test-secret-id');
expect(serializedResult).not.toContain('test-secret-key');
expect(serializedResult).not.toContain('nas.kwitsukasa.top');
expect(serializedResult).not.toContain('198.51.100.10:8211');
});
});

View File

@ -40,4 +40,28 @@ describe('Network management production deployment contract', () => {
); );
expect(manifest).not.toContain('NETWORK_AGENT_MQTT_PASSWORD:'); expect(manifest).not.toContain('NETWORK_AGENT_MQTT_PASSWORD:');
}); });
it('documents optional DDNS placeholders without promoting them to the Jenkins required gate', () => {
const envExample = readDeploymentFile('.env.example');
const jenkinsfile = readDeploymentFile('Jenkinsfile');
const requiredRuntimeBlock = jenkinsfile.match(
/def requiredRuntimeEnvKeys\(\) \{[\s\S]*?return \[([\s\S]*?)\]\s*\}/,
)?.[1];
const optionalKeys = [
'NETWORK_DDNS_DNSPOD_ENABLED',
'NETWORK_DDNS_DNSPOD_SECRET_ID',
'NETWORK_DDNS_DNSPOD_SECRET_KEY',
'NETWORK_DDNS_RECONCILE_INTERVAL_MS',
'NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS',
];
expect(requiredRuntimeBlock).toBeDefined();
for (const key of optionalKeys) {
expect(envExample).toMatch(new RegExp(`^${key}=.*$`, 'm'));
expect(requiredRuntimeBlock).not.toContain(key);
}
expect(envExample).toMatch(/^NETWORK_DDNS_DNSPOD_SECRET_ID=$/m);
expect(envExample).toMatch(/^NETWORK_DDNS_DNSPOD_SECRET_KEY=$/m);
});
}); });

View File

@ -5,6 +5,7 @@ import * as request from 'supertest';
import { AdminSuperGuard } from '../../../src/modules/admin/identity/auth/admin-super.guard'; import { AdminSuperGuard } from '../../../src/modules/admin/identity/auth/admin-super.guard';
import { JwtAuthGuard } from '../../../src/modules/admin/identity/auth/jwt-auth.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 { NetworkManagementController } from '../../../src/modules/admin/platform-config/network-management/network-management.controller';
import { NetworkDdnsService } from '../../../src/modules/admin/platform-config/network-management/network-ddns.service';
import { import {
NetworkManagementEventStreamService, NetworkManagementEventStreamService,
type NetworkManagementStreamEvent, type NetworkManagementStreamEvent,
@ -42,6 +43,15 @@ describe('NetworkManagementController', () => {
}), }),
), ),
}; };
const ddnsService = {
create: jest.fn(),
getProviderStatus: jest.fn(),
list: jest.fn(),
remove: jest.fn(),
retry: jest.fn(),
sourceOptions: jest.fn(),
update: jest.fn(),
};
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ const moduleRef = await Test.createTestingModule({
@ -49,6 +59,7 @@ describe('NetworkManagementController', () => {
providers: [ providers: [
AdminSuperGuard, AdminSuperGuard,
{ provide: NetworkManagementService, useValue: service }, { provide: NetworkManagementService, useValue: service },
{ provide: NetworkDdnsService, useValue: ddnsService },
{ {
provide: NetworkManagementEventStreamService, provide: NetworkManagementEventStreamService,
useValue: eventStream, useValue: eventStream,
@ -79,6 +90,21 @@ describe('NetworkManagementController', () => {
agentId: 'nas-main', agentId: 'nas-main',
online: false, online: false,
}); });
ddnsService.list.mockResolvedValue({ items: [], total: 0 });
ddnsService.sourceOptions.mockResolvedValue([]);
ddnsService.getProviderStatus.mockReturnValue({
configured: true,
enabled: true,
provider: 'dnspod',
});
ddnsService.create.mockResolvedValue({
id: '200',
recordType: 'AAAA',
syncStatus: 'pending',
});
ddnsService.update.mockResolvedValue({ id: '200' });
ddnsService.remove.mockResolvedValue({ id: '200' });
ddnsService.retry.mockResolvedValue({ id: '200' });
}); });
afterAll(async () => { afterAll(async () => {
@ -202,6 +228,112 @@ describe('NetworkManagementController', () => {
expect(service.agentStatus).not.toHaveBeenCalled(); expect(service.agentStatus).not.toHaveBeenCalled();
}); });
it('exposes strict dual-stack DDNS CRUD without provider credentials', async () => {
await request(apiUrl)
.get('/system/network/ddns/list?pageNo=1&pageSize=20&recordType=AAAA')
.expect(200)
.expect('Cache-Control', 'no-store');
const sources = await request(apiUrl)
.get('/system/network/ddns/source-options?recordType=AAAA')
.expect(200)
.expect('Cache-Control', 'no-store');
expect(sources.body.data).toEqual({ items: [] });
const provider = await request(apiUrl)
.get('/system/network/ddns/provider-status')
.expect(200)
.expect('Cache-Control', 'no-store');
expect(provider.body.data).toEqual({
configured: true,
enabled: true,
provider: 'dnspod',
});
expect(JSON.stringify(provider.body)).not.toMatch(
/secret|credential|token/i,
);
await request(apiUrl)
.post('/system/network/ddns')
.send({
domain: 'kwitsukasa.top',
enabled: true,
name: 'NAS IPv6',
recordType: 'AAAA',
secretKey: 'must-not-be-accepted',
sourceType: 'agent_ipv6',
subDomain: 'nas6',
})
.expect(400);
expect(ddnsService.create).not.toHaveBeenCalled();
await request(apiUrl)
.post('/system/network/ddns')
.send({
domain: 'kwitsukasa.top',
enabled: true,
name: 'NAS IPv4',
recordType: 'A',
sourceType: 'port_forward_ipv4',
subDomain: 'nas',
})
.expect(400);
const created = await request(apiUrl)
.post('/system/network/ddns')
.send({
domain: 'kwitsukasa.top',
enabled: true,
name: 'NAS IPv6',
recordType: 'AAAA',
sourceType: 'agent_ipv6',
subDomain: 'nas6',
})
.expect(201);
expect(created.body.data).toMatchObject({
id: '200',
recordType: 'AAAA',
});
expect(ddnsService.create).toHaveBeenCalledWith(
expect.objectContaining({
recordType: 'AAAA',
sourceType: 'agent_ipv6',
}),
);
await request(apiUrl)
.put('/system/network/ddns/200')
.send({
domain: 'kwitsukasa.top',
enabled: false,
name: 'NAS IPv6',
recordType: 'AAAA',
sourceType: 'agent_ipv6',
subDomain: 'nas6',
})
.expect(200);
await request(apiUrl).post('/system/network/ddns/200/retry').expect(200);
await request(apiUrl).delete('/system/network/ddns/200').expect(200);
});
it.each([123, '', '100'])(
'rejects an AAAA request that supplies portForwardId=%p',
async (portForwardId) => {
await request(apiUrl)
.post('/system/network/ddns')
.send({
domain: 'kwitsukasa.top',
enabled: true,
name: 'NAS IPv6',
portForwardId,
recordType: 'AAAA',
sourceType: 'agent_ipv6',
subDomain: 'nas6',
})
.expect(400);
expect(ddnsService.create).not.toHaveBeenCalled();
},
);
it('keeps an empty heartbeat cursor instead of accepting a Nest-generated ID', async () => { it('keeps an empty heartbeat cursor instead of accepting a Nest-generated ID', async () => {
let parsed = false; let parsed = false;
let serialized = ''; let serialized = '';

View File

@ -6,19 +6,26 @@ import {
} from '../../../src/modules/admin/platform-config/admin-platform-config.module'; } 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 { 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 { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity';
import { NetworkDdnsRecord } from '../../../src/modules/admin/platform-config/network-management/network-ddns.entity';
import { NetworkDdnsService } from '../../../src/modules/admin/platform-config/network-management/network-ddns.service';
import { NetworkDnsPodClient } from '../../../src/modules/admin/platform-config/network-management/network-dnspod.client';
import { NetworkEndpointHistory } from '../../../src/modules/admin/platform-config/network-management/network-endpoint-history.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 { 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'; import { NetworkManagementService } from '../../../src/modules/admin/platform-config/network-management/network-management.service';
describe('network management persistence module', () => { describe('network management persistence module', () => {
it('registers the three exact database entity tables', () => { it('registers the four exact database entity tables', () => {
const tables = getMetadataArgsStorage().tables.filter((table) => const tables = getMetadataArgsStorage().tables.filter((table) =>
[NetworkPortForward, NetworkAgentState, NetworkEndpointHistory].includes( [
table.target as never, NetworkPortForward,
), NetworkAgentState,
NetworkEndpointHistory,
NetworkDdnsRecord,
].includes(table.target as never),
); );
expect(tables.map((table) => table.name).sort()).toEqual([ expect(tables.map((table) => table.name).sort()).toEqual([
'network_agent_state', 'network_agent_state',
'network_ddns_record',
'network_endpoint_history', 'network_endpoint_history',
'network_port_forward', 'network_port_forward',
]); ]);
@ -32,12 +39,16 @@ describe('network management persistence module', () => {
expect(ADMIN_PLATFORM_CONFIG_PROVIDERS).toEqual( expect(ADMIN_PLATFORM_CONFIG_PROVIDERS).toEqual(
expect.arrayContaining([ expect.arrayContaining([
NetworkManagementService, NetworkManagementService,
NetworkDdnsService,
NetworkDnsPodClient,
NetworkAgentMqttService, NetworkAgentMqttService,
]), ]),
); );
expect(providers).toEqual( expect(providers).toEqual(
expect.arrayContaining([ expect.arrayContaining([
NetworkManagementService, NetworkManagementService,
NetworkDdnsService,
NetworkDnsPodClient,
NetworkAgentMqttService, NetworkAgentMqttService,
]), ]),
); );

View File

@ -181,4 +181,38 @@ describe('network management MQTT contracts', () => {
}), }),
).toThrow(); ).toThrow();
}); });
it('accepts one canonicalizable global IPv6 status field and rejects unsafe IPv6', () => {
expect(
parseStatusSnapshot({
agentId: 'nas-main',
observedAt: '2026-07-23T01:02:04.000Z',
online: true,
publicIpv6: '2409:8A31:05E1:6020:A5EA:838E:843F:BE5E',
schemaVersion: 1,
}),
).toMatchObject({
publicIpv6: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
});
for (const publicIpv6 of [
'::',
'::1',
'::ffff:192.0.2.1',
'fc00::1',
'fe80::1',
'ff02::1',
'192.0.2.1',
]) {
expect(() =>
parseStatusSnapshot({
agentId: 'nas-main',
observedAt: '2026-07-23T01:02:04.000Z',
online: true,
publicIpv6,
schemaVersion: 1,
}),
).toThrow();
}
});
}); });

View File

@ -269,8 +269,56 @@ describe('RuntimeConfigService', () => {
}), }),
]), ]),
); );
expect(JSON.stringify(networkChecks)).not.toContain( expect(JSON.stringify(networkChecks)).not.toContain('network-api-password');
'network-api-password', });
it('reports DDNS runtime keys as optional without exposing provider credentials', () => {
const secretId = 'ddns-id-fixture-123';
const secretKey = 'ddns-key-fixture-456';
const service = createService({
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
NETWORK_DDNS_DNSPOD_SECRET_ID: secretId,
NETWORK_DDNS_DNSPOD_SECRET_KEY: secretKey,
NETWORK_DDNS_RECONCILE_INTERVAL_MS: '60000',
NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS: '60000',
});
const checks = service
.getConfigChecks()
.filter((check) => check.key.startsWith('NETWORK_DDNS_'));
const serialized = JSON.stringify(checks);
expect(checks).toHaveLength(5);
expect(checks).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'NETWORK_DDNS_DNSPOD_ENABLED',
level: 'optional',
present: true,
}),
expect.objectContaining({
key: 'NETWORK_DDNS_DNSPOD_SECRET_ID',
level: 'optional',
present: true,
}),
expect.objectContaining({
key: 'NETWORK_DDNS_DNSPOD_SECRET_KEY',
level: 'optional',
present: true,
}),
expect.objectContaining({
key: 'NETWORK_DDNS_RECONCILE_INTERVAL_MS',
level: 'optional',
present: true,
}),
expect.objectContaining({
key: 'NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS',
level: 'optional',
present: true,
}),
]),
); );
expect(serialized).not.toContain(secretId);
expect(serialized).not.toContain(secretKey);
}); });
}); });