From ddc0e9580be92e7262b7a58b3301fbb14536e70f Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 23 Jul 2026 13:00:50 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=85=BE=E8=AE=AF?= =?UTF-8?q?=E4=BA=91=E5=8F=8C=E6=A0=88=E8=87=AA=E5=8A=A8DDNS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 7 + API.md | 15 +- README.md | 4 +- sql/network-management-init.sql | 66 +- sql/network-management-menu.sql | 23 +- sql/refactor-v3/00-full-schema.sql | 31 + sql/refactor-v3/01-seed-core.sql | 49 + sql/refactor-v3/99-verify.sql | 76 + sql/vben-admin-init.sql | 12 +- .../admin-platform-config.module.ts | 6 + .../network-agent-mqtt.service.ts | 66 +- .../network-agent-state.entity.ts | 11 + .../network-management/network-ddns.entity.ts | 117 ++ .../network-ddns.service.ts | 1410 +++++++++++++++++ .../network-dnspod.client.ts | 635 ++++++++ .../network-management.controller.ts | 85 + .../network-management.dto.ts | 156 +- .../network-management.service.ts | 4 + .../network-management.types.ts | 86 +- src/runtime/config/runtime-config.service.ts | 5 + .../network-agent-mqtt.service.spec.ts | 47 + .../network-ddns-sql.spec.ts | 200 +++ .../network-ddns.service.spec.ts | 604 +++++++ .../network-dnspod.client.spec.ts | 583 +++++++ .../network-management-deployment.spec.ts | 24 + .../network-management.controller.spec.ts | 132 ++ .../network-management.entity.spec.ts | 19 +- .../network-management.types.spec.ts | 34 + test/runtime/runtime-config.service.spec.ts | 52 +- 29 files changed, 4528 insertions(+), 31 deletions(-) create mode 100644 src/modules/admin/platform-config/network-management/network-ddns.entity.ts create mode 100644 src/modules/admin/platform-config/network-management/network-ddns.service.ts create mode 100644 src/modules/admin/platform-config/network-management/network-dnspod.client.ts create mode 100644 test/admin/network-management/network-ddns-sql.spec.ts create mode 100644 test/admin/network-management/network-ddns.service.spec.ts create mode 100644 test/admin/network-management/network-dnspod.client.spec.ts diff --git a/.env.example b/.env.example index c4cdf4b..04e9d58 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,13 @@ NETWORK_AGENT_MQTT_USERNAME= NETWORK_AGENT_MQTT_PASSWORD= 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_APP_NAME=kt-template-online-api LOG_PRETTY=true diff --git a/API.md b/API.md index 0e7d97d..e24b8a4 100644 --- a/API.md +++ b/API.md @@ -88,7 +88,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 当前版本只提供观测和只读自检。重启 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/agent/status` | `super` | 查询 Agent 在线与 revision 收敛状态 | | `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`。 @@ -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 处理。 +自动 DDNS 支持 `A` 和 `AAAA`。A 记录绑定 `sourceType=port_forward_ipv4` 与一条合格的同源端口 UDP Keeper;AAAA 记录固定使用 `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_*` | | MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | Env Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` | -| Network | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`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` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` | | FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | diff --git a/README.md b/README.md index d5027a3..1171f64 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ ci/ Jenkins Agent/Docker 辅助文件 | Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | | QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | | Environment Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` | -| Network Management | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`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` | | 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` | @@ -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 发布 SSE;QoS 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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码;v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true` 与 `o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 diff --git a/sql/network-management-init.sql b/sql/network-management-init.sql index 46edb65..799ca9f 100644 --- a/sql/network-management-init.sql +++ b/sql/network-management-init.sql @@ -1,4 +1,4 @@ --- 通用端口转发控制面数据表与单 Agent 初始状态。 +-- 通用网络控制面数据表与单 Agent 初始状态。 SET NAMES utf8mb4; @@ -37,6 +37,36 @@ CREATE TABLE IF NOT EXISTS `network_port_forward` ( KEY `idx_network_port_forward_protocol` (`protocol`, `external_port`) ) 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` ( `agent_id` VARCHAR(64) NOT NULL, `target_ipv4` VARCHAR(15) NOT NULL, @@ -48,6 +78,8 @@ CREATE TABLE IF NOT EXISTS `network_agent_state` ( `version` VARCHAR(64) NULL, `started_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_message` VARCHAR(500) NULL, `last_reconcile_error_code` VARCHAR(64) NULL, @@ -57,6 +89,38 @@ CREATE TABLE IF NOT EXISTS `network_agent_state` ( PRIMARY KEY (`agent_id`) ) 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` ( `id` BIGINT NOT NULL, `event_id` VARCHAR(128) NOT NULL, diff --git a/sql/network-management-menu.sql b/sql/network-management-menu.sql index 8ac841f..a663824 100644 --- a/sql/network-management-menu.sql +++ b/sql/network-management-menu.sql @@ -1,4 +1,4 @@ --- 增量初始化通用网络端口转发菜单;仅授予启用中的超级管理员。 +-- 增量初始化通用网络管理菜单;仅授予启用中的超级管理员。 SET NAMES utf8mb4; @@ -14,7 +14,12 @@ VALUES (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) + (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 `name` = VALUES(`name`), `pid` = VALUES(`pid`), @@ -42,7 +47,12 @@ WHERE role.`role_code` <> 'super' 'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardProbe', - 'SystemNetworkPortForwardHistory' + 'SystemNetworkPortForwardHistory', + 'SystemNetworkDdnsList', + 'SystemNetworkDdnsCreate', + 'SystemNetworkDdnsUpdate', + 'SystemNetworkDdnsDelete', + 'SystemNetworkDdnsRetry' ); INSERT IGNORE INTO `admin_role_menu` (`role_id`, `menu_id`) @@ -57,7 +67,12 @@ JOIN `admin_menu` menu ON menu.`name` IN ( 'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardProbe', - 'SystemNetworkPortForwardHistory' + 'SystemNetworkPortForwardHistory', + 'SystemNetworkDdnsList', + 'SystemNetworkDdnsCreate', + 'SystemNetworkDdnsUpdate', + 'SystemNetworkDdnsDelete', + 'SystemNetworkDdnsRetry' ) WHERE role.`role_code` = 'super' AND role.`status` = 1 diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index 2b431ed..c59a362 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -160,6 +160,35 @@ CREATE TABLE IF NOT EXISTS network_port_forward ( KEY idx_network_port_forward_protocol (protocol, external_port) ) 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 ( agent_id VARCHAR(64) NOT NULL PRIMARY KEY, target_ipv4 VARCHAR(15) NOT NULL, @@ -171,6 +200,8 @@ CREATE TABLE IF NOT EXISTS network_agent_state ( version VARCHAR(64) NULL, started_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_message VARCHAR(500) NULL, last_reconcile_error_code VARCHAR(64) NULL, diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index 2175ad1..ea95c6d 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -123,6 +123,33 @@ INSERT INTO admin_menu ( 1, 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, 0, @@ -180,6 +207,28 @@ ON DUPLICATE KEY UPDATE sort = VALUES(sort), is_deleted = 0; +DELETE role_menu +FROM admin_role_menu role_menu +JOIN admin_role role ON role.id = role_menu.role_id +JOIN admin_menu menu ON menu.id = role_menu.menu_id +WHERE role.role_code <> 'super' + AND menu.name IN ( + 'SystemNetwork', + 'SystemNetworkPortForwardList', + 'SystemNetworkPortForwardCreate', + 'SystemNetworkPortForwardUpdate', + 'SystemNetworkPortForwardDelete', + 'SystemNetworkPortForwardRetry', + 'SystemNetworkPortForwardKeeper', + 'SystemNetworkPortForwardProbe', + 'SystemNetworkPortForwardHistory', + 'SystemNetworkDdnsList', + 'SystemNetworkDdnsCreate', + 'SystemNetworkDdnsUpdate', + 'SystemNetworkDdnsDelete', + 'SystemNetworkDdnsRetry' + ); + INSERT INTO admin_dict ( id, dict_code, diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index fa5bf50..077acf2 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -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_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_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_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; @@ -44,12 +45,45 @@ FROM network_agent_state WHERE agent_id = 'nas-main' 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 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'network_port_forward' AND index_name = 'uk_network_port_forward_active_key'; +SELECT 'index_network_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 FROM information_schema.statistics WHERE table_schema = DATABASE() @@ -112,6 +146,48 @@ SELECT 'seed_qqbot_account_webui_permission' AS check_name, COUNT(*) AS matched_ FROM admin_menu 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 FROM information_schema.statistics WHERE table_schema = DATABASE() diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index e67fe96..fd96a29 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -248,6 +248,11 @@ VALUES (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), (2041700000000100300, 0, 'Blog', '/blog', NULL, '/blog/article', NULL, 'catalog', '{"icon":"lucide:newspaper","order":100,"title":"博客管理"}', 1, 100), (2041700000000100301, 2041700000000100300, 'BlogArticle', '/blog/article', '/blog/article/list', NULL, 'Blog:Article:List', 'menu', '{"icon":"lucide:file-text","title":"文章管理"}', 1, 0), (2041700000000120301, 2041700000000100301, 'BlogArticleCreate', NULL, NULL, NULL, 'Blog:Article:Create', 'button', '{"title":"common.create"}', 1, 0), @@ -375,7 +380,12 @@ WHERE `is_deleted` = 0 'SystemNetworkPortForwardRetry', 'SystemNetworkPortForwardKeeper', 'SystemNetworkPortForwardProbe', - 'SystemNetworkPortForwardHistory' + 'SystemNetworkPortForwardHistory', + 'SystemNetworkDdnsList', + 'SystemNetworkDdnsCreate', + 'SystemNetworkDdnsUpdate', + 'SystemNetworkDdnsDelete', + 'SystemNetworkDdnsRetry' ); INSERT INTO `admin_role_menu` (`role_id`, `menu_id`) diff --git a/src/modules/admin/platform-config/admin-platform-config.module.ts b/src/modules/admin/platform-config/admin-platform-config.module.ts index 82acd6b..b3a1156 100644 --- a/src/modules/admin/platform-config/admin-platform-config.module.ts +++ b/src/modules/admin/platform-config/admin-platform-config.module.ts @@ -10,6 +10,9 @@ import { AdminNoticeController } from '@/modules/admin/platform-config/notice/ad import { NoticeModule } from '@/modules/admin/platform-config/notice/notice.module'; import { NetworkAgentMqttService } from '@/modules/admin/platform-config/network-management/network-agent-mqtt.service'; import { NetworkAgentState } from '@/modules/admin/platform-config/network-management/network-agent-state.entity'; +import { 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 { 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'; @@ -83,6 +86,8 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [ EnvironmentEventStreamService, NetworkManagementService, NetworkManagementEventStreamService, + NetworkDnsPodClient, + NetworkDdnsService, NetworkAgentMqttService, ]; @@ -94,6 +99,7 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [ NetworkPortForward, NetworkAgentState, NetworkEndpointHistory, + NetworkDdnsRecord, ]), AdminAuthGuardModule, DictModule, diff --git a/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts b/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts index 7ac2811..5a4fce1 100644 --- a/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts +++ b/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts @@ -13,6 +13,7 @@ import type { IClientOptions, MqttClient } from 'mqtt'; import { KtDateTime } from '@/common'; import { NetworkAgentState } from './network-agent-state.entity'; import { NetworkEndpointHistory } from './network-endpoint-history.entity'; +import { NetworkDdnsService } from './network-ddns.service'; import { NetworkManagementEventStreamService } from './network-management-event-stream.service'; import { NetworkPortForward } from './network-management.entity'; import { @@ -60,6 +61,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { * @param dataSource - Transaction boundary for publish acknowledgements and inbound state. * @param eventStream - SSE fan-out notified only after accepted inbound commits. * @param clientFactory - Optional deterministic MQTT client factory used by tests. + * @param ddnsService - Optional automatic-DDNS reconciler notified after address semantics commit. */ constructor( private readonly configService: ConfigService, @@ -68,6 +70,8 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { @Optional() @Inject(NETWORK_MQTT_CLIENT_FACTORY) private readonly clientFactory?: NetworkMqttClientFactory, + @Optional() + private readonly ddnsService?: NetworkDdnsService, ) {} /** Opens a persistent MQTT 5 session and schedules convergence publication. */ @@ -175,13 +179,18 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { } let changed = false; + let ddnsSourceChanged = false; let source: NetworkStateChangeSource; if (topic === this.topic('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')) { 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')) { source = 'events'; changed = await this.appendEndpointEvent(parseEndpointEvent(parsed)); @@ -189,6 +198,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { throw new NetworkMessageValidationError('Unexpected network MQTT topic'); } if (changed) this.eventStream.publishCommitted(source); + if (ddnsSourceChanged) this.ddnsService?.requestReconcile(); } /** 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. * @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( - report: NetworkReportedSnapshot, - ): Promise { + private async applyReported(report: NetworkReportedSnapshot): Promise<{ + ddnsSourceChanged: boolean; + visibleStateChanged: boolean; + }> { this.assertAgentId(report.agentId); const result = await this.dataSource.transaction(async (manager) => { const stateRepository = manager.getRepository(NetworkAgentState); @@ -335,9 +346,14 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { throw new NetworkMessageValidationError('Invalid reported revision'); } if (BigInt(report.appliedRevision) < BigInt(state.appliedRevision)) { - return { desiredChanged: false, visibleStateChanged: false }; + return { + ddnsSourceChanged: false, + desiredChanged: false, + visibleStateChanged: false, + }; } const stateBefore = this.reportedAgentStateFingerprint(state); + let ddnsSourceChanged = false; let visibleStateChanged = false; const isCurrentRevision = BigInt(report.appliedRevision) === BigInt(state.desiredRevision); @@ -407,6 +423,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { this.reportedPersistedMappingStateFingerprint(mapping); const refreshMappingBefore = this.reportedRefreshMappingStateFingerprint(mapping); + const publicIpv4Before = mapping.currentPublicIpv4; mapping.reportedRevision = String(item.revision); mapping.syncStatus = item.syncStatus; mapping.keeperStatus = item.keeperStatus; @@ -418,6 +435,9 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { item.lastObservedEndpoint, report.reportedAt, ); + if (mapping.currentPublicIpv4 !== publicIpv4Before) { + ddnsSourceChanged = true; + } if ( mapping.desiredPresence === 'absent' && @@ -471,12 +491,16 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { await stateRepository.save(state); } return { + ddnsSourceChanged, desiredChanged: finalizedDeletion, visibleStateChanged, }; }); 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. * @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 { + private async applyStatus(status: NetworkStatusSnapshot): Promise<{ + ddnsSourceChanged: boolean; + visibleStateChanged: boolean; + }> { this.assertAgentId(status.agentId); return await this.dataSource.transaction(async (manager) => { const repository = manager.getRepository(NetworkAgentState); @@ -556,7 +583,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { currentStartedAt && incomingStartedAt.getTime() < currentStartedAt.getTime() ) { - return false; + return { ddnsSourceChanged: false, visibleStateChanged: false }; } const isSameSessionWill = status.online === false && @@ -568,10 +595,11 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { observedAt.getTime() < new Date(state.lastHeartbeatAt).getTime() && !isSameSessionWill ) { - return false; + return { ddnsSourceChanged: false, visibleStateChanged: false }; } const persistedStateBefore = this.statusPersistedStateFingerprint(state); const refreshStateBefore = this.statusRefreshStateFingerprint(state); + const publicIpv6Before = state.currentPublicIpv6 || null; state.online = status.online; state.version = status.version || null; state.startedAt = incomingStartedAt @@ -582,12 +610,21 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { } state.lastMqttErrorCode = status.errorCode || 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 ( this.statusPersistedStateFingerprint(state) !== persistedStateBefore ) { 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 { return JSON.stringify([ state.lastHeartbeatAt, + state.currentIpv6ObservedAt, + state.currentPublicIpv6, state.lastMqttErrorCode, state.lastMqttErrorMessage, state.online, @@ -727,6 +766,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { return JSON.stringify([ state.lastMqttErrorCode, state.lastMqttErrorMessage, + state.currentPublicIpv6, state.online, state.startedAt, state.version, diff --git a/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts b/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts index c5ce220..8b60dd1 100644 --- a/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts +++ b/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts @@ -42,6 +42,17 @@ export class NetworkAgentState { }) 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 }) lastMqttErrorCode?: string | null; diff --git a/src/modules/admin/platform-config/network-management/network-ddns.entity.ts b/src/modules/admin/platform-config/network-management/network-ddns.entity.ts new file mode 100644 index 0000000..23e72bc --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-ddns.entity.ts @@ -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); + } +} diff --git a/src/modules/admin/platform-config/network-management/network-ddns.service.ts b/src/modules/admin/platform-config/network-management/network-ddns.service.ts new file mode 100644 index 0000000..3711ab0 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-ddns.service.ts @@ -0,0 +1,1410 @@ +import { isIP } from 'node:net'; +import { + HttpStatus, + Injectable, + type OnModuleDestroy, + type OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { IsNull, Repository } from 'typeorm'; +import { KtDateTime, throwVbenError } from '@/common'; +import { NetworkAgentState } from './network-agent-state.entity'; +import { NetworkDdnsRecord } from './network-ddns.entity'; +import { + NetworkDnsPodClient, + NetworkDnsPodClientError, +} from './network-dnspod.client'; +import { NetworkManagementEventStreamService } from './network-management-event-stream.service'; +import { NetworkPortForward } from './network-management.entity'; +import type { + NetworkDdnsListQuery, + NetworkDdnsRecordInput, + NetworkDdnsRecordType, + NetworkDdnsRecordUpdateInput, + NetworkDdnsSourceOption, +} from './network-management.types'; + +type ReconcileRequest = { + force: boolean; + id: null | string; + reject: (error: unknown) => void; + resolve: () => void; +}; + +type SafeProviderError = { + code: string; + message: string; + retryable: boolean; +}; + +const DEFAULT_AGENT_ID = 'nas-main'; +const DEFAULT_AGENT_IPV6_MAX_AGE_MS = 60_000; +const DEFAULT_RECONCILE_INTERVAL_MS = 60_000; +const RETRY_BASE_DELAY_MS = 5_000; +const RETRY_MAX_DELAY_MS = 15 * 60_000; +const RETRY_MAX_ATTEMPTS = 8; +const DNS_LABEL_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/; +const PROVIDER_ERROR_CODES: Record = { + DNSPOD_AUTH_FAILED: 'provider_auth_failed', + DNSPOD_DISABLED: 'provider_unconfigured', + DNSPOD_INVALID_INPUT: 'record_identity_changed', + DNSPOD_NOT_CONFIGURED: 'provider_unconfigured', + DNSPOD_PERMISSION_DENIED: 'provider_permission_denied', + DNSPOD_PROVIDER_REJECTED: 'provider_permission_denied', + DNSPOD_PROVIDER_RETRYABLE: 'provider_unavailable', + DNSPOD_RATE_LIMITED: 'provider_rate_limited', + DNSPOD_RECORD_AMBIGUOUS: 'record_ambiguous', + DNSPOD_RECORD_DISABLED: 'record_disabled', + DNSPOD_RECORD_INVALID: 'record_identity_changed', + DNSPOD_RECORD_MISMATCH: 'record_identity_changed', + DNSPOD_RECORD_NOT_FOUND: 'record_not_found', + DNSPOD_VERIFICATION_FAILED: 'provider_write_unverified', +}; + +/** + * Owns local automatic-DDNS bindings and serializes provider reconciliation. + * + * The single-flight lock is deliberately process-local because production runs + * one Recreate API replica. A database claim or leader lease is required before + * this service can safely run in multiple replicas. + */ +@Injectable() +export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy { + private destroyed = false; + private reconcileInterval?: NodeJS.Timeout; + private reconcileRequestTimer?: NodeJS.Timeout; + private reconcileWorker: null | Promise = null; + private readonly reconcileRequests: ReconcileRequest[] = []; + private readonly recordMutationTails = new Map>(); + private requestedForce = false; + + /** + * Creates the persistent DDNS application service. + * @param recordRepository - Local updater bindings and persistent retry state. + * @param mappingRepository - IPv4 address sources produced by UDP Keepers. + * @param stateRepository - Singleton Agent state containing the current IPv6. + * @param configService - Reconcile cadence and Agent identity configuration. + * @param dnsPodClient - Redacted Tencent Cloud DNS provider boundary. + * @param eventStream - Committed semantic-change notification stream. + */ + constructor( + @InjectRepository(NetworkDdnsRecord) + private readonly recordRepository: Repository, + @InjectRepository(NetworkPortForward) + private readonly mappingRepository: Repository, + @InjectRepository(NetworkAgentState) + private readonly stateRepository: Repository, + private readonly configService: ConfigService, + private readonly dnsPodClient: NetworkDnsPodClient, + private readonly eventStream: NetworkManagementEventStreamService, + ) {} + + /** + * Recovers durable pending work and starts the bounded due-retry scan. + * @returns Nothing; provider work remains asynchronous from Nest startup. + */ + onModuleInit(): void { + this.requestReconcile(); + this.reconcileInterval = setInterval( + () => this.requestReconcile(), + this.reconcileIntervalMs(), + ); + this.reconcileInterval.unref?.(); + } + + /** + * Stops new scheduling and waits for the one bounded provider flight to finish. + * @returns A promise resolved after in-flight reconciliation has settled. + */ + async onModuleDestroy(): Promise { + this.destroyed = true; + if (this.reconcileRequestTimer) { + clearTimeout(this.reconcileRequestTimer); + this.reconcileRequestTimer = undefined; + } + if (this.reconcileInterval) { + clearInterval(this.reconcileInterval); + this.reconcileInterval = undefined; + } + const pending = this.reconcileRequests.splice(0); + pending.forEach((request) => request.resolve()); + await this.reconcileWorker; + } + + /** + * Lists active bindings with API-owned FQDN and live source classification. + * @param query - Validated pagination and independent DDNS filters. + * @returns Page of response-safe bindings. + */ + async list(query: NetworkDdnsListQuery = {}) { + const pageNo = query.pageNo || 1; + const pageSize = query.pageSize || 20; + const builder = this.recordRepository + .createQueryBuilder('record') + .where('record.isDeleted = :isDeleted', { isDeleted: false }); + if (query.name) { + builder.andWhere('record.name LIKE :name', { + name: `%${query.name.trim()}%`, + }); + } + if (query.recordType) { + builder.andWhere('record.recordType = :recordType', { + recordType: query.recordType, + }); + } + if (query.syncStatus) { + builder.andWhere('record.syncStatus = :syncStatus', { + syncStatus: query.syncStatus, + }); + } + if (query.enabled !== undefined) { + builder.andWhere('record.enabled = :enabled', { + enabled: query.enabled, + }); + } + const [records, total] = await builder + .orderBy('record.createTime', 'DESC') + .skip((pageNo - 1) * pageSize) + .take(pageSize) + .getManyAndCount(); + return { + items: await Promise.all( + records.map((record) => this.serializeRecord(record)), + ), + total, + }; + } + + /** + * Returns server-classified sources for one address family. + * @param query - Requested A or AAAA record family. + * @returns IPv4 Keeper choices or the singleton Agent IPv6 choice. + */ + async sourceOptions(query: { + recordType: NetworkDdnsRecordType; + }): Promise { + if (query.recordType === 'AAAA') { + return [await this.agentIpv6SourceOption()]; + } + if (query.recordType !== 'A') { + throwVbenError('DDNS 记录类型无效', HttpStatus.BAD_REQUEST); + } + const mappings = await this.mappingRepository.find({ + order: { id: 'ASC', name: 'ASC' }, + where: { isDeleted: false }, + }); + return mappings.map((mapping) => this.portForwardSourceOption(mapping)); + } + + /** + * Returns redacted provider readiness without creating an SDK client. + * @returns Provider name plus enabled and configured flags. + */ + getProviderStatus() { + return this.dnsPodClient.getStatus(); + } + + /** + * Creates one local automatic updater after normalizing its DNS identity. + * @param input - User-editable fields without provider identity or credentials. + * @returns Persisted response-safe binding. + */ + async create(input: NetworkDdnsRecordInput) { + const normalized = await this.normalizeCreateInput(input); + await this.assertActiveKeyAvailable(normalized.activeKey); + const record = this.recordRepository.create({ + activeKey: normalized.activeKey, + appliedAddress: null, + domain: normalized.domain, + enabled: normalized.enabled, + isDeleted: false, + lastAttemptAt: null, + lastErrorCode: null, + lastErrorMessage: null, + lastSyncedAt: null, + name: normalized.name, + nextRetryAt: null, + portForwardId: normalized.portForwardId, + providerRecordId: null, + recordType: normalized.recordType, + remark: normalized.remark, + retryCount: 0, + sourceAddress: null, + sourceType: normalized.sourceType, + subDomain: normalized.subDomain, + syncStatus: normalized.enabled ? 'pending' : 'disabled', + }); + try { + await this.recordRepository.save(record); + } catch (error) { + if (this.isDuplicateKeyError(error)) { + throwVbenError('同类型完整域名已存在自动更新配置', HttpStatus.CONFLICT); + } + throw error; + } + this.publishSemanticChange(); + if (record.enabled) this.requestReconcile(); + return this.serializeRecord(record); + } + + /** + * Updates one binding while preserving provider identity for the same DNS name. + * @param id - Active local updater identifier. + * @param input - Non-empty partial editable fields. + * @returns Updated response-safe binding. + */ + async update(id: string, input: NetworkDdnsRecordUpdateInput) { + this.assertId(id); + return this.withRecordMutation(id, async () => { + if (!input || Object.keys(input).length === 0) { + throwVbenError('至少提供一个修改字段', HttpStatus.BAD_REQUEST); + } + const record = await this.findActiveRecord(id); + const normalized = await this.normalizeUpdateInput(record, input); + if (normalized.activeKey !== record.activeKey) { + const conflict = await this.findByActiveKey(normalized.activeKey); + if (conflict && conflict.id !== record.id) { + throwVbenError( + '同类型完整域名已存在自动更新配置', + HttpStatus.CONFLICT, + ); + } + } + + const beforeSemantic = this.semanticFingerprint(record); + const dnsIdentityChanged = + record.recordType !== normalized.recordType || + record.domain !== normalized.domain || + record.subDomain !== normalized.subDomain; + const sourceIdentityChanged = + record.sourceType !== normalized.sourceType || + (record.portForwardId || null) !== normalized.portForwardId; + const enabledChanged = record.enabled !== normalized.enabled; + + record.activeKey = normalized.activeKey; + record.domain = normalized.domain; + record.enabled = normalized.enabled; + record.name = normalized.name; + record.portForwardId = normalized.portForwardId; + record.recordType = normalized.recordType; + record.remark = normalized.remark; + record.sourceType = normalized.sourceType; + record.subDomain = normalized.subDomain; + + if (dnsIdentityChanged) { + record.appliedAddress = null; + record.providerRecordId = null; + } + if (dnsIdentityChanged || sourceIdentityChanged) { + record.sourceAddress = null; + } + if (!record.enabled) { + this.markDisabled(record); + } else if ( + dnsIdentityChanged || + sourceIdentityChanged || + enabledChanged + ) { + this.markPending(record); + } + + try { + await this.saveWithSemanticEvent(record, beforeSemantic); + } catch (error) { + if (this.isDuplicateKeyError(error)) { + throwVbenError( + '同类型完整域名已存在自动更新配置', + HttpStatus.CONFLICT, + ); + } + throw error; + } + if ( + record.enabled && + (dnsIdentityChanged || sourceIdentityChanged || enabledChanged) + ) { + this.requestReconcile(); + } + return this.serializeRecord(record); + }); + } + + /** + * Soft-deletes only the local updater and never calls the DNS provider. + * @param id - Active local updater identifier. + * @returns Deleted local record state for the mutation response. + */ + async remove(id: string) { + this.assertId(id); + return this.withRecordMutation(id, async () => { + const record = await this.findActiveRecord(id); + const beforeSemantic = this.semanticFingerprint(record); + record.activeKey = null; + record.enabled = false; + record.isDeleted = true; + this.markDisabled(record); + await this.saveWithSemanticEvent(record, beforeSemantic); + return this.serializeRecord(record); + }); + } + + /** + * Clears persistent backoff and requests one immediate forced reconciliation. + * @param id - Enabled local updater identifier. + * @returns Pending response-safe binding; provider work continues asynchronously. + */ + async retry(id: string) { + this.assertId(id); + return this.withRecordMutation(id, async () => { + const record = await this.findActiveRecord(id); + if (!record.enabled) { + throwVbenError('自动更新已停用', HttpStatus.BAD_REQUEST); + } + const beforeSemantic = this.semanticFingerprint(record); + this.markPending(record); + await this.saveWithSemanticEvent(record, beforeSemantic); + void this.reconcileNow(id, true).catch(() => { + // Durable pending state is retried by the interval and next startup. + }); + return this.serializeRecord(record); + }); + } + + /** + * Coalesces one asynchronous all-record reconcile request. + * @param force - Whether the next scan should bypass durable retry timing. + */ + requestReconcile(force = false): void { + if (this.destroyed) return; + this.requestedForce ||= force; + if (this.reconcileRequestTimer) return; + this.reconcileRequestTimer = setTimeout(() => { + this.reconcileRequestTimer = undefined; + const requestedForce = this.requestedForce; + this.requestedForce = false; + void this.reconcileNow(undefined, requestedForce).catch(() => { + // Durable rows remain recoverable by the next bounded scan or restart. + }); + }, 0); + this.reconcileRequestTimer.unref?.(); + } + + /** + * Queues one record or all active records behind the process-local single flight. + * @param id - Optional specific binding; omitted means scan all active bindings. + * @param force - Whether to bypass identical-state and retry timing gates. + * @returns Promise settled after the coalesced batch finishes. + */ + reconcileNow(id?: string, force = false): Promise { + if (id !== undefined) this.assertId(id); + if (this.destroyed) return Promise.resolve(); + return new Promise((resolve, reject) => { + this.reconcileRequests.push({ + force, + id: id || null, + reject, + resolve, + }); + this.ensureReconcileWorker(); + }); + } + + /** Starts the one queue worker when no provider flight currently owns it. */ + private ensureReconcileWorker(): void { + if (this.reconcileWorker || this.destroyed) return; + this.reconcileWorker = this.drainReconcileRequests().finally(() => { + this.reconcileWorker = null; + if (this.reconcileRequests.length > 0 && !this.destroyed) { + this.ensureReconcileWorker(); + } + }); + } + + /** + * Serializes one binding's HTTP mutations with its provider flight. + * @param id - Local updater identifier used as the lock key. + * @param operation - Mutation or reconciliation work to run exclusively. + * @returns The operation result after all earlier work for the same row settles. + */ + private async withRecordMutation( + id: string, + operation: () => Promise, + ): Promise { + const previous = this.recordMutationTails.get(id) || Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => current); + this.recordMutationTails.set(id, tail); + await previous; + try { + return await operation(); + } finally { + release(); + if (this.recordMutationTails.get(id) === tail) { + this.recordMutationTails.delete(id); + } + } + } + + /** Drains coalesced requests serially so different records cannot overlap provider I/O. */ + private async drainReconcileRequests(): Promise { + while (!this.destroyed && this.reconcileRequests.length > 0) { + const requests = this.reconcileRequests.splice(0); + try { + await this.reconcileBatch(requests); + requests.forEach((request) => request.resolve()); + } catch (error) { + requests.forEach((request) => request.reject(error)); + } + } + } + + /** + * Expands one coalesced batch into deterministic per-record work. + * @param requests - Current pending callers sharing this single flight. + */ + private async reconcileBatch(requests: ReconcileRequest[]): Promise { + const recordForces = new Map(); + const allRequests = requests.filter((request) => request.id === null); + if (allRequests.length > 0) { + const forceAll = allRequests.some((request) => request.force); + const activeRecords = await this.recordRepository.find({ + order: { nextRetryAt: 'ASC', id: 'ASC' }, + where: { enabled: true, isDeleted: false }, + }); + activeRecords.forEach((record) => { + recordForces.set( + String(record.id), + forceAll || recordForces.get(String(record.id)) === true, + ); + }); + } + requests + .filter((request) => request.id !== null) + .forEach((request) => { + const id = request.id as string; + recordForces.set(id, request.force || recordForces.get(id) === true); + }); + const ids = [...recordForces.keys()].sort(compareDecimalIds); + for (const id of ids) { + await this.reconcileRecord(id, recordForces.get(id) === true); + } + } + + /** + * Reconciles one durable binding without holding a database transaction over I/O. + * @param id - Local updater identifier. + * @param force - Whether an operator explicitly requested provider verification. + */ + private async reconcileRecord(id: string, force: boolean): Promise { + await this.withRecordMutation(id, () => + this.reconcileRecordLocked(id, force), + ); + } + + /** + * Reconciles one binding while its row-level process lock excludes HTTP writes. + * @param id - Local updater identifier. + * @param force - Whether to bypass identical-state and retry timing gates. + */ + private async reconcileRecordLocked( + id: string, + force: boolean, + ): Promise { + const record = await this.recordRepository.findOne({ + where: { enabled: true, id, isDeleted: false }, + }); + if (!record) return; + const source = await this.resolveRecordSource(record); + const targetAddress = source.currentAddress; + if (!targetAddress) { + await this.persistWaitingSource(record); + return; + } + + const sourceChanged = record.sourceAddress !== targetAddress; + if ( + !force && + record.syncStatus === 'synced' && + !sourceChanged && + record.appliedAddress === targetAddress && + !!record.providerRecordId + ) { + return; + } + if ( + !force && + record.syncStatus === 'failed' && + !sourceChanged && + (!record.nextRetryAt || + new Date(record.nextRetryAt).getTime() > Date.now()) + ) { + return; + } + + const beforeSyncing = this.semanticFingerprint(record); + if (sourceChanged) record.retryCount = 0; + record.lastAttemptAt = new KtDateTime(); + record.lastErrorCode = null; + record.lastErrorMessage = null; + record.nextRetryAt = null; + record.sourceAddress = targetAddress; + record.syncStatus = 'syncing'; + if (!(await this.saveReconcileState(record, beforeSyncing))) return; + + const expectedIdentity = this.reconcileIdentity(record); + let result; + try { + result = await this.dnsPodClient.reconcile({ + domain: record.domain, + expectedRecordId: record.providerRecordId || null, + recordType: record.recordType, + subDomain: record.subDomain, + targetAddress, + }); + } catch (error) { + const current = await this.reReadForProviderResult( + id, + expectedIdentity, + targetAddress, + ); + if (!current) return; + await this.persistProviderFailure(current, this.safeProviderError(error)); + return; + } + const current = await this.reReadForProviderResult( + id, + expectedIdentity, + targetAddress, + ); + if (!current) return; + const beforeSynced = this.semanticFingerprint(current); + const expectedProviderRecordId = current.providerRecordId || null; + current.appliedAddress = result.appliedAddress; + current.lastErrorCode = null; + current.lastErrorMessage = null; + current.lastSyncedAt = new KtDateTime(); + current.nextRetryAt = null; + current.providerRecordId = result.providerRecordId; + current.retryCount = 0; + current.sourceAddress = targetAddress; + current.syncStatus = 'synced'; + await this.saveReconcileState( + current, + beforeSynced, + expectedProviderRecordId, + ); + } + + /** + * Re-reads row and source after provider I/O before accepting its result. + * @param id - Binding that initiated the provider request. + * @param expectedIdentity - DNS/source identity captured before I/O. + * @param targetAddress - Source address sent to the provider. + * @returns Current row only when both identity and source are unchanged. + */ + private async reReadForProviderResult( + id: string, + expectedIdentity: string, + targetAddress: string, + ): Promise { + const current = await this.recordRepository.findOne({ + where: { enabled: true, id, isDeleted: false }, + }); + if (!current) return null; + if (this.reconcileIdentity(current) !== expectedIdentity) { + this.enqueueInternalReconcile(id); + return null; + } + const source = await this.resolveRecordSource(current); + if (source.currentAddress === targetAddress) return current; + + const beforeSemantic = this.semanticFingerprint(current); + current.lastErrorCode = source.currentAddress ? null : 'source_unavailable'; + current.lastErrorMessage = source.currentAddress + ? null + : 'DDNS source is unavailable'; + current.nextRetryAt = null; + current.retryCount = 0; + current.sourceAddress = source.currentAddress; + current.syncStatus = source.currentAddress ? 'pending' : 'waiting_source'; + if (await this.saveReconcileState(current, beforeSemantic)) { + this.enqueueInternalReconcile(id); + } + return null; + } + + /** + * Appends an internal rerun after a source or binding changed during provider I/O. + * @param id - Active binding that needs a fresh source snapshot. + */ + private enqueueInternalReconcile(id: string): void { + if (this.destroyed) return; + this.reconcileRequests.push({ + force: false, + id, + reject: () => undefined, + resolve: () => undefined, + }); + this.ensureReconcileWorker(); + } + + /** + * Persists waiting-source state without invoking the provider. + * @param record - Enabled binding whose current source is unavailable. + */ + private async persistWaitingSource(record: NetworkDdnsRecord): Promise { + const beforeSemantic = this.semanticFingerprint(record); + record.lastAttemptAt = new KtDateTime(); + record.lastErrorCode = 'source_unavailable'; + record.lastErrorMessage = 'DDNS source is unavailable'; + record.nextRetryAt = null; + record.retryCount = 0; + record.sourceAddress = null; + record.syncStatus = 'waiting_source'; + await this.saveReconcileState(record, beforeSemantic); + } + + /** + * Persists one redacted permanent failure or bounded exponential retry. + * @param record - Current unchanged binding after provider I/O. + * @param error - Stable provider-boundary classification. + */ + private async persistProviderFailure( + record: NetworkDdnsRecord, + error: SafeProviderError, + ): Promise { + const beforeSemantic = this.semanticFingerprint(record); + const previousRetryCount = Math.max(0, record.retryCount || 0); + const retryCount = Math.min(previousRetryCount + 1, RETRY_MAX_ATTEMPTS); + const shouldRetry = + error.retryable && previousRetryCount < RETRY_MAX_ATTEMPTS; + record.lastErrorCode = error.code.slice(0, 64); + record.lastErrorMessage = error.message.slice(0, 512); + record.nextRetryAt = shouldRetry + ? new KtDateTime(Date.now() + this.retryDelayMs(retryCount)) + : null; + record.retryCount = retryCount; + record.syncStatus = 'failed'; + await this.saveReconcileState(record, beforeSemantic); + } + + /** + * Converts provider failures into stable redacted application errors. + * @param error - Unknown rejection from the provider boundary. + * @returns Bounded safe code/message and retry classification. + */ + private safeProviderError(error: unknown): SafeProviderError { + if (error instanceof NetworkDnsPodClientError) { + const passthroughCode = /^[a-z][a-z0-9_]{0,63}$/.test(error.code) + ? error.code + : null; + return { + code: + passthroughCode || + PROVIDER_ERROR_CODES[error.code] || + 'provider_unavailable', + message: error.message.slice(0, 512) || 'DDNS provider request failed', + retryable: error.retryable, + }; + } + return { + code: 'provider_unavailable', + message: 'DDNS provider request failed', + retryable: true, + }; + } + + /** + * Resolves the current source option for one persisted binding. + * @param record - DDNS binding with server-controlled source identity. + * @returns Live source classification and address. + */ + private async resolveRecordSource( + record: NetworkDdnsRecord, + ): Promise { + if ( + record.recordType === 'AAAA' && + record.sourceType === 'agent_ipv6' && + !record.portForwardId + ) { + return this.agentIpv6SourceOption(); + } + if ( + record.recordType === 'A' && + record.sourceType === 'port_forward_ipv4' && + record.portForwardId + ) { + const mapping = await this.mappingRepository.findOne({ + where: { id: record.portForwardId, isDeleted: false }, + }); + if (mapping) return this.portForwardSourceOption(mapping); + return this.missingPortForwardSourceOption(record.portForwardId); + } + return this.missingPortForwardSourceOption( + record.portForwardId || 'invalid', + ); + } + + /** + * Classifies one port-forward row without deriving DNS data from its port. + * @param mapping - Candidate UDP Keeper source. + * @returns Source option with a current IPv4 only while its lease is valid. + */ + private portForwardSourceOption( + mapping: NetworkPortForward, + ): NetworkDdnsSourceOption { + let disabledReasonCode: null | string = null; + if (mapping.isDeleted || mapping.desiredPresence !== 'present') { + disabledReasonCode = 'SOURCE_DELETING'; + } else if (mapping.protocol !== 'udp') { + disabledReasonCode = 'UDP_REQUIRED'; + } else if (mapping.externalPort !== mapping.internalPort) { + disabledReasonCode = 'PORT_MISMATCH'; + } else if (!mapping.keeperDesiredEnabled) { + disabledReasonCode = 'KEEPER_DISABLED'; + } + const leaseValid = + isIP(mapping.currentPublicIpv4 || '') === 4 && + !!mapping.currentValidUntil && + new Date(mapping.currentValidUntil).getTime() > Date.now(); + const sourceUsable = disabledReasonCode === null && leaseValid; + return { + currentAddress: sourceUsable ? mapping.currentPublicIpv4 || null : null, + disabledReasonCode, + eligible: disabledReasonCode === null, + externalPort: mapping.externalPort, + id: String(mapping.id), + name: mapping.name, + observedAt: sourceUsable ? mapping.currentObservedAt || null : null, + protocol: mapping.protocol, + sourceType: 'port_forward_ipv4', + validUntil: sourceUsable ? mapping.currentValidUntil || null : null, + }; + } + + /** + * Returns a stable unavailable option when an A binding lost its source row. + * @param id - Persisted source identifier. + */ + private missingPortForwardSourceOption(id: string): NetworkDdnsSourceOption { + return { + currentAddress: null, + disabledReasonCode: 'SOURCE_NOT_FOUND', + eligible: false, + id, + name: '端口转发来源已删除', + observedAt: null, + sourceType: 'port_forward_ipv4', + validUntil: null, + }; + } + + /** + * Classifies the singleton online, fresh, global Agent IPv6 source. + * @returns Stable `agent-ipv6` option without any port semantics. + */ + private async agentIpv6SourceOption(): Promise { + const state = await this.stateRepository.findOne({ + where: { agentId: this.agentId() }, + }); + let disabledReasonCode: null | string = null; + const address = normalizeGlobalIpv6(state?.currentPublicIpv6); + if (!state?.online) { + disabledReasonCode = 'AGENT_OFFLINE'; + } else if (!address || !state.currentIpv6ObservedAt) { + disabledReasonCode = 'IPV6_UNAVAILABLE'; + } else if ( + Date.now() - new Date(state.currentIpv6ObservedAt).getTime() > + this.agentIpv6MaxAgeMs() + ) { + disabledReasonCode = 'IPV6_STALE'; + } + return { + currentAddress: disabledReasonCode === null ? address : null, + disabledReasonCode, + eligible: disabledReasonCode === null, + id: 'agent-ipv6', + name: 'Agent 公网 IPv6', + observedAt: state?.currentIpv6ObservedAt || null, + sourceType: 'agent_ipv6', + validUntil: state?.currentIpv6ObservedAt + ? new KtDateTime( + new Date(state.currentIpv6ObservedAt).getTime() + + this.agentIpv6MaxAgeMs(), + ) + : null, + }; + } + + /** + * Builds and validates a fully normalized create model. + * @param input - Untrusted service-boundary input after DTO transformation. + * @returns Canonical user fields plus derived active key. + */ + private async normalizeCreateInput(input: NetworkDdnsRecordInput) { + const normalized = this.normalizeInput({ + ...input, + portForwardId: input?.portForwardId, + }); + await this.assertBindingSource( + normalized.recordType, + normalized.sourceType, + normalized.portForwardId, + ); + return normalized; + } + + /** + * Merges a partial update over the persisted canonical model. + * @param record - Existing active binding. + * @param input - Non-empty partial editable fields. + * @returns Canonical next model plus derived active key. + */ + private async normalizeUpdateInput( + record: NetworkDdnsRecord, + input: NetworkDdnsRecordUpdateInput, + ) { + const recordType = input.recordType || record.recordType; + const normalized = this.normalizeInput({ + domain: input.domain ?? record.domain, + enabled: input.enabled ?? record.enabled, + name: input.name ?? record.name, + portForwardId: + recordType === 'AAAA' + ? input.portForwardId + : (input.portForwardId ?? record.portForwardId ?? undefined), + recordType, + remark: input.remark ?? record.remark ?? undefined, + sourceType: input.sourceType || record.sourceType, + subDomain: input.subDomain ?? record.subDomain, + }); + const sourceIdentityChanged = + record.recordType !== normalized.recordType || + record.sourceType !== normalized.sourceType || + (record.portForwardId || null) !== normalized.portForwardId; + if (normalized.enabled || sourceIdentityChanged) { + await this.assertBindingSource( + normalized.recordType, + normalized.sourceType, + normalized.portForwardId, + ); + } else { + this.assertBindingShape( + normalized.recordType, + normalized.sourceType, + normalized.portForwardId, + ); + } + return normalized; + } + + /** + * Canonicalizes the user-editable model and validates DNS syntax. + * @param input - Complete service-boundary input. + * @returns Canonical model with a nullable source ID and active key. + */ + private normalizeInput(input: NetworkDdnsRecordInput) { + if (input.recordType !== 'A' && input.recordType !== 'AAAA') { + throwVbenError('DDNS 记录类型无效', HttpStatus.BAD_REQUEST); + } + if ( + input.sourceType !== 'port_forward_ipv4' && + input.sourceType !== 'agent_ipv6' + ) { + throwVbenError('DDNS 来源类型无效', HttpStatus.BAD_REQUEST); + } + if (typeof input.enabled !== 'boolean') { + throwVbenError('DDNS 启用状态无效', HttpStatus.BAD_REQUEST); + } + const name = this.normalizeName(input.name); + const remark = this.normalizeRemark(input.remark); + const domain = this.normalizeDomain(input.domain); + const subDomain = this.normalizeSubDomain(input.subDomain); + const fqdn = subDomain === '@' ? domain : `${subDomain}.${domain}`; + if (fqdn.length > 253) { + throwVbenError('DDNS 完整域名过长', HttpStatus.BAD_REQUEST); + } + const portForwardId = + typeof input.portForwardId === 'string' && input.portForwardId.trim() + ? input.portForwardId.trim() + : null; + this.assertBindingShape(input.recordType, input.sourceType, portForwardId); + return { + activeKey: `${input.recordType.toLowerCase()}:${fqdn}`, + domain, + enabled: input.enabled, + fqdn, + name, + portForwardId, + recordType: input.recordType, + remark, + sourceType: input.sourceType, + subDomain, + }; + } + + /** + * Enforces address-family/source identity without querying current source state. + * @param recordType - A or AAAA family. + * @param sourceType - Server-recognized source family. + * @param portForwardId - Optional A source identifier. + */ + private assertBindingShape( + recordType: NetworkDdnsRecordType, + sourceType: string, + portForwardId: null | string, + ): void { + if ( + recordType === 'A' && + (sourceType !== 'port_forward_ipv4' || + !portForwardId || + !/^\d{1,24}$/.test(portForwardId)) + ) { + throwVbenError( + 'A 记录必须选择有效的 IPv4 端口来源', + HttpStatus.BAD_REQUEST, + ); + } + if ( + recordType === 'AAAA' && + (sourceType !== 'agent_ipv6' || portForwardId !== null) + ) { + throwVbenError( + 'AAAA 记录必须使用 Agent IPv6 且不能绑定端口', + HttpStatus.BAD_REQUEST, + ); + } + } + + /** + * Ensures an A binding points at an active same-port UDP Keeper. + * @param recordType - A or AAAA family. + * @param sourceType - Server-recognized source family. + * @param portForwardId - Required A source identifier. + */ + private async assertBindingSource( + recordType: NetworkDdnsRecordType, + sourceType: string, + portForwardId: null | string, + ): Promise { + this.assertBindingShape(recordType, sourceType, portForwardId); + if (recordType === 'AAAA') return; + const mapping = await this.mappingRepository.findOne({ + where: { id: portForwardId as string, isDeleted: false }, + }); + if ( + !mapping || + mapping.desiredPresence !== 'present' || + mapping.protocol !== 'udp' || + mapping.externalPort !== mapping.internalPort || + !mapping.keeperDesiredEnabled + ) { + throwVbenError( + 'A 记录来源必须是已启用 Keeper 的同端口 UDP 转发', + HttpStatus.BAD_REQUEST, + ); + } + } + + /** + * Normalizes and validates one public DNS zone. + * @param value - Raw domain input. + * @returns Lowercase multi-label zone without one trailing dot. + */ + private normalizeDomain(value: string): string { + const normalized = + typeof value === 'string' + ? value.trim().toLowerCase().replace(/\.$/, '') + : ''; + if (!isValidDnsName(normalized, true)) { + throwVbenError('DDNS 主域名无效', HttpStatus.BAD_REQUEST); + } + return normalized; + } + + /** + * Normalizes and validates one DNSPod host record. + * @param value - Raw host-record input; apex must be explicit `@`. + * @returns Lowercase `@` or dot-separated host labels. + */ + private normalizeSubDomain(value: string): string { + const normalized = + typeof value === 'string' ? value.trim().toLowerCase() : ''; + if (normalized !== '@' && !isValidDnsName(normalized, false)) { + throwVbenError('DDNS 主机记录无效', HttpStatus.BAD_REQUEST); + } + return normalized; + } + + /** + * Normalizes a required Admin display name. + * @param value - Raw name input. + * @returns Trimmed value safe for the entity column. + */ + private normalizeName(value: string): string { + const normalized = typeof value === 'string' ? value.trim() : ''; + if (!normalized || normalized.length > 100) { + throwVbenError('DDNS 名称长度无效', HttpStatus.BAD_REQUEST); + } + return normalized; + } + + /** + * Normalizes an optional Admin remark. + * @param value - Raw optional remark. + * @returns Trimmed remark or null. + */ + private normalizeRemark(value?: string): null | string { + if (value === undefined || value === null) return null; + if (typeof value !== 'string' || value.trim().length > 500) { + throwVbenError('DDNS 备注长度无效', HttpStatus.BAD_REQUEST); + } + return value.trim() || null; + } + + /** + * Serializes one binding without exposing provider identity. + * @param record - Persisted local updater. + * @returns Admin contract with normalized FQDN and live source state. + */ + private async serializeRecord(record: NetworkDdnsRecord) { + const source = await this.resolveRecordSource(record); + return { + appliedAddress: record.appliedAddress || null, + domain: record.domain, + enabled: record.enabled, + fqdn: + record.subDomain === '@' + ? record.domain + : `${record.subDomain}.${record.domain}`, + id: String(record.id), + lastErrorCode: record.lastErrorCode || null, + lastErrorMessage: record.lastErrorMessage || null, + lastSyncedAt: record.lastSyncedAt || null, + name: record.name, + nextRetryAt: record.nextRetryAt || null, + ...(record.recordType === 'A' + ? { portForwardId: record.portForwardId || null } + : {}), + recordType: record.recordType, + remark: record.remark || null, + retryCount: record.retryCount || 0, + source, + sourceAddress: record.sourceAddress || null, + sourceType: record.sourceType, + subDomain: record.subDomain, + syncStatus: record.syncStatus, + updateTime: record.updateTime, + }; + } + + /** + * Finds one active binding or throws a Vben-compatible not-found error. + * @param id - Valid decimal Snowflake identifier. + * @returns Active local updater. + */ + private async findActiveRecord(id: string): Promise { + const record = await this.recordRepository.findOne({ + where: { id, isDeleted: false }, + }); + if (!record) { + throwVbenError('DDNS 自动更新配置不存在', HttpStatus.NOT_FOUND); + } + return record; + } + + /** + * Finds one active DNS identity. + * @param activeKey - Canonical lower-family plus FQDN key. + * @returns Conflicting record or null. + */ + private findByActiveKey( + activeKey: string, + ): Promise { + return this.recordRepository.findOne({ where: { activeKey } }); + } + + /** + * Rejects an already-owned active DNS identity. + * @param activeKey - Canonical lower-family plus FQDN key. + */ + private async assertActiveKeyAvailable(activeKey: string): Promise { + if (await this.findByActiveKey(activeKey)) { + throwVbenError('同类型完整域名已存在自动更新配置', HttpStatus.CONFLICT); + } + } + + /** + * Persists one record and publishes only when response semantics changed. + * @param record - Mutated entity. + * @param beforeSemantic - Fingerprint captured before mutation. + */ + private async saveWithSemanticEvent( + record: NetworkDdnsRecord, + beforeSemantic: string, + ): Promise { + await this.recordRepository.save(record); + if (this.semanticFingerprint(record) !== beforeSemantic) { + this.publishSemanticChange(); + } + } + + /** + * Persists only reconciler-owned fields with an optimistic identity/time guard. + * @param record - Mutated current binding snapshot. + * @param beforeSemantic - Fingerprint captured before the reconciler mutation. + * @param expectedProviderRecordId - Provider identity read before this mutation. + * @returns True when exactly one unchanged active row accepted the update. + */ + private async saveReconcileState( + record: NetworkDdnsRecord, + beforeSemantic: string, + expectedProviderRecordId: null | string = record.providerRecordId || null, + ): Promise { + const expectedUpdateTime = record.updateTime; + const previousTimestamp = new Date(expectedUpdateTime).getTime(); + if (!Number.isFinite(previousTimestamp)) { + this.enqueueInternalReconcile(String(record.id)); + return false; + } + const updateTime = new KtDateTime( + Math.max(Date.now(), previousTimestamp + 1), + ); + const result = await this.recordRepository.update( + { + activeKey: record.activeKey as string, + domain: record.domain, + enabled: true, + id: String(record.id), + isDeleted: false, + portForwardId: record.portForwardId || IsNull(), + providerRecordId: expectedProviderRecordId || IsNull(), + recordType: record.recordType, + sourceType: record.sourceType, + subDomain: record.subDomain, + updateTime: expectedUpdateTime, + }, + { + appliedAddress: record.appliedAddress || null, + lastAttemptAt: record.lastAttemptAt || null, + lastErrorCode: record.lastErrorCode || null, + lastErrorMessage: record.lastErrorMessage || null, + lastSyncedAt: record.lastSyncedAt || null, + nextRetryAt: record.nextRetryAt || null, + providerRecordId: record.providerRecordId || null, + retryCount: record.retryCount, + sourceAddress: record.sourceAddress || null, + syncStatus: record.syncStatus, + updateTime, + }, + ); + if (result.affected !== 1) { + this.enqueueInternalReconcile(String(record.id)); + return false; + } + record.updateTime = updateTime; + if (this.semanticFingerprint(record) !== beforeSemantic) { + this.publishSemanticChange(); + } + return true; + } + + /** Publishes a committed DDNS semantic event without coupling persistence to SSE. */ + private publishSemanticChange(): void { + try { + this.eventStream.publishCommitted('ddns'); + } catch { + // Persistence is authoritative; a later HTTP snapshot repairs missed SSE. + } + } + + /** + * Captures user-visible fields while excluding retry counts and timestamp-only churn. + * @param record - Current persisted or pending entity state. + * @returns Stable comparison string for SSE emission. + */ + private semanticFingerprint(record: NetworkDdnsRecord): string { + return JSON.stringify([ + record.appliedAddress || null, + record.domain, + record.enabled, + record.isDeleted, + record.lastErrorCode || null, + record.lastErrorMessage || null, + record.name, + record.portForwardId || null, + record.recordType, + record.remark || null, + record.sourceAddress || null, + record.sourceType, + record.subDomain, + record.syncStatus, + ]); + } + + /** + * Captures only fields that make a provider response applicable. + * @param record - Binding immediately before provider I/O. + * @returns Stable identity excluding display and retry state. + */ + private reconcileIdentity(record: NetworkDdnsRecord): string { + return JSON.stringify([ + record.domain, + record.enabled, + record.isDeleted, + record.portForwardId || null, + record.providerRecordId || null, + record.recordType, + record.sourceType, + record.subDomain, + ]); + } + + /** + * Resets one enabled binding to durable pending state. + * @param record - Binding requested for immediate reconciliation. + */ + private markPending(record: NetworkDdnsRecord): void { + record.lastErrorCode = null; + record.lastErrorMessage = null; + record.nextRetryAt = null; + record.retryCount = 0; + record.syncStatus = 'pending'; + } + + /** + * Stops local scheduling without clearing the last provider-confirmed address. + * @param record - Disabled or deleted local updater. + */ + private markDisabled(record: NetworkDdnsRecord): void { + record.lastErrorCode = null; + record.lastErrorMessage = null; + record.nextRetryAt = null; + record.retryCount = 0; + record.syncStatus = 'disabled'; + } + + /** + * Calculates persistent exponential retry delay. + * @param retryCount - One-based bounded failed-attempt count. + * @returns Delay capped at fifteen minutes. + */ + private retryDelayMs(retryCount: number): number { + return Math.min( + RETRY_BASE_DELAY_MS * 2 ** Math.max(0, retryCount - 1), + RETRY_MAX_DELAY_MS, + ); + } + + /** Returns the configured singleton Agent identifier. */ + private agentId(): string { + return ( + this.configService.get('NETWORK_AGENT_ID') || DEFAULT_AGENT_ID + ); + } + + /** Returns the bounded freshness window for Agent IPv6 status. */ + private agentIpv6MaxAgeMs(): number { + return this.durationConfig( + 'NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS', + DEFAULT_AGENT_IPV6_MAX_AGE_MS, + ); + } + + /** Returns the bounded periodic recovery and due-retry cadence. */ + private reconcileIntervalMs(): number { + return this.durationConfig( + 'NETWORK_DDNS_RECONCILE_INTERVAL_MS', + DEFAULT_RECONCILE_INTERVAL_MS, + ); + } + + /** + * Reads a positive bounded millisecond duration. + * @param key - Runtime configuration key. + * @param fallback - Safe default when absent or invalid. + * @returns Duration between one second and one day. + */ + private durationConfig(key: string, fallback: number): number { + const value = Number(this.configService.get(key)); + return Number.isFinite(value) && value >= 1_000 && value <= 86_400_000 + ? Math.floor(value) + : fallback; + } + + /** + * Validates decimal Snowflake path input without number coercion. + * @param id - Route or internal record identifier. + */ + private assertId(id: string): void { + if (!/^\d{1,24}$/.test(id)) { + throwVbenError('DDNS 配置 ID 无效', HttpStatus.BAD_REQUEST); + } + } + + /** + * Detects MySQL nullable-unique active-key conflicts. + * @param error - Unknown repository failure. + * @returns True only for MySQL duplicate-key metadata. + */ + private isDuplicateKeyError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const record = error as { code?: unknown; errno?: unknown }; + return record.code === 'ER_DUP_ENTRY' || record.errno === 1062; + } +} + +/** + * Validates dot-separated ASCII DNS labels. + * @param value - Canonical candidate without a trailing dot. + * @param requireMultipleLabels - Whether a public zone-style name is required. + * @returns True when every label and total length are valid. + */ +function isValidDnsName( + value: string, + requireMultipleLabels: boolean, +): boolean { + if (!value || value.length > 253) return false; + const labels = value.split('.'); + if (requireMultipleLabels && labels.length < 2) return false; + return labels.every( + (label) => label.length <= 63 && DNS_LABEL_PATTERN.test(label), + ); +} + +/** + * Canonicalizes a globally routable IPv6 address from persisted Agent state. + * @param value - Optional persisted address. + * @returns Lowercase canonical address or null for non-global/invalid input. + */ +function normalizeGlobalIpv6(value?: null | string): null | string { + if (!value || isIP(value) !== 6) return null; + let normalized: string; + try { + const hostname = new URL(`http://[${value}]/`).hostname; + normalized = hostname.slice(1, -1).toLowerCase(); + } catch { + return null; + } + const firstHextet = Number.parseInt(normalized.split(':', 1)[0], 16); + return Number.isInteger(firstHextet) && + firstHextet >= 0x2000 && + firstHextet <= 0x3fff + ? normalized + : null; +} + +/** + * Orders decimal Snowflake strings without lossy number coercion. + * @param left - First decimal identifier. + * @param right - Second decimal identifier. + * @returns Standard ascending comparator result. + */ +function compareDecimalIds(left: string, right: string): number { + if (left.length !== right.length) return left.length - right.length; + return left.localeCompare(right); +} diff --git a/src/modules/admin/platform-config/network-management/network-dnspod.client.ts b/src/modules/admin/platform-config/network-management/network-dnspod.client.ts new file mode 100644 index 0000000..8ffa368 --- /dev/null +++ b/src/modules/admin/platform-config/network-management/network-dnspod.client.ts @@ -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>; +} + +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)[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; + 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).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 { + 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 { + 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(key); + return typeof value === 'string' ? value.trim() : ''; + } +} diff --git a/src/modules/admin/platform-config/network-management/network-management.controller.ts b/src/modules/admin/platform-config/network-management/network-management.controller.ts index 52641a6..f124759 100644 --- a/src/modules/admin/platform-config/network-management/network-management.controller.ts +++ b/src/modules/admin/platform-config/network-management/network-management.controller.ts @@ -22,11 +22,15 @@ import { vbenPage, vbenSuccess } from '@/common'; import { AdminSuperGuard } from '@/modules/admin/identity/auth/admin-super.guard'; import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; import { + NetworkDdnsListQueryDto, + NetworkDdnsRecordInputDto, + NetworkDdnsSourceOptionsQueryDto, NetworkEndpointHistoryQueryDto, NetworkPortForwardCreateDto, NetworkPortForwardListQueryDto, NetworkPortForwardUpdateDto, } from './network-management.dto'; +import { NetworkDdnsService } from './network-ddns.service'; import { NetworkManagementService } from './network-management.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. * @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. */ constructor( private readonly service: NetworkManagementService, + private readonly ddnsService: NetworkDdnsService, private readonly eventStream: NetworkManagementEventStreamService, ) {} @@ -181,6 +187,85 @@ export class NetworkManagementController { 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. * @param response - Express response receiving the fixed directive. diff --git a/src/modules/admin/platform-config/network-management/network-management.dto.ts b/src/modules/admin/platform-config/network-management/network-management.dto.ts index 8f5ea54..0f64929 100644 --- a/src/modules/admin/platform-config/network-management/network-management.dto.ts +++ b/src/modules/admin/platform-config/network-management/network-management.dto.ts @@ -1,5 +1,6 @@ -import { Type } from 'class-transformer'; +import { Transform, Type, type TransformFnParams } from 'class-transformer'; import { + IsBoolean, IsIn, IsInt, IsOptional, @@ -9,15 +10,54 @@ import { Max, MaxLength, Min, + Validate, ValidateIf, + type ValidationArguments, + ValidatorConstraint, + type ValidatorConstraintInterface, } from 'class-validator'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import type { + NetworkDdnsRecordType, + NetworkDdnsSourceType, + NetworkDdnsSyncStatus, KeeperStatus, PortForwardProtocol, PortForwardSyncStatus, } 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 { @ApiProperty({ maxLength: 100 }) @IsString() @@ -134,6 +174,109 @@ export class NetworkEndpointHistoryQueryDto { 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 { @ApiProperty() id: string; @@ -186,3 +329,14 @@ export class NetworkPortForwardResponseDto { function isProvided(_object: object, value: unknown): boolean { 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; +} diff --git a/src/modules/admin/platform-config/network-management/network-management.service.ts b/src/modules/admin/platform-config/network-management/network-management.service.ts index 6ea5b80..4fa0631 100644 --- a/src/modules/admin/platform-config/network-management/network-management.service.ts +++ b/src/modules/admin/platform-config/network-management/network-management.service.ts @@ -304,6 +304,8 @@ export class NetworkManagementService { lastErrorCode: null, lastErrorMessage: null, lastHeartbeatAt: null, + currentIpv6ObservedAt: null, + currentPublicIpv6: null, online: false, publishedRevision: '0', targetIpv4: this.targetIpv4(), @@ -323,6 +325,8 @@ export class NetworkManagementService { lastMqttErrorMessage: state.lastMqttErrorMessage || null, lastReconcileErrorCode: state.lastReconcileErrorCode || null, lastReconcileErrorMessage: state.lastReconcileErrorMessage || null, + currentIpv6ObservedAt: state.currentIpv6ObservedAt || null, + currentPublicIpv6: state.currentPublicIpv6 || null, online: state.online, publishedRevision: state.publishedRevision, startedAt: state.startedAt || null, diff --git a/src/modules/admin/platform-config/network-management/network-management.types.ts b/src/modules/admin/platform-config/network-management/network-management.types.ts index c47ca92..5d565bb 100644 --- a/src/modules/admin/platform-config/network-management/network-management.types.ts +++ b/src/modules/admin/platform-config/network-management/network-management.types.ts @@ -1,4 +1,6 @@ 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 { NetworkPortForward } from './network-management.entity'; @@ -28,7 +30,11 @@ export type EndpointEventType = | 'published' | 'restored' | 'withdrawn'; -export type NetworkStateChangeSource = 'events' | 'reported' | 'status'; +export type NetworkStateChangeSource = + | 'ddns' + | 'events' + | 'reported' + | 'status'; export type NetworkStateChangeEvent = { eventId: string; @@ -36,6 +42,51 @@ export type NetworkStateChangeEvent = { 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; + +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 = { externalPort: number; id: string; @@ -98,6 +149,7 @@ export type NetworkStatusSnapshot = { errorMessage?: string | null; observedAt: string; online: boolean; + publicIpv6?: string | null; schemaVersion: typeof NETWORK_AGENT_SCHEMA_VERSION; startedAt?: string | null; version?: string | null; @@ -290,23 +342,53 @@ export function parseStatusSnapshot(value: unknown): NetworkStatusSnapshot { const record = exactRecord( value, ['agentId', 'observedAt', 'online', 'schemaVersion'], - ['errorCode', 'errorMessage', 'startedAt', 'version'], + ['errorCode', 'errorMessage', 'publicIpv6', 'startedAt', 'version'], 'status', ); assertSchema(record.schemaVersion); if (typeof record.online !== 'boolean') invalid('status.online'); + const publicIpv6 = optionalGlobalIpv6(record.publicIpv6, 'status.publicIpv6'); + if (!record.online && publicIpv6) invalid('status.publicIpv6'); return { agentId: boundedString(record.agentId, 'agentId', 64), errorCode: optionalString(record.errorCode, 'errorCode', 64), errorMessage: optionalString(record.errorMessage, 'errorMessage', 500), observedAt: isoString(record.observedAt, 'observedAt'), online: record.online, + publicIpv6, schemaVersion: NETWORK_AGENT_SCHEMA_VERSION, startedAt: optionalIsoString(record.startedAt, 'startedAt'), 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. * @param value - Untrusted MQTT JSON value. diff --git a/src/runtime/config/runtime-config.service.ts b/src/runtime/config/runtime-config.service.ts index da929b0..9ca47cf 100644 --- a/src/runtime/config/runtime-config.service.ts +++ b/src/runtime/config/runtime-config.service.ts @@ -31,6 +31,11 @@ const REQUIRED_CONFIG_KEYS = [ const OPTIONAL_CONFIG_CHECKS: ReadonlyArray = [ '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_PORT', 'MINIO_ACCESS_KEY', diff --git a/test/admin/network-management/network-agent-mqtt.service.spec.ts b/test/admin/network-management/network-agent-mqtt.service.spec.ts index c713f47..9d5cac0 100644 --- a/test/admin/network-management/network-agent-mqtt.service.spec.ts +++ b/test/admin/network-management/network-agent-mqtt.service.spec.ts @@ -24,6 +24,7 @@ type MqttHarness = { publishCommitted: jest.Mock; mapping: NetworkPortForward; publishCallback: () => (error?: Error) => void; + requestDdnsReconcile: jest.Mock; service: NetworkAgentMqttService; state: NetworkAgentState; stateSave: jest.Mock; @@ -137,11 +138,13 @@ function createHarness(): MqttHarness { const eventStream = { publishCommitted, } as unknown as NetworkManagementEventStreamService; + const requestDdnsReconcile = jest.fn(); const service = new NetworkAgentMqttService( configService, dataSource, eventStream, factory, + { requestReconcile: requestDdnsReconcile } as never, ); return { client, @@ -151,6 +154,7 @@ function createHarness(): MqttHarness { mapping, publishCallback: () => publishAck, publishCommitted, + requestDdnsReconcile, service, state, stateSave, @@ -498,6 +502,7 @@ describe('NetworkAgentMqttService', () => { expect(harness.stateSave).toHaveBeenCalledTimes(stateSavesAfterFirstReport); expect(harness.publishCommitted).toHaveBeenCalledTimes(1); expect(harness.publishCommitted).toHaveBeenCalledWith('reported'); + expect(harness.requestDdnsReconcile).toHaveBeenCalledTimes(1); }); /** 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)); harness.publishCommitted.mockClear(); + harness.requestDdnsReconcile.mockClear(); const savesBeforeRenewal = harness.mappingSave.mock.calls.length; await harness.service.consumeMessage( topic, @@ -534,6 +540,7 @@ describe('NetworkAgentMqttService', () => { ); expect(harness.mappingSave).toHaveBeenCalledTimes(savesBeforeRenewal + 1); expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); await harness.service.consumeMessage( topic, @@ -548,6 +555,7 @@ describe('NetworkAgentMqttService', () => { ); expect(harness.publishCommitted).toHaveBeenCalledTimes(1); 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 () => { @@ -786,6 +794,7 @@ describe('NetworkAgentMqttService', () => { ); expect(harness.stateSave).toHaveBeenCalledTimes(savesBeforeHeartbeat + 1); expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); await harness.service.consumeMessage( topic, @@ -793,6 +802,44 @@ describe('NetworkAgentMqttService', () => { ); expect(harness.publishCommitted).toHaveBeenCalledTimes(1); 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 () => { diff --git a/test/admin/network-management/network-ddns-sql.spec.ts b/test/admin/network-management/network-ddns-sql.spec.ts new file mode 100644 index 0000000..bc1d2a1 --- /dev/null +++ b/test/admin/network-management/network-ddns-sql.spec.ts @@ -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); + } + }); +}); diff --git a/test/admin/network-management/network-ddns.service.spec.ts b/test/admin/network-management/network-ddns.service.spec.ts new file mode 100644 index 0000000..9a87455 --- /dev/null +++ b/test/admin/network-management/network-ddns.service.spec.ts @@ -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>; + 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, + patch: Partial, + ) => { + const index = records.findIndex((record) => + Object.entries(criteria).every(([key, expected]) => + matchesUpdateCriterion( + (record as unknown as Record)[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; + const mappingRepository = { + find: async () => [mapping], + findOne: async ({ where }) => + where.id === mapping.id && !mapping.isDeleted ? mapping : null, + } as unknown as Repository; + const stateRepository = { + findOne: async () => state, + } as unknown as Repository; + 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((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((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, + ); + }); +}); diff --git a/test/admin/network-management/network-dnspod.client.spec.ts b/test/admin/network-management/network-dnspod.client.spec.ts new file mode 100644 index 0000000..f952f94 --- /dev/null +++ b/test/admin/network-management/network-dnspod.client.spec.ts @@ -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>, + [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, +): 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 = enabledConfig, +) { + const factory = jest.fn(() => 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'); + }); +}); diff --git a/test/admin/network-management/network-management-deployment.spec.ts b/test/admin/network-management/network-management-deployment.spec.ts index 22ca2be..b07ebdd 100644 --- a/test/admin/network-management/network-management-deployment.spec.ts +++ b/test/admin/network-management/network-management-deployment.spec.ts @@ -40,4 +40,28 @@ describe('Network management production deployment contract', () => { ); 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); + }); }); diff --git a/test/admin/network-management/network-management.controller.spec.ts b/test/admin/network-management/network-management.controller.spec.ts index 81b63d3..cecf76b 100644 --- a/test/admin/network-management/network-management.controller.spec.ts +++ b/test/admin/network-management/network-management.controller.spec.ts @@ -5,6 +5,7 @@ import * as request from 'supertest'; import { AdminSuperGuard } from '../../../src/modules/admin/identity/auth/admin-super.guard'; import { JwtAuthGuard } from '../../../src/modules/admin/identity/auth/jwt-auth.guard'; import { NetworkManagementController } from '../../../src/modules/admin/platform-config/network-management/network-management.controller'; +import { NetworkDdnsService } from '../../../src/modules/admin/platform-config/network-management/network-ddns.service'; import { NetworkManagementEventStreamService, 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 () => { const moduleRef = await Test.createTestingModule({ @@ -49,6 +59,7 @@ describe('NetworkManagementController', () => { providers: [ AdminSuperGuard, { provide: NetworkManagementService, useValue: service }, + { provide: NetworkDdnsService, useValue: ddnsService }, { provide: NetworkManagementEventStreamService, useValue: eventStream, @@ -79,6 +90,21 @@ describe('NetworkManagementController', () => { agentId: 'nas-main', 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 () => { @@ -202,6 +228,112 @@ describe('NetworkManagementController', () => { 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 () => { let parsed = false; let serialized = ''; diff --git a/test/admin/network-management/network-management.entity.spec.ts b/test/admin/network-management/network-management.entity.spec.ts index 93c6d66..22e0dc5 100644 --- a/test/admin/network-management/network-management.entity.spec.ts +++ b/test/admin/network-management/network-management.entity.spec.ts @@ -6,19 +6,26 @@ import { } from '../../../src/modules/admin/platform-config/admin-platform-config.module'; import { NetworkAgentMqttService } from '../../../src/modules/admin/platform-config/network-management/network-agent-mqtt.service'; import { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity'; +import { 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 { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity'; import { NetworkManagementService } from '../../../src/modules/admin/platform-config/network-management/network-management.service'; describe('network management persistence module', () => { - it('registers the three exact database entity tables', () => { + it('registers the four exact database entity tables', () => { 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([ 'network_agent_state', + 'network_ddns_record', 'network_endpoint_history', 'network_port_forward', ]); @@ -32,12 +39,16 @@ describe('network management persistence module', () => { expect(ADMIN_PLATFORM_CONFIG_PROVIDERS).toEqual( expect.arrayContaining([ NetworkManagementService, + NetworkDdnsService, + NetworkDnsPodClient, NetworkAgentMqttService, ]), ); expect(providers).toEqual( expect.arrayContaining([ NetworkManagementService, + NetworkDdnsService, + NetworkDnsPodClient, NetworkAgentMqttService, ]), ); diff --git a/test/admin/network-management/network-management.types.spec.ts b/test/admin/network-management/network-management.types.spec.ts index a07dd75..8286b9f 100644 --- a/test/admin/network-management/network-management.types.spec.ts +++ b/test/admin/network-management/network-management.types.spec.ts @@ -181,4 +181,38 @@ describe('network management MQTT contracts', () => { }), ).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(); + } + }); }); diff --git a/test/runtime/runtime-config.service.spec.ts b/test/runtime/runtime-config.service.spec.ts index dcfe1f5..e58b912 100644 --- a/test/runtime/runtime-config.service.spec.ts +++ b/test/runtime/runtime-config.service.spec.ts @@ -269,8 +269,56 @@ describe('RuntimeConfigService', () => { }), ]), ); - expect(JSON.stringify(networkChecks)).not.toContain( - 'network-api-password', + expect(JSON.stringify(networkChecks)).not.toContain('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); }); });