feat: 增加腾讯云双栈自动DDNS
This commit is contained in:
parent
b5041af7c6
commit
ddc0e9580b
@ -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
|
||||
|
||||
15
API.md
15
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` |
|
||||
|
||||
@ -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/<pid>/mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q <uin>` 快速登录参数,避免重启后退回无账号扫码;v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true` 与 `o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。
|
||||
|
||||
运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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`)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
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,
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -0,0 +1,117 @@
|
||||
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||
import {
|
||||
ensureSnowflakeId,
|
||||
KtCreateDateColumn,
|
||||
KtDateTime,
|
||||
KtDateTimeColumn,
|
||||
KtUpdateDateColumn,
|
||||
} from '@/common';
|
||||
import type {
|
||||
NetworkDdnsRecordType,
|
||||
NetworkDdnsSourceType,
|
||||
NetworkDdnsSyncStatus,
|
||||
} from './network-management.types';
|
||||
|
||||
@Entity('network_ddns_record')
|
||||
@Index('uk_network_ddns_record_active_key', ['activeKey'], { unique: true })
|
||||
@Index('idx_network_ddns_record_status', [
|
||||
'isDeleted',
|
||||
'enabled',
|
||||
'syncStatus',
|
||||
'nextRetryAt',
|
||||
])
|
||||
@Index('idx_network_ddns_record_port_forward', ['portForwardId'])
|
||||
export class NetworkDdnsRecord {
|
||||
@PrimaryColumn({ type: 'bigint' })
|
||||
id: string;
|
||||
|
||||
@Column({ length: 100 })
|
||||
name: string;
|
||||
|
||||
@Column({ nullable: true, type: 'text' })
|
||||
remark?: null | string;
|
||||
|
||||
@Column({ length: 8, name: 'record_type' })
|
||||
recordType: NetworkDdnsRecordType;
|
||||
|
||||
@Column({ length: 32, name: 'source_type' })
|
||||
sourceType: NetworkDdnsSourceType;
|
||||
|
||||
@Column({ name: 'port_forward_id', nullable: true, type: 'bigint' })
|
||||
portForwardId?: null | string;
|
||||
|
||||
@Column({ length: 253 })
|
||||
domain: string;
|
||||
|
||||
@Column({ length: 253, name: 'sub_domain' })
|
||||
subDomain: string;
|
||||
|
||||
@Column({ length: 300, name: 'active_key', nullable: true })
|
||||
activeKey?: null | string;
|
||||
|
||||
@Column({ default: false, type: 'boolean' })
|
||||
enabled: boolean;
|
||||
|
||||
@Column({ default: 'disabled', length: 32, name: 'sync_status' })
|
||||
syncStatus: NetworkDdnsSyncStatus;
|
||||
|
||||
@Column({ length: 32, name: 'provider_record_id', nullable: true })
|
||||
providerRecordId?: null | string;
|
||||
|
||||
@Column({ length: 45, name: 'source_address', nullable: true })
|
||||
sourceAddress?: null | string;
|
||||
|
||||
@Column({ length: 45, name: 'applied_address', nullable: true })
|
||||
appliedAddress?: null | string;
|
||||
|
||||
@Column({ default: 0, name: 'retry_count', type: 'int', unsigned: true })
|
||||
retryCount: number;
|
||||
|
||||
@KtDateTimeColumn({
|
||||
name: 'next_retry_at',
|
||||
nullable: true,
|
||||
precision: 3,
|
||||
type: 'datetime',
|
||||
})
|
||||
nextRetryAt?: KtDateTime | null;
|
||||
|
||||
@KtDateTimeColumn({
|
||||
name: 'last_attempt_at',
|
||||
nullable: true,
|
||||
precision: 3,
|
||||
type: 'datetime',
|
||||
})
|
||||
lastAttemptAt?: KtDateTime | null;
|
||||
|
||||
@KtDateTimeColumn({
|
||||
name: 'last_synced_at',
|
||||
nullable: true,
|
||||
precision: 3,
|
||||
type: 'datetime',
|
||||
})
|
||||
lastSyncedAt?: KtDateTime | null;
|
||||
|
||||
@Column({ length: 64, name: 'last_error_code', nullable: true })
|
||||
lastErrorCode?: null | string;
|
||||
|
||||
@Column({ length: 512, name: 'last_error_message', nullable: true })
|
||||
lastErrorMessage?: null | string;
|
||||
|
||||
@Column({ default: false, name: 'is_deleted', type: 'boolean' })
|
||||
isDeleted: boolean;
|
||||
|
||||
@KtCreateDateColumn({ name: 'create_time', precision: 3, type: 'datetime' })
|
||||
createTime: KtDateTime;
|
||||
|
||||
@KtUpdateDateColumn({ name: 'update_time', precision: 3, type: 'datetime' })
|
||||
updateTime: KtDateTime;
|
||||
|
||||
/**
|
||||
* Assigns a Snowflake string before the local automatic updater is persisted.
|
||||
* @returns The stable DDNS binding identifier.
|
||||
*/
|
||||
@BeforeInsert()
|
||||
createId(): string {
|
||||
return ensureSnowflakeId(this);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,635 @@
|
||||
import { Injectable, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { isIP } from 'node:net';
|
||||
import type { ClientConfig } from 'tencentcloud-sdk-nodejs/tencentcloud/common/interface';
|
||||
import { dnspod } from 'tencentcloud-sdk-nodejs/tencentcloud/services/dnspod';
|
||||
import type { DescribeRecordFilterListRequest as TencentDescribeRecordFilterListRequest } from 'tencentcloud-sdk-nodejs/tencentcloud/services/dnspod/v20210323/dnspod_models';
|
||||
|
||||
export type NetworkDnsPodProviderStatus = {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
provider: 'dnspod';
|
||||
};
|
||||
|
||||
export type NetworkDnsPodReconcileInput = {
|
||||
domain: string;
|
||||
expectedRecordId?: null | string;
|
||||
recordType: 'A' | 'AAAA';
|
||||
subDomain: string;
|
||||
targetAddress: string;
|
||||
};
|
||||
|
||||
export type NetworkDnsPodReconcileResult = {
|
||||
appliedAddress: string;
|
||||
changed: boolean;
|
||||
providerRecordId: string;
|
||||
};
|
||||
|
||||
type DnsPodRecord = {
|
||||
Line?: string;
|
||||
LineId?: string;
|
||||
Name?: string;
|
||||
RecordId?: number;
|
||||
Status?: string;
|
||||
TTL?: number;
|
||||
Type?: string;
|
||||
Value?: string;
|
||||
};
|
||||
|
||||
type DescribeRecordFilterListRequest =
|
||||
TencentDescribeRecordFilterListRequest & {
|
||||
Domain: string;
|
||||
IsExactSubDomain: true;
|
||||
Limit: 2;
|
||||
Offset: 0;
|
||||
RecordLine: string[];
|
||||
RecordType: Array<'A' | 'AAAA'>;
|
||||
SubDomain: string;
|
||||
};
|
||||
|
||||
type ModifyDynamicDNSRequest = {
|
||||
Domain: string;
|
||||
RecordId: number;
|
||||
RecordLine: string;
|
||||
RecordLineId: string;
|
||||
SubDomain: string;
|
||||
Ttl: number;
|
||||
Value: string;
|
||||
};
|
||||
|
||||
interface DnsPodSdkClient {
|
||||
DescribeRecordFilterList(request: DescribeRecordFilterListRequest): Promise<{
|
||||
RecordList?: DnsPodRecord[];
|
||||
}>;
|
||||
ModifyDynamicDNS(
|
||||
request: ModifyDynamicDNSRequest,
|
||||
): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type NetworkDnsPodClientFactory = (
|
||||
clientConfig: ClientConfig,
|
||||
) => DnsPodSdkClient;
|
||||
|
||||
type NormalizedReconcileInput = {
|
||||
domain: string;
|
||||
expectedRecordId: null | string;
|
||||
recordType: 'A' | 'AAAA';
|
||||
subDomain: string;
|
||||
targetAddress: string;
|
||||
};
|
||||
|
||||
type ValidatedRecord = {
|
||||
address: string;
|
||||
line: string;
|
||||
lineId: string;
|
||||
recordId: number;
|
||||
recordIdText: string;
|
||||
ttl: number;
|
||||
};
|
||||
|
||||
const DNSPOD_ENDPOINT = 'dnspod.tencentcloudapi.com';
|
||||
const DNSPOD_REQUEST_TIMEOUT_SECONDS = 10;
|
||||
const DNSPOD_DEFAULT_LINE = '默认';
|
||||
const MAX_DNSPOD_TTL = 604_800;
|
||||
const MAX_SAFE_RECORD_ID = BigInt(Number.MAX_SAFE_INTEGER);
|
||||
const RETRYABLE_PROVIDER_CODES = [
|
||||
'InternalError',
|
||||
'ServerUnavailable',
|
||||
'ServiceUnavailable',
|
||||
];
|
||||
const PROVIDER_AUTH_CODES = ['AuthFailure'];
|
||||
const PROVIDER_PERMISSION_CODES = [
|
||||
'AuthFailure.UnauthorizedOperation',
|
||||
'FailedOperation.NoPermission',
|
||||
'OperationDenied',
|
||||
'UnauthorizedOperation',
|
||||
];
|
||||
const RETRYABLE_NETWORK_CODES = new Set([
|
||||
'ECONNABORTED',
|
||||
'ECONNREFUSED',
|
||||
'ECONNRESET',
|
||||
'EHOSTUNREACH',
|
||||
'ENETDOWN',
|
||||
'ENETUNREACH',
|
||||
'ESOCKETTIMEDOUT',
|
||||
'ETIMEDOUT',
|
||||
'EAI_AGAIN',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Represents a stable, redacted error at the DNSPod provider boundary.
|
||||
*/
|
||||
export class NetworkDnsPodClientError extends Error {
|
||||
/**
|
||||
* Creates a safe provider-boundary error without retaining the raw SDK error.
|
||||
* @param code - Stable application error code.
|
||||
* @param message - Redacted operator-facing message.
|
||||
* @param retryable - Whether a later retry may recover automatically.
|
||||
*/
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
message: string,
|
||||
public readonly retryable: boolean,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'NetworkDnsPodClientError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the official DNSPod SDK client used in production.
|
||||
* @param clientConfig - Credential and bounded HTTP profile.
|
||||
* @returns DNSPod client restricted to the two operations required by DDNS.
|
||||
*/
|
||||
function createDnsPodSdkClient(clientConfig: ClientConfig): DnsPodSdkClient {
|
||||
return new dnspod.v20210323.Client(
|
||||
clientConfig,
|
||||
) as unknown as DnsPodSdkClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a DNS label without accepting wildcard or URL syntax.
|
||||
* @param value - Candidate DNS label.
|
||||
* @returns True when the label is safe for an exact DNSPod query.
|
||||
*/
|
||||
function isValidDnsLabel(value: string): boolean {
|
||||
return (
|
||||
value.length >= 1 &&
|
||||
value.length <= 63 &&
|
||||
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(value)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a dot-separated ASCII DNS name.
|
||||
* @param value - Candidate domain or host-record name.
|
||||
* @param requireMultipleLabels - Whether a public zone-style name is required.
|
||||
* @returns True when every label is valid and the total length is bounded.
|
||||
*/
|
||||
function isValidDnsName(
|
||||
value: string,
|
||||
requireMultipleLabels: boolean,
|
||||
): boolean {
|
||||
if (value.length > 253) return false;
|
||||
const labels = value.split('.');
|
||||
if (requireMultipleLabels && labels.length < 2) return false;
|
||||
return labels.every(isValidDnsLabel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalizes and validates an address for a DNS record family.
|
||||
* @param address - Raw address supplied by configuration or DNSPod.
|
||||
* @param recordType - DNS address record family.
|
||||
* @returns Canonical address, or null when the value is unsafe or mismatched.
|
||||
*/
|
||||
function normalizeAddress(
|
||||
address: unknown,
|
||||
recordType: 'A' | 'AAAA',
|
||||
): null | string {
|
||||
if (
|
||||
typeof address !== 'string' ||
|
||||
address.length === 0 ||
|
||||
address !== address.trim()
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (recordType === 'A') {
|
||||
return isIP(address) === 4 ? address : null;
|
||||
}
|
||||
if (isIP(address) !== 6) return null;
|
||||
|
||||
let canonicalAddress: string;
|
||||
try {
|
||||
const hostname = new URL(`http://[${address}]/`).hostname;
|
||||
canonicalAddress = hostname.slice(1, -1).toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstHextet = Number.parseInt(canonicalAddress.split(':', 1)[0], 16);
|
||||
if (
|
||||
!Number.isInteger(firstHextet) ||
|
||||
firstHextet < 0x2000 ||
|
||||
firstHextet > 0x3fff
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return canonicalAddress;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an optional persisted record ID into a safe comparison value.
|
||||
* @param value - Expected DNSPod record ID from persisted configuration.
|
||||
* @returns Canonical decimal ID or null when no expectation was supplied.
|
||||
*/
|
||||
function normalizeExpectedRecordId(
|
||||
value: NetworkDnsPodReconcileInput['expectedRecordId'],
|
||||
): null | string {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value !== 'string' || !/^[1-9]\d*$/.test(value)) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_INVALID_INPUT',
|
||||
'DNSPod reconcile input is invalid',
|
||||
false,
|
||||
);
|
||||
}
|
||||
const numericValue = BigInt(value);
|
||||
if (numericValue > MAX_SAFE_RECORD_ID) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_INVALID_INPUT',
|
||||
'DNSPod reconcile input is invalid',
|
||||
false,
|
||||
);
|
||||
}
|
||||
return numericValue.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes and validates all reconcile input before any SDK client is created.
|
||||
* @param input - Reconcile request from the DDNS service.
|
||||
* @returns Safe normalized values for exact provider requests.
|
||||
*/
|
||||
function normalizeInput(
|
||||
input: NetworkDnsPodReconcileInput,
|
||||
): NormalizedReconcileInput {
|
||||
const domain =
|
||||
typeof input?.domain === 'string' ? input.domain.toLowerCase() : '';
|
||||
const subDomain =
|
||||
typeof input?.subDomain === 'string' ? input.subDomain.toLowerCase() : '';
|
||||
const recordType = input?.recordType;
|
||||
const validDomain =
|
||||
domain === input?.domain &&
|
||||
isValidDnsName(domain, true) &&
|
||||
!domain.endsWith('.');
|
||||
const validSubDomain =
|
||||
subDomain === input?.subDomain &&
|
||||
(subDomain === '@' || isValidDnsName(subDomain, false));
|
||||
const fqdnLength =
|
||||
subDomain === '@' ? domain.length : `${subDomain}.${domain}`.length;
|
||||
const targetAddress =
|
||||
recordType === 'A' || recordType === 'AAAA'
|
||||
? normalizeAddress(input.targetAddress, recordType)
|
||||
: null;
|
||||
if (!validDomain || !validSubDomain || fqdnLength > 253 || !targetAddress) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_INVALID_INPUT',
|
||||
'DNSPod reconcile input is invalid',
|
||||
false,
|
||||
);
|
||||
}
|
||||
return {
|
||||
domain,
|
||||
expectedRecordId: normalizeExpectedRecordId(input.expectedRecordId),
|
||||
recordType,
|
||||
subDomain,
|
||||
targetAddress,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a string property from an unknown provider error.
|
||||
* @param value - Unknown SDK error.
|
||||
* @param key - Property name to inspect.
|
||||
* @returns String property when present.
|
||||
*/
|
||||
function errorString(value: unknown, key: string): string {
|
||||
if (!value || typeof value !== 'object') return '';
|
||||
const property = (value as Record<string, unknown>)[key];
|
||||
return typeof property === 'string' ? property : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts an HTTP status from common SDK error shapes.
|
||||
* @param value - Unknown SDK error.
|
||||
* @returns HTTP status or zero when unavailable.
|
||||
*/
|
||||
function errorHttpStatus(value: unknown): number {
|
||||
if (!value || typeof value !== 'object') return 0;
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ['statusCode', 'status', 'httpCode']) {
|
||||
const status = record[key];
|
||||
if (typeof status === 'number') return status;
|
||||
}
|
||||
const response = record.response;
|
||||
if (response && typeof response === 'object') {
|
||||
const status = (response as Record<string, unknown>).status;
|
||||
if (typeof status === 'number') return status;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the SDK failure belongs to an explicitly retryable class.
|
||||
* @param error - Unknown raw SDK error.
|
||||
* @returns True only for rate limit, internal/service, HTTP 429/5xx, timeout, or network errors.
|
||||
*/
|
||||
function isRetryableProviderError(error: unknown): boolean {
|
||||
const code = errorString(error, 'code');
|
||||
const name = errorString(error, 'name');
|
||||
const status = errorHttpStatus(error);
|
||||
return (
|
||||
RETRYABLE_PROVIDER_CODES.some(
|
||||
(prefix) => code === prefix || code.startsWith(`${prefix}.`),
|
||||
) ||
|
||||
RETRYABLE_NETWORK_CODES.has(code.toUpperCase()) ||
|
||||
name === 'AbortError' ||
|
||||
name === 'TimeoutError' ||
|
||||
status === 429 ||
|
||||
status >= 500
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches one provider error code against exact or dotted-prefix categories.
|
||||
* @param code - SDK error code without using its raw message.
|
||||
* @param categories - Stable provider code families.
|
||||
* @returns True when the code belongs to one category.
|
||||
*/
|
||||
function matchesProviderCode(code: string, categories: string[]): boolean {
|
||||
return categories.some(
|
||||
(category) => code === category || code.startsWith(`${category}.`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an unknown SDK failure to a stable error without retaining provider details.
|
||||
* @param error - Raw SDK error used only for retry classification.
|
||||
* @returns Redacted provider-boundary error.
|
||||
*/
|
||||
function mapProviderError(error: unknown): NetworkDnsPodClientError {
|
||||
const code = errorString(error, 'code');
|
||||
const status = errorHttpStatus(error);
|
||||
if (matchesProviderCode(code, ['RequestLimitExceeded']) || status === 429) {
|
||||
return new NetworkDnsPodClientError(
|
||||
'DNSPOD_RATE_LIMITED',
|
||||
'DNSPod provider request was rate limited',
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (matchesProviderCode(code, PROVIDER_PERMISSION_CODES)) {
|
||||
return new NetworkDnsPodClientError(
|
||||
'DNSPOD_PERMISSION_DENIED',
|
||||
'DNSPod provider permission was denied',
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (matchesProviderCode(code, PROVIDER_AUTH_CODES)) {
|
||||
return new NetworkDnsPodClientError(
|
||||
'DNSPOD_AUTH_FAILED',
|
||||
'DNSPod provider authentication failed',
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (isRetryableProviderError(error)) {
|
||||
return new NetworkDnsPodClientError(
|
||||
'DNSPOD_PROVIDER_RETRYABLE',
|
||||
'DNSPod provider request failed temporarily',
|
||||
true,
|
||||
);
|
||||
}
|
||||
return new NetworkDnsPodClientError(
|
||||
'DNSPOD_PROVIDER_REJECTED',
|
||||
'DNSPod provider request was rejected',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a single exact DNSPod record and protects subsequent mutation metadata.
|
||||
* @param records - Provider records returned by the exact list request.
|
||||
* @param input - Normalized reconcile request.
|
||||
* @returns Safe record metadata and canonical current address.
|
||||
*/
|
||||
function validateRecord(
|
||||
records: DnsPodRecord[] | undefined,
|
||||
input: NormalizedReconcileInput,
|
||||
): ValidatedRecord {
|
||||
if (!Array.isArray(records) || records.length === 0) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_RECORD_NOT_FOUND',
|
||||
'DNSPod address record was not found',
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (records.length !== 1) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_RECORD_AMBIGUOUS',
|
||||
'DNSPod address record is ambiguous',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const record = records[0];
|
||||
if (record.Status !== 'ENABLE') {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_RECORD_DISABLED',
|
||||
'DNSPod address record is disabled',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const recordId = record.RecordId;
|
||||
const recordIdIsSafe =
|
||||
typeof recordId === 'number' &&
|
||||
Number.isSafeInteger(recordId) &&
|
||||
recordId > 0;
|
||||
const ttlIsSafe =
|
||||
typeof record.TTL === 'number' &&
|
||||
Number.isSafeInteger(record.TTL) &&
|
||||
record.TTL >= 1 &&
|
||||
record.TTL <= MAX_DNSPOD_TTL;
|
||||
const lineIdIsSafe =
|
||||
typeof record.LineId === 'string' &&
|
||||
/^[A-Za-z0-9=_:-]{1,64}$/.test(record.LineId);
|
||||
const nameMatches =
|
||||
typeof record.Name === 'string' &&
|
||||
record.Name.toLowerCase() === input.subDomain;
|
||||
const address = normalizeAddress(record.Value, input.recordType);
|
||||
if (
|
||||
!recordIdIsSafe ||
|
||||
!ttlIsSafe ||
|
||||
!lineIdIsSafe ||
|
||||
!nameMatches ||
|
||||
record.Type !== input.recordType ||
|
||||
record.Line !== DNSPOD_DEFAULT_LINE ||
|
||||
!address
|
||||
) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_RECORD_INVALID',
|
||||
'DNSPod address record metadata is invalid',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const recordIdText = String(recordId);
|
||||
if (
|
||||
input.expectedRecordId !== null &&
|
||||
input.expectedRecordId !== recordIdText
|
||||
) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_RECORD_MISMATCH',
|
||||
'DNSPod address record identity changed',
|
||||
false,
|
||||
);
|
||||
}
|
||||
return {
|
||||
address,
|
||||
line: record.Line,
|
||||
lineId: record.LineId,
|
||||
recordId,
|
||||
recordIdText,
|
||||
ttl: record.TTL,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NetworkDnsPodClient {
|
||||
private readonly createClient: NetworkDnsPodClientFactory;
|
||||
|
||||
/**
|
||||
* Initializes the DNSPod boundary with lazy SDK client creation.
|
||||
* @param config - Runtime configuration reader.
|
||||
* @param createClient - Optional factory used by isolated tests.
|
||||
*/
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
@Optional() createClient?: NetworkDnsPodClientFactory,
|
||||
) {
|
||||
this.createClient = createClient || createDnsPodSdkClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports explicit provider enablement and credential readiness.
|
||||
* @returns DNSPod provider status without creating an SDK client.
|
||||
*/
|
||||
getStatus(): NetworkDnsPodProviderStatus {
|
||||
return {
|
||||
configured:
|
||||
this.configValue('NETWORK_DDNS_DNSPOD_SECRET_ID').length > 0 &&
|
||||
this.configValue('NETWORK_DDNS_DNSPOD_SECRET_KEY').length > 0,
|
||||
enabled:
|
||||
this.configValue('NETWORK_DDNS_DNSPOD_ENABLED').toLowerCase() ===
|
||||
'true',
|
||||
provider: 'dnspod',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconciles one existing DNSPod A or AAAA record and verifies provider read-back.
|
||||
* @param input - Exact zone, host record, family, target, and optional record identity.
|
||||
* @returns Applied canonical address and provider record identity.
|
||||
*/
|
||||
async reconcile(
|
||||
input: NetworkDnsPodReconcileInput,
|
||||
): Promise<NetworkDnsPodReconcileResult> {
|
||||
const normalizedInput = normalizeInput(input);
|
||||
const status = this.getStatus();
|
||||
if (!status.enabled) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_DISABLED',
|
||||
'DNSPod provider is disabled',
|
||||
false,
|
||||
);
|
||||
}
|
||||
if (!status.configured) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_NOT_CONFIGURED',
|
||||
'DNSPod provider is not configured',
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
const client = this.createClient(this.clientConfig());
|
||||
try {
|
||||
const current = await this.describeExactRecord(client, normalizedInput);
|
||||
if (current.address === normalizedInput.targetAddress) {
|
||||
return {
|
||||
appliedAddress: normalizedInput.targetAddress,
|
||||
changed: false,
|
||||
providerRecordId: current.recordIdText,
|
||||
};
|
||||
}
|
||||
|
||||
await client.ModifyDynamicDNS({
|
||||
Domain: normalizedInput.domain,
|
||||
RecordId: current.recordId,
|
||||
RecordLine: current.line,
|
||||
RecordLineId: current.lineId,
|
||||
SubDomain: normalizedInput.subDomain,
|
||||
Ttl: current.ttl,
|
||||
Value: normalizedInput.targetAddress,
|
||||
});
|
||||
const verified = await this.describeExactRecord(client, {
|
||||
...normalizedInput,
|
||||
expectedRecordId: current.recordIdText,
|
||||
});
|
||||
if (verified.address !== normalizedInput.targetAddress) {
|
||||
throw new NetworkDnsPodClientError(
|
||||
'DNSPOD_VERIFICATION_FAILED',
|
||||
'DNSPod address record verification failed',
|
||||
false,
|
||||
);
|
||||
}
|
||||
return {
|
||||
appliedAddress: normalizedInput.targetAddress,
|
||||
changed: true,
|
||||
providerRecordId: current.recordIdText,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof NetworkDnsPodClientError) throw error;
|
||||
throw mapProviderError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one exact address record and validates all provider metadata.
|
||||
* @param client - Initialized DNSPod SDK boundary.
|
||||
* @param input - Normalized exact-record query.
|
||||
* @returns Validated record safe for comparison or mutation.
|
||||
*/
|
||||
private async describeExactRecord(
|
||||
client: DnsPodSdkClient,
|
||||
input: NormalizedReconcileInput,
|
||||
): Promise<ValidatedRecord> {
|
||||
const request: DescribeRecordFilterListRequest = {
|
||||
Domain: input.domain,
|
||||
IsExactSubDomain: true,
|
||||
Limit: 2,
|
||||
Offset: 0,
|
||||
RecordLine: ['0'],
|
||||
RecordType: [input.recordType],
|
||||
SubDomain: input.subDomain,
|
||||
};
|
||||
const response = await client.DescribeRecordFilterList(request);
|
||||
return validateRecord(response.RecordList, input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the official SDK configuration with bounded HTTP behavior.
|
||||
* @returns DNSPod client configuration; region is intentionally omitted.
|
||||
*/
|
||||
private clientConfig(): ClientConfig {
|
||||
return {
|
||||
credential: {
|
||||
secretId: this.configValue('NETWORK_DDNS_DNSPOD_SECRET_ID'),
|
||||
secretKey: this.configValue('NETWORK_DDNS_DNSPOD_SECRET_KEY'),
|
||||
},
|
||||
profile: {
|
||||
httpProfile: {
|
||||
endpoint: DNSPOD_ENDPOINT,
|
||||
reqTimeout: DNSPOD_REQUEST_TIMEOUT_SECONDS,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one configuration value as a trimmed string.
|
||||
* @param key - Runtime configuration key.
|
||||
* @returns String value or an empty string when absent.
|
||||
*/
|
||||
private configValue(key: string): string {
|
||||
const value = this.config.get<unknown>(key);
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<NetworkDdnsRecordInput>;
|
||||
|
||||
export type NetworkDdnsListQuery = {
|
||||
enabled?: boolean;
|
||||
name?: string;
|
||||
pageNo?: number;
|
||||
pageSize?: number;
|
||||
recordType?: NetworkDdnsRecordType;
|
||||
syncStatus?: NetworkDdnsSyncStatus;
|
||||
};
|
||||
|
||||
export type NetworkDdnsSourceOption = {
|
||||
currentAddress: null | string;
|
||||
disabledReasonCode: null | string;
|
||||
eligible: boolean;
|
||||
externalPort?: number;
|
||||
id: string;
|
||||
name: string;
|
||||
observedAt: null | KtDateTime;
|
||||
protocol?: PortForwardProtocol;
|
||||
sourceType: NetworkDdnsSourceType;
|
||||
validUntil: null | KtDateTime;
|
||||
};
|
||||
|
||||
export type NetworkDesiredMapping = {
|
||||
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.
|
||||
|
||||
@ -31,6 +31,11 @@ const REQUIRED_CONFIG_KEYS = [
|
||||
|
||||
const OPTIONAL_CONFIG_CHECKS: ReadonlyArray<string | readonly string[]> = [
|
||||
'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',
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
200
test/admin/network-management/network-ddns-sql.spec.ts
Normal file
200
test/admin/network-management/network-ddns-sql.spec.ts
Normal file
@ -0,0 +1,200 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { resolve } from 'path';
|
||||
|
||||
const REPO_ROOT = resolve(__dirname, '../../..');
|
||||
|
||||
/**
|
||||
* Reads one repository SQL artifact as normalized lowercase text.
|
||||
* @param relativePath - Path relative to the API repository root.
|
||||
* @returns SQL text with identifier quotes and repeated whitespace removed.
|
||||
*/
|
||||
function readNormalizedSql(relativePath: string): string {
|
||||
return readFileSync(resolve(REPO_ROOT, relativePath), 'utf8')
|
||||
.replace(/`/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts one CREATE TABLE body from normalized SQL.
|
||||
* @param sql - Normalized SQL text.
|
||||
* @param tableName - Table whose declaration is required.
|
||||
* @returns CREATE TABLE body used by focused schema assertions.
|
||||
*/
|
||||
function extractCreateTable(sql: string, tableName: string): string {
|
||||
const match = sql.match(
|
||||
new RegExp(
|
||||
`create table if not exists ${tableName} \\(([\\s\\S]*?)\\) engine=innodb`,
|
||||
),
|
||||
);
|
||||
|
||||
expect(match).not.toBeNull();
|
||||
return match?.[1] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts the complete DDNS table contract shared by incremental and bootstrap SQL.
|
||||
* @param sql - Normalized SQL containing the network DDNS table.
|
||||
* @returns Nothing; Jest assertions fail when one schema fragment drifts.
|
||||
*/
|
||||
function expectDdnsTableContract(sql: string): void {
|
||||
const table = extractCreateTable(sql, 'network_ddns_record');
|
||||
const requiredColumns = [
|
||||
'id bigint not null',
|
||||
'name varchar(100) not null',
|
||||
'remark text null',
|
||||
'record_type varchar(8) not null',
|
||||
'source_type varchar(32) not null',
|
||||
'port_forward_id bigint null',
|
||||
'domain varchar(253) not null',
|
||||
'sub_domain varchar(253) not null',
|
||||
'active_key varchar(300) null',
|
||||
'enabled tinyint(1) not null default 0',
|
||||
"sync_status varchar(32) not null default 'disabled'",
|
||||
'provider_record_id varchar(32) null',
|
||||
'source_address varchar(45) null',
|
||||
'applied_address varchar(45) null',
|
||||
'retry_count int unsigned not null default 0',
|
||||
'next_retry_at datetime(3) null',
|
||||
'last_attempt_at datetime(3) null',
|
||||
'last_synced_at datetime(3) null',
|
||||
'last_error_code varchar(64) null',
|
||||
'last_error_message varchar(512) null',
|
||||
'is_deleted tinyint(1) not null default 0',
|
||||
'create_time datetime(3) not null default current_timestamp(3)',
|
||||
'update_time datetime(3) not null default current_timestamp(3) on update current_timestamp(3)',
|
||||
];
|
||||
|
||||
for (const column of requiredColumns) {
|
||||
expect(table).toContain(column);
|
||||
}
|
||||
|
||||
expect(table).toContain('primary key');
|
||||
expect(table).toContain(
|
||||
'unique key uk_network_ddns_record_active_key (active_key)',
|
||||
);
|
||||
expect(table).toContain(
|
||||
'key idx_network_ddns_record_status (is_deleted, enabled, sync_status, next_retry_at)',
|
||||
);
|
||||
expect(table).toContain(
|
||||
'key idx_network_ddns_record_port_forward (port_forward_id)',
|
||||
);
|
||||
}
|
||||
|
||||
describe('Network DDNS SQL contract', () => {
|
||||
it('keeps the DDNS table aligned in incremental and refactor-v3 bootstrap schemas', () => {
|
||||
expectDdnsTableContract(
|
||||
readNormalizedSql('sql/network-management-init.sql'),
|
||||
);
|
||||
expectDdnsTableContract(
|
||||
readNormalizedSql('sql/refactor-v3/00-full-schema.sql'),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds idempotent Agent IPv6 columns and verifies the schema indexes', () => {
|
||||
const incremental = readNormalizedSql('sql/network-management-init.sql');
|
||||
const fullSchema = readNormalizedSql('sql/refactor-v3/00-full-schema.sql');
|
||||
const verify = readNormalizedSql('sql/refactor-v3/99-verify.sql');
|
||||
|
||||
for (const sql of [incremental, fullSchema]) {
|
||||
const agentState = extractCreateTable(sql, 'network_agent_state');
|
||||
expect(agentState).toContain('current_public_ipv6 varchar(45) null');
|
||||
expect(agentState).toContain('current_ipv6_observed_at datetime(3) null');
|
||||
}
|
||||
|
||||
expect(incremental).toContain(
|
||||
'alter table network_agent_state add column current_public_ipv6 varchar(45) null',
|
||||
);
|
||||
expect(incremental).toContain(
|
||||
'alter table network_agent_state add column current_ipv6_observed_at datetime(3) null',
|
||||
);
|
||||
expect(incremental).toContain(
|
||||
"table_name = 'network_agent_state' and column_name = 'current_public_ipv6'",
|
||||
);
|
||||
expect(incremental).toContain(
|
||||
"table_name = 'network_agent_state' and column_name = 'current_ipv6_observed_at'",
|
||||
);
|
||||
|
||||
expect(verify).toContain(
|
||||
"select 'network_ddns_record' as table_name, count(*) as row_count from network_ddns_record",
|
||||
);
|
||||
expect(verify).toContain(
|
||||
"column_name = 'current_public_ipv6' and column_type = 'varchar(45)'",
|
||||
);
|
||||
expect(verify).toContain(
|
||||
"column_name = 'current_ipv6_observed_at' and column_type = 'datetime(3)'",
|
||||
);
|
||||
expect(verify).toContain(
|
||||
"index_name = 'uk_network_ddns_record_active_key'",
|
||||
);
|
||||
expect(verify).toContain("index_name = 'idx_network_ddns_record_status'");
|
||||
expect(verify).toContain(
|
||||
"index_name = 'idx_network_ddns_record_port_forward'",
|
||||
);
|
||||
});
|
||||
|
||||
it('mirrors stable DDNS permissions and restricts grants to enabled super roles', () => {
|
||||
const menu = readNormalizedSql('sql/network-management-menu.sql');
|
||||
const seed = readNormalizedSql('sql/refactor-v3/01-seed-core.sql');
|
||||
const vben = readNormalizedSql('sql/vben-admin-init.sql');
|
||||
const incrementalNonSuperBlock = menu.match(
|
||||
/where role\.role_code <> 'super' and menu\.name in \(([\s\S]*?)\);/,
|
||||
)?.[1];
|
||||
const incrementalSuperBlock = menu.match(
|
||||
/join admin_menu menu on menu\.name in \(([\s\S]*?)\) where role\.role_code = 'super'/,
|
||||
)?.[1];
|
||||
const vbenNonSuperBlock = vben.match(
|
||||
/and name not in \(([\s\S]*?)\);/,
|
||||
)?.[1];
|
||||
const permissions = [
|
||||
{
|
||||
code: 'system:network:ddns:list',
|
||||
id: '2041700000000120222',
|
||||
name: 'systemnetworkddnslist',
|
||||
},
|
||||
{
|
||||
code: 'system:network:ddns:create',
|
||||
id: '2041700000000120223',
|
||||
name: 'systemnetworkddnscreate',
|
||||
},
|
||||
{
|
||||
code: 'system:network:ddns:update',
|
||||
id: '2041700000000120224',
|
||||
name: 'systemnetworkddnsupdate',
|
||||
},
|
||||
{
|
||||
code: 'system:network:ddns:delete',
|
||||
id: '2041700000000120225',
|
||||
name: 'systemnetworkddnsdelete',
|
||||
},
|
||||
{
|
||||
code: 'system:network:ddns:retry',
|
||||
id: '2041700000000120226',
|
||||
name: 'systemnetworkddnsretry',
|
||||
},
|
||||
];
|
||||
|
||||
for (const sql of [menu, seed, vben]) {
|
||||
for (const permission of permissions) {
|
||||
expect(sql).toContain(permission.id);
|
||||
expect(sql).toContain(permission.name);
|
||||
expect(sql).toContain(permission.code);
|
||||
}
|
||||
}
|
||||
|
||||
expect(menu).toContain("where role.role_code <> 'super'");
|
||||
expect(menu).toContain("where role.role_code = 'super'");
|
||||
expect(menu).toContain('and role.status = 1');
|
||||
expect(menu).toContain('and role.is_deleted = 0');
|
||||
expect(incrementalNonSuperBlock).toBeDefined();
|
||||
expect(incrementalSuperBlock).toBeDefined();
|
||||
expect(vbenNonSuperBlock).toBeDefined();
|
||||
|
||||
for (const permission of permissions) {
|
||||
expect(incrementalNonSuperBlock).toContain(permission.name);
|
||||
expect(incrementalSuperBlock).toContain(permission.name);
|
||||
expect(vbenNonSuperBlock).toContain(permission.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
604
test/admin/network-management/network-ddns.service.spec.ts
Normal file
604
test/admin/network-management/network-ddns.service.spec.ts
Normal file
@ -0,0 +1,604 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { Repository } from 'typeorm';
|
||||
import { KtDateTime } from '../../../src/common';
|
||||
import { NetworkAgentState } from '../../../src/modules/admin/platform-config/network-management/network-agent-state.entity';
|
||||
import { NetworkDdnsRecord } from '../../../src/modules/admin/platform-config/network-management/network-ddns.entity';
|
||||
import { NetworkDdnsService } from '../../../src/modules/admin/platform-config/network-management/network-ddns.service';
|
||||
import {
|
||||
NetworkDnsPodClient,
|
||||
NetworkDnsPodClientError,
|
||||
} from '../../../src/modules/admin/platform-config/network-management/network-dnspod.client';
|
||||
import type { NetworkManagementEventStreamService } from '../../../src/modules/admin/platform-config/network-management/network-management-event-stream.service';
|
||||
import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity';
|
||||
|
||||
type Harness = {
|
||||
client: jest.Mocked<Pick<NetworkDnsPodClient, 'getStatus' | 'reconcile'>>;
|
||||
mapping: NetworkPortForward;
|
||||
recordUpdate: jest.Mock;
|
||||
records: NetworkDdnsRecord[];
|
||||
service: NetworkDdnsService;
|
||||
state: NetworkAgentState;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clones one entity to model TypeORM snapshots instead of shared object identity.
|
||||
* @param record - In-memory database row.
|
||||
* @returns Detached entity snapshot.
|
||||
*/
|
||||
function cloneRecord(record: NetworkDdnsRecord): NetworkDdnsRecord {
|
||||
return Object.assign(new NetworkDdnsRecord(), record);
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches a repository update criterion, including TypeORM's IsNull operator.
|
||||
* @param actual - Stored entity field value.
|
||||
* @param expected - Plain value or FindOperator-like criterion.
|
||||
* @returns True when the in-memory row satisfies the criterion.
|
||||
*/
|
||||
function matchesUpdateCriterion(actual: unknown, expected: unknown): boolean {
|
||||
if (
|
||||
expected &&
|
||||
typeof expected === 'object' &&
|
||||
(expected as { _type?: string })._type === 'isNull'
|
||||
) {
|
||||
return actual === null || actual === undefined;
|
||||
}
|
||||
if (actual instanceof Date && expected instanceof Date) {
|
||||
return actual.getTime() === expected.getTime();
|
||||
}
|
||||
return actual === expected;
|
||||
}
|
||||
|
||||
/** Creates an in-memory repository boundary around the real DDNS service. */
|
||||
function createHarness(): Harness {
|
||||
const records: NetworkDdnsRecord[] = [];
|
||||
const mapping = Object.assign(new NetworkPortForward(), {
|
||||
currentObservedAt: new KtDateTime('2026-07-23T01:00:00.000Z'),
|
||||
currentPublicIpv4: '8.8.8.8',
|
||||
currentPublicPort: 45_678,
|
||||
currentValidUntil: new KtDateTime('2026-07-23T02:00:00.000Z'),
|
||||
desiredPresence: 'present',
|
||||
externalPort: 45_678,
|
||||
id: '100',
|
||||
internalPort: 45_678,
|
||||
isDeleted: false,
|
||||
keeperDesiredEnabled: true,
|
||||
name: 'Public UDP',
|
||||
protocol: 'udp',
|
||||
});
|
||||
const state = Object.assign(new NetworkAgentState(), {
|
||||
agentId: 'nas-main',
|
||||
currentIpv6ObservedAt: new KtDateTime('2026-07-23T01:00:00.000Z'),
|
||||
currentPublicIpv6: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
|
||||
lastHeartbeatAt: new KtDateTime('2026-07-23T01:00:00.000Z'),
|
||||
online: true,
|
||||
});
|
||||
const recordUpdate = jest.fn(
|
||||
async (
|
||||
criteria: Record<string, unknown>,
|
||||
patch: Partial<NetworkDdnsRecord>,
|
||||
) => {
|
||||
const index = records.findIndex((record) =>
|
||||
Object.entries(criteria).every(([key, expected]) =>
|
||||
matchesUpdateCriterion(
|
||||
(record as unknown as Record<string, unknown>)[key],
|
||||
expected,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (index < 0) {
|
||||
return { affected: 0, generatedMaps: [], raw: [] };
|
||||
}
|
||||
records[index] = Object.assign(
|
||||
new NetworkDdnsRecord(),
|
||||
records[index],
|
||||
patch,
|
||||
);
|
||||
return { affected: 1, generatedMaps: [], raw: [] };
|
||||
},
|
||||
);
|
||||
const recordRepository = {
|
||||
count: async () => records.filter((record) => !record.isDeleted).length,
|
||||
create: (input) =>
|
||||
Object.assign(
|
||||
new NetworkDdnsRecord(),
|
||||
{ id: String(200 + records.length) },
|
||||
input,
|
||||
),
|
||||
createQueryBuilder: () => createListBuilder(records),
|
||||
find: async ({ where } = {} as any) =>
|
||||
records
|
||||
.filter((record) =>
|
||||
Object.entries(where || {}).every(
|
||||
([key, value]) => record[key] === value,
|
||||
),
|
||||
)
|
||||
.map(cloneRecord),
|
||||
findOne: async ({ where }) => {
|
||||
const record = records.find((item) =>
|
||||
Object.entries(where).every(([key, value]) => item[key] === value),
|
||||
);
|
||||
return record ? cloneRecord(record) : null;
|
||||
},
|
||||
save: async (record) => {
|
||||
const now = new KtDateTime();
|
||||
record.createTime ||= now;
|
||||
record.updateTime = now;
|
||||
const index = records.findIndex((item) => item.id === record.id);
|
||||
if (index >= 0) records[index] = cloneRecord(record);
|
||||
else records.push(cloneRecord(record));
|
||||
return record;
|
||||
},
|
||||
update: recordUpdate,
|
||||
} as unknown as Repository<NetworkDdnsRecord>;
|
||||
const mappingRepository = {
|
||||
find: async () => [mapping],
|
||||
findOne: async ({ where }) =>
|
||||
where.id === mapping.id && !mapping.isDeleted ? mapping : null,
|
||||
} as unknown as Repository<NetworkPortForward>;
|
||||
const stateRepository = {
|
||||
findOne: async () => state,
|
||||
} as unknown as Repository<NetworkAgentState>;
|
||||
const config = {
|
||||
get: (key: string) => {
|
||||
const values = {
|
||||
NETWORK_AGENT_ID: 'nas-main',
|
||||
NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS: '60000',
|
||||
NETWORK_DDNS_RECONCILE_INTERVAL_MS: '60000',
|
||||
};
|
||||
return values[key];
|
||||
},
|
||||
} as ConfigService;
|
||||
const client = {
|
||||
getStatus: jest.fn(() => ({
|
||||
configured: true,
|
||||
enabled: true,
|
||||
provider: 'dnspod' as const,
|
||||
})),
|
||||
reconcile: jest.fn(),
|
||||
};
|
||||
const eventStream = {
|
||||
publishCommitted: jest.fn(),
|
||||
} as unknown as NetworkManagementEventStreamService;
|
||||
const service = new NetworkDdnsService(
|
||||
recordRepository,
|
||||
mappingRepository,
|
||||
stateRepository,
|
||||
config,
|
||||
client as unknown as NetworkDnsPodClient,
|
||||
eventStream,
|
||||
);
|
||||
return { client, mapping, recordUpdate, records, service, state };
|
||||
}
|
||||
|
||||
/** Creates the fluent list query subset used by the service. */
|
||||
function createListBuilder(records: NetworkDdnsRecord[]) {
|
||||
const builder = {
|
||||
andWhere: () => builder,
|
||||
getManyAndCount: async () => [
|
||||
records.filter((record) => !record.isDeleted),
|
||||
records.filter((record) => !record.isDeleted).length,
|
||||
],
|
||||
orderBy: () => builder,
|
||||
skip: () => builder,
|
||||
take: () => builder,
|
||||
where: () => builder,
|
||||
};
|
||||
return builder;
|
||||
}
|
||||
|
||||
/** Reads a Nest HTTP status from one rejected service operation. */
|
||||
function errorStatus(error: unknown): number {
|
||||
return error instanceof HttpException ? error.getStatus() : 0;
|
||||
}
|
||||
|
||||
describe('NetworkDdnsService', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-07-23T01:00:30.000Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns server-classified IPv4 and Agent IPv6 source options', async () => {
|
||||
await expect(
|
||||
createHarness().service.sourceOptions({ recordType: 'A' }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
currentAddress: '8.8.8.8',
|
||||
eligible: true,
|
||||
id: '100',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
}),
|
||||
]);
|
||||
await expect(
|
||||
createHarness().service.sourceOptions({ recordType: 'AAAA' }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
currentAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
|
||||
eligible: true,
|
||||
id: 'agent-ipv6',
|
||||
sourceType: 'agent_ipv6',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('never exposes a residual lease from an ineligible IPv4 source', async () => {
|
||||
const harness = createHarness();
|
||||
harness.mapping.keeperDesiredEnabled = false;
|
||||
|
||||
await expect(
|
||||
harness.service.sourceOptions({ recordType: 'A' }),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
currentAddress: null,
|
||||
disabledReasonCode: 'KEEPER_DISABLED',
|
||||
eligible: false,
|
||||
observedAt: null,
|
||||
validUntil: null,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes one disabled A binding without exposing provider identity input', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
await expect(
|
||||
harness.service.create({
|
||||
domain: ' KWITSUKASA.TOP. ',
|
||||
enabled: false,
|
||||
name: ' Pal A ',
|
||||
portForwardId: '100',
|
||||
recordType: 'A',
|
||||
remark: ' game ',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
subDomain: ' PAL ',
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
fqdn: 'pal.kwitsukasa.top',
|
||||
name: 'Pal A',
|
||||
recordType: 'A',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
syncStatus: 'disabled',
|
||||
});
|
||||
expect(harness.records[0].providerRecordId).toBeNull();
|
||||
expect(harness.client.reconcile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects record/source family mismatches before persistence', async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
await harness.service
|
||||
.create({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
name: 'bad',
|
||||
portForwardId: '100',
|
||||
recordType: 'AAAA',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
subDomain: 'nas6',
|
||||
})
|
||||
.catch((error) => expect(errorStatus(error)).toBe(400));
|
||||
|
||||
expect(harness.records).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reconciles A and AAAA addresses without ever using a port', async () => {
|
||||
const harness = createHarness();
|
||||
harness.client.reconcile
|
||||
.mockResolvedValueOnce({
|
||||
appliedAddress: '8.8.8.8',
|
||||
changed: true,
|
||||
providerRecordId: '300',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
appliedAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
|
||||
changed: false,
|
||||
providerRecordId: '301',
|
||||
});
|
||||
await harness.service.create({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
name: 'Pal A',
|
||||
portForwardId: '100',
|
||||
recordType: 'A',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
subDomain: 'pal',
|
||||
});
|
||||
await harness.service.create({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
name: 'NAS AAAA',
|
||||
recordType: 'AAAA',
|
||||
sourceType: 'agent_ipv6',
|
||||
subDomain: 'nas6',
|
||||
});
|
||||
harness.records[0].enabled = true;
|
||||
harness.records[0].syncStatus = 'pending';
|
||||
harness.records[1].enabled = true;
|
||||
harness.records[1].syncStatus = 'pending';
|
||||
|
||||
await harness.service.reconcileNow(undefined, true);
|
||||
|
||||
expect(harness.client.reconcile).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
domain: 'kwitsukasa.top',
|
||||
recordType: 'A',
|
||||
subDomain: 'pal',
|
||||
targetAddress: '8.8.8.8',
|
||||
}),
|
||||
);
|
||||
expect(harness.client.reconcile).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
recordType: 'AAAA',
|
||||
targetAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
|
||||
}),
|
||||
);
|
||||
expect(JSON.stringify(harness.client.reconcile.mock.calls)).not.toContain(
|
||||
'45678',
|
||||
);
|
||||
expect(harness.records.map((record) => record.syncStatus)).toEqual([
|
||||
'synced',
|
||||
'synced',
|
||||
]);
|
||||
});
|
||||
|
||||
it('waits without calling DNSPod when the IPv6 source is stale', async () => {
|
||||
const harness = createHarness();
|
||||
harness.state.currentIpv6ObservedAt = new KtDateTime(
|
||||
'2026-07-23T00:00:00.000Z',
|
||||
);
|
||||
await harness.service.create({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
name: 'NAS AAAA',
|
||||
recordType: 'AAAA',
|
||||
sourceType: 'agent_ipv6',
|
||||
subDomain: 'nas6',
|
||||
});
|
||||
harness.records[0].enabled = true;
|
||||
harness.records[0].syncStatus = 'pending';
|
||||
|
||||
await harness.service.reconcileNow('200', true);
|
||||
|
||||
expect(harness.records[0]).toMatchObject({
|
||||
appliedAddress: null,
|
||||
sourceAddress: null,
|
||||
syncStatus: 'waiting_source',
|
||||
});
|
||||
expect(harness.client.reconcile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('waits without calling the provider when an IPv4 source keeps an ineligible residual lease', async () => {
|
||||
const harness = createHarness();
|
||||
await harness.service.create({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
name: 'Pal A',
|
||||
portForwardId: '100',
|
||||
recordType: 'A',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
subDomain: 'pal',
|
||||
});
|
||||
harness.records[0].enabled = true;
|
||||
harness.records[0].syncStatus = 'pending';
|
||||
harness.mapping.keeperDesiredEnabled = false;
|
||||
|
||||
await harness.service.reconcileNow('200', true);
|
||||
|
||||
expect(harness.records[0]).toMatchObject({
|
||||
sourceAddress: null,
|
||||
syncStatus: 'waiting_source',
|
||||
});
|
||||
expect(harness.client.reconcile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues a fresh reconcile when the source changes during provider I/O', async () => {
|
||||
const harness = createHarness();
|
||||
let providerStarted!: () => void;
|
||||
let resolveFirst!: (result: {
|
||||
appliedAddress: string;
|
||||
changed: boolean;
|
||||
providerRecordId: string;
|
||||
}) => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
providerStarted = resolve;
|
||||
});
|
||||
const firstResult = new Promise<{
|
||||
appliedAddress: string;
|
||||
changed: boolean;
|
||||
providerRecordId: string;
|
||||
}>((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
harness.client.reconcile
|
||||
.mockImplementationOnce(() => {
|
||||
providerStarted();
|
||||
return firstResult;
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
appliedAddress: '9.9.9.9',
|
||||
changed: true,
|
||||
providerRecordId: '300',
|
||||
});
|
||||
await harness.service.create({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
name: 'Pal A',
|
||||
portForwardId: '100',
|
||||
recordType: 'A',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
subDomain: 'pal',
|
||||
});
|
||||
harness.records[0].enabled = true;
|
||||
harness.records[0].syncStatus = 'pending';
|
||||
|
||||
const firstReconcile = harness.service.reconcileNow('200', true);
|
||||
await started;
|
||||
harness.mapping.currentPublicIpv4 = '9.9.9.9';
|
||||
resolveFirst({
|
||||
appliedAddress: '8.8.8.8',
|
||||
changed: true,
|
||||
providerRecordId: '300',
|
||||
});
|
||||
await firstReconcile;
|
||||
await harness.service.reconcileNow('200');
|
||||
|
||||
expect(harness.client.reconcile).toHaveBeenCalledTimes(2);
|
||||
expect(harness.client.reconcile.mock.calls.map(([input]) => input)).toEqual(
|
||||
[
|
||||
expect.objectContaining({ targetAddress: '8.8.8.8' }),
|
||||
expect.objectContaining({ targetAddress: '9.9.9.9' }),
|
||||
],
|
||||
);
|
||||
expect(harness.records[0]).toMatchObject({
|
||||
appliedAddress: '9.9.9.9',
|
||||
sourceAddress: '9.9.9.9',
|
||||
syncStatus: 'synced',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a stale CAS write and reconciles only the newly committed DNS identity', async () => {
|
||||
const harness = createHarness();
|
||||
harness.client.reconcile.mockResolvedValue({
|
||||
appliedAddress: '8.8.8.8',
|
||||
changed: true,
|
||||
providerRecordId: '300',
|
||||
});
|
||||
await harness.service.create({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
name: 'Pal A',
|
||||
portForwardId: '100',
|
||||
recordType: 'A',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
subDomain: 'pal',
|
||||
});
|
||||
harness.records[0].enabled = true;
|
||||
harness.records[0].syncStatus = 'pending';
|
||||
harness.recordUpdate.mockImplementationOnce(async () => {
|
||||
harness.records[0].activeKey = 'a:pal2.kwitsukasa.top';
|
||||
harness.records[0].subDomain = 'pal2';
|
||||
harness.records[0].syncStatus = 'pending';
|
||||
harness.records[0].updateTime = new KtDateTime(
|
||||
harness.records[0].updateTime.getTime() + 10,
|
||||
);
|
||||
return { affected: 0, generatedMaps: [], raw: [] };
|
||||
});
|
||||
|
||||
await harness.service.reconcileNow('200', true);
|
||||
await harness.service.reconcileNow('200');
|
||||
|
||||
expect(harness.client.reconcile).toHaveBeenCalledTimes(1);
|
||||
expect(harness.client.reconcile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ subDomain: 'pal2' }),
|
||||
);
|
||||
expect(harness.records[0]).toMatchObject({
|
||||
appliedAddress: '8.8.8.8',
|
||||
subDomain: 'pal2',
|
||||
syncStatus: 'synced',
|
||||
});
|
||||
});
|
||||
|
||||
it('serializes a concurrent delete behind provider I/O without resurrecting the row', async () => {
|
||||
const harness = createHarness();
|
||||
let providerStarted!: () => void;
|
||||
let resolveProvider!: (result: {
|
||||
appliedAddress: string;
|
||||
changed: boolean;
|
||||
providerRecordId: string;
|
||||
}) => void;
|
||||
const started = new Promise<void>((resolve) => {
|
||||
providerStarted = resolve;
|
||||
});
|
||||
const providerResult = new Promise<{
|
||||
appliedAddress: string;
|
||||
changed: boolean;
|
||||
providerRecordId: string;
|
||||
}>((resolve) => {
|
||||
resolveProvider = resolve;
|
||||
});
|
||||
harness.client.reconcile.mockImplementation(() => {
|
||||
providerStarted();
|
||||
return providerResult;
|
||||
});
|
||||
await harness.service.create({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
name: 'Pal A',
|
||||
portForwardId: '100',
|
||||
recordType: 'A',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
subDomain: 'pal',
|
||||
});
|
||||
harness.records[0].enabled = true;
|
||||
harness.records[0].syncStatus = 'pending';
|
||||
|
||||
const reconcile = harness.service.reconcileNow('200', true);
|
||||
await started;
|
||||
let deleteSettled = false;
|
||||
const remove = harness.service.remove('200').then((result) => {
|
||||
deleteSettled = true;
|
||||
return result;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(deleteSettled).toBe(false);
|
||||
|
||||
resolveProvider({
|
||||
appliedAddress: '8.8.8.8',
|
||||
changed: true,
|
||||
providerRecordId: '300',
|
||||
});
|
||||
await reconcile;
|
||||
await remove;
|
||||
|
||||
expect(harness.records[0]).toMatchObject({
|
||||
activeKey: null,
|
||||
enabled: false,
|
||||
isDeleted: true,
|
||||
syncStatus: 'disabled',
|
||||
});
|
||||
expect(harness.client.reconcile).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('persists a safe bounded retry after a retryable provider failure', async () => {
|
||||
const harness = createHarness();
|
||||
harness.client.reconcile.mockRejectedValue(
|
||||
new NetworkDnsPodClientError(
|
||||
'DNSPOD_RATE_LIMITED',
|
||||
'DNSPod 请求受限,请稍后重试',
|
||||
true,
|
||||
),
|
||||
);
|
||||
await harness.service.create({
|
||||
domain: 'kwitsukasa.top',
|
||||
enabled: false,
|
||||
name: 'Pal A',
|
||||
portForwardId: '100',
|
||||
recordType: 'A',
|
||||
sourceType: 'port_forward_ipv4',
|
||||
subDomain: 'pal',
|
||||
});
|
||||
harness.records[0].enabled = true;
|
||||
harness.records[0].syncStatus = 'pending';
|
||||
|
||||
await harness.service.reconcileNow('200', true);
|
||||
|
||||
expect(harness.records[0]).toMatchObject({
|
||||
lastErrorCode: 'provider_rate_limited',
|
||||
retryCount: 1,
|
||||
sourceAddress: '8.8.8.8',
|
||||
syncStatus: 'failed',
|
||||
});
|
||||
expect(harness.records[0].nextRetryAt).toBeInstanceOf(Date);
|
||||
expect(JSON.stringify(harness.records[0])).not.toMatch(
|
||||
/secret|credential|raw provider/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
583
test/admin/network-management/network-dnspod.client.spec.ts
Normal file
583
test/admin/network-management/network-dnspod.client.spec.ts
Normal file
@ -0,0 +1,583 @@
|
||||
import type { ConfigService } from '@nestjs/config';
|
||||
import type { ClientConfig } from 'tencentcloud-sdk-nodejs/tencentcloud/common/interface';
|
||||
import {
|
||||
NetworkDnsPodClient,
|
||||
NetworkDnsPodClientError,
|
||||
type NetworkDnsPodReconcileInput,
|
||||
} from '../../../src/modules/admin/platform-config/network-management/network-dnspod.client';
|
||||
|
||||
type DescribeRecordFilterListRequest = {
|
||||
Domain: string;
|
||||
IsExactSubDomain: boolean;
|
||||
Limit: number;
|
||||
Offset: number;
|
||||
RecordLine: string[];
|
||||
RecordType: string[];
|
||||
SubDomain: string;
|
||||
};
|
||||
|
||||
type ModifyDynamicDNSRequest = {
|
||||
Domain: string;
|
||||
RecordId: number;
|
||||
RecordLine: string;
|
||||
RecordLineId: string;
|
||||
SubDomain: string;
|
||||
Ttl: number;
|
||||
Value: string;
|
||||
};
|
||||
|
||||
type MockRecord = {
|
||||
Line?: string;
|
||||
LineId?: string;
|
||||
Name?: string;
|
||||
RecordId?: number;
|
||||
Status?: string;
|
||||
TTL?: number;
|
||||
Type?: string;
|
||||
Value?: string;
|
||||
};
|
||||
|
||||
type MockDnsPodSdkClient = {
|
||||
DescribeRecordFilterList: jest.Mock<
|
||||
Promise<{ RecordList?: MockRecord[] }>,
|
||||
[DescribeRecordFilterListRequest]
|
||||
>;
|
||||
ModifyDynamicDNS: jest.Mock<
|
||||
Promise<Record<string, never>>,
|
||||
[ModifyDynamicDNSRequest]
|
||||
>;
|
||||
};
|
||||
|
||||
const enabledConfig = {
|
||||
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
|
||||
NETWORK_DDNS_DNSPOD_SECRET_ID: 'test-secret-id',
|
||||
NETWORK_DDNS_DNSPOD_SECRET_KEY: 'test-secret-key',
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a ConfigService-compatible readonly key/value reader.
|
||||
* @param values - Runtime values exposed to the client under test.
|
||||
* @returns Minimal ConfigService mock.
|
||||
*/
|
||||
function createConfig(
|
||||
values: Record<string, string | undefined>,
|
||||
): ConfigService {
|
||||
return {
|
||||
get: jest.fn((key: string) => values[key]),
|
||||
} as unknown as ConfigService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DNSPod SDK mock with successful defaults.
|
||||
* @returns Mocked DescribeRecordFilterList and ModifyDynamicDNS methods.
|
||||
*/
|
||||
function createSdkClient(): MockDnsPodSdkClient {
|
||||
return {
|
||||
DescribeRecordFilterList: jest.fn(),
|
||||
ModifyDynamicDNS: jest.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a valid enabled DNSPod record.
|
||||
* @param overrides - Record fields replaced for a focused test.
|
||||
* @returns Provider record suitable for DescribeRecordFilterList.
|
||||
*/
|
||||
function createRecord(overrides: MockRecord = {}): MockRecord {
|
||||
return {
|
||||
Line: '默认',
|
||||
LineId: '0',
|
||||
Name: 'nas',
|
||||
RecordId: 123,
|
||||
Status: 'ENABLE',
|
||||
TTL: 600,
|
||||
Type: 'A',
|
||||
Value: '198.51.100.10',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an initialized client and captures SDK factory calls.
|
||||
* @param sdkClient - SDK behavior used by the test.
|
||||
* @param values - Runtime configuration overrides.
|
||||
* @returns Client under test and its SDK factory mock.
|
||||
*/
|
||||
function createClient(
|
||||
sdkClient = createSdkClient(),
|
||||
values: Record<string, string | undefined> = enabledConfig,
|
||||
) {
|
||||
const factory = jest.fn<MockDnsPodSdkClient, [ClientConfig]>(() => sdkClient);
|
||||
return {
|
||||
client: new NetworkDnsPodClient(createConfig(values), factory),
|
||||
factory,
|
||||
sdkClient,
|
||||
};
|
||||
}
|
||||
|
||||
const ipv4Input: NetworkDnsPodReconcileInput = {
|
||||
domain: 'kwitsukasa.top',
|
||||
recordType: 'A',
|
||||
subDomain: 'nas',
|
||||
targetAddress: '198.51.100.10',
|
||||
};
|
||||
|
||||
describe('NetworkDnsPodClient', () => {
|
||||
it('reports disabled or missing credentials without creating an SDK client', async () => {
|
||||
const disabled = createClient(createSdkClient(), {
|
||||
...enabledConfig,
|
||||
NETWORK_DDNS_DNSPOD_ENABLED: 'false',
|
||||
});
|
||||
expect(disabled.client.getStatus()).toEqual({
|
||||
configured: true,
|
||||
enabled: false,
|
||||
provider: 'dnspod',
|
||||
});
|
||||
await expect(disabled.client.reconcile(ipv4Input)).rejects.toMatchObject({
|
||||
code: 'DNSPOD_DISABLED',
|
||||
retryable: false,
|
||||
});
|
||||
expect(disabled.factory).not.toHaveBeenCalled();
|
||||
|
||||
const missing = createClient(createSdkClient(), {
|
||||
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
|
||||
NETWORK_DDNS_DNSPOD_SECRET_ID: '',
|
||||
NETWORK_DDNS_DNSPOD_SECRET_KEY: undefined,
|
||||
});
|
||||
expect(missing.client.getStatus()).toEqual({
|
||||
configured: false,
|
||||
enabled: true,
|
||||
provider: 'dnspod',
|
||||
});
|
||||
await expect(missing.client.reconcile(ipv4Input)).rejects.toMatchObject({
|
||||
code: 'DNSPOD_NOT_CONFIGURED',
|
||||
retryable: false,
|
||||
});
|
||||
expect(missing.factory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the exact root-host IPv4 list request and a bounded SDK profile', async () => {
|
||||
const { client, factory, sdkClient } = createClient();
|
||||
sdkClient.DescribeRecordFilterList.mockResolvedValue({
|
||||
RecordList: [
|
||||
createRecord({
|
||||
Name: '@',
|
||||
Value: '198.51.100.10',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.reconcile({
|
||||
...ipv4Input,
|
||||
subDomain: '@',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
appliedAddress: '198.51.100.10',
|
||||
changed: false,
|
||||
providerRecordId: '123',
|
||||
});
|
||||
|
||||
expect(sdkClient.DescribeRecordFilterList).toHaveBeenCalledWith({
|
||||
Domain: 'kwitsukasa.top',
|
||||
IsExactSubDomain: true,
|
||||
Limit: 2,
|
||||
Offset: 0,
|
||||
RecordLine: ['0'],
|
||||
RecordType: ['A'],
|
||||
SubDomain: '@',
|
||||
});
|
||||
expect(sdkClient.ModifyDynamicDNS).not.toHaveBeenCalled();
|
||||
expect(factory).toHaveBeenCalledTimes(1);
|
||||
const config = factory.mock.calls[0][0];
|
||||
expect(config.region).toBeUndefined();
|
||||
expect(config.profile?.httpProfile).toEqual({
|
||||
endpoint: 'dnspod.tencentcloudapi.com',
|
||||
reqTimeout: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the exact normal-host IPv6 list request and canonicalizes equality', async () => {
|
||||
const { client, sdkClient } = createClient();
|
||||
sdkClient.DescribeRecordFilterList.mockResolvedValue({
|
||||
RecordList: [
|
||||
createRecord({
|
||||
Name: 'nas6',
|
||||
Type: 'AAAA',
|
||||
Value: '2409:8a31:05e1:6020:a5ea:838e:843f:be5e',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.reconcile({
|
||||
domain: 'kwitsukasa.top',
|
||||
recordType: 'AAAA',
|
||||
subDomain: 'nas6',
|
||||
targetAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
appliedAddress: '2409:8a31:5e1:6020:a5ea:838e:843f:be5e',
|
||||
changed: false,
|
||||
providerRecordId: '123',
|
||||
});
|
||||
|
||||
expect(sdkClient.DescribeRecordFilterList).toHaveBeenCalledWith({
|
||||
Domain: 'kwitsukasa.top',
|
||||
IsExactSubDomain: true,
|
||||
Limit: 2,
|
||||
Offset: 0,
|
||||
RecordLine: ['0'],
|
||||
RecordType: ['AAAA'],
|
||||
SubDomain: 'nas6',
|
||||
});
|
||||
expect(sdkClient.ModifyDynamicDNS).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates IPv4 without changing record metadata and verifies the read-back', async () => {
|
||||
const { client, sdkClient } = createClient();
|
||||
sdkClient.DescribeRecordFilterList.mockResolvedValueOnce({
|
||||
RecordList: [
|
||||
createRecord({
|
||||
LineId: '10=1',
|
||||
Value: '198.51.100.9',
|
||||
}),
|
||||
],
|
||||
}).mockResolvedValueOnce({
|
||||
RecordList: [
|
||||
createRecord({
|
||||
LineId: '10=1',
|
||||
Value: '198.51.100.10',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.reconcile({
|
||||
...ipv4Input,
|
||||
expectedRecordId: '123',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
appliedAddress: '198.51.100.10',
|
||||
changed: true,
|
||||
providerRecordId: '123',
|
||||
});
|
||||
|
||||
expect(sdkClient.ModifyDynamicDNS).toHaveBeenCalledTimes(1);
|
||||
expect(sdkClient.ModifyDynamicDNS).toHaveBeenCalledWith({
|
||||
Domain: 'kwitsukasa.top',
|
||||
RecordId: 123,
|
||||
RecordLine: '默认',
|
||||
RecordLineId: '10=1',
|
||||
SubDomain: 'nas',
|
||||
Ttl: 600,
|
||||
Value: '198.51.100.10',
|
||||
});
|
||||
expect(sdkClient.DescribeRecordFilterList).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('updates IPv6 without changing record metadata and verifies the read-back', async () => {
|
||||
const { client, sdkClient } = createClient();
|
||||
sdkClient.DescribeRecordFilterList.mockResolvedValueOnce({
|
||||
RecordList: [
|
||||
createRecord({
|
||||
Name: 'nas6',
|
||||
Type: 'AAAA',
|
||||
Value: '2409:8a31:5e1:6020::1',
|
||||
}),
|
||||
],
|
||||
}).mockResolvedValueOnce({
|
||||
RecordList: [
|
||||
createRecord({
|
||||
Name: 'nas6',
|
||||
Type: 'AAAA',
|
||||
Value: '2409:8a31:5e1:6020::2',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.reconcile({
|
||||
domain: 'kwitsukasa.top',
|
||||
expectedRecordId: '123',
|
||||
recordType: 'AAAA',
|
||||
subDomain: 'nas6',
|
||||
targetAddress: '2409:8a31:5e1:6020::2',
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
appliedAddress: '2409:8a31:5e1:6020::2',
|
||||
changed: true,
|
||||
providerRecordId: '123',
|
||||
});
|
||||
|
||||
expect(sdkClient.ModifyDynamicDNS).toHaveBeenCalledWith({
|
||||
Domain: 'kwitsukasa.top',
|
||||
RecordId: 123,
|
||||
RecordLine: '默认',
|
||||
RecordLineId: '0',
|
||||
SubDomain: 'nas6',
|
||||
Ttl: 600,
|
||||
Value: '2409:8a31:5e1:6020::2',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing record', [], 'DNSPOD_RECORD_NOT_FOUND'],
|
||||
[
|
||||
'ambiguous records',
|
||||
[createRecord(), createRecord({ RecordId: 124 })],
|
||||
'DNSPOD_RECORD_AMBIGUOUS',
|
||||
],
|
||||
[
|
||||
'disabled record',
|
||||
[createRecord({ Status: 'DISABLE' })],
|
||||
'DNSPOD_RECORD_DISABLED',
|
||||
],
|
||||
[
|
||||
'wrong record type',
|
||||
[createRecord({ Type: 'CNAME' })],
|
||||
'DNSPOD_RECORD_INVALID',
|
||||
],
|
||||
['wrong line', [createRecord({ Line: '境外' })], 'DNSPOD_RECORD_INVALID'],
|
||||
[
|
||||
'unsafe record ID',
|
||||
[createRecord({ RecordId: Number.MAX_SAFE_INTEGER + 1 })],
|
||||
'DNSPOD_RECORD_INVALID',
|
||||
],
|
||||
[
|
||||
'different expected record ID',
|
||||
[createRecord({ RecordId: 124 })],
|
||||
'DNSPOD_RECORD_MISMATCH',
|
||||
],
|
||||
[
|
||||
'wrong address family',
|
||||
[createRecord({ Value: '2409:8a31:5e1:6020::1' })],
|
||||
'DNSPOD_RECORD_INVALID',
|
||||
],
|
||||
])('fails closed for %s', async (_name, records, code) => {
|
||||
const { client, sdkClient } = createClient();
|
||||
sdkClient.DescribeRecordFilterList.mockResolvedValue({
|
||||
RecordList: records,
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.reconcile({
|
||||
...ipv4Input,
|
||||
expectedRecordId: code === 'DNSPOD_RECORD_MISMATCH' ? '123' : undefined,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code,
|
||||
retryable: false,
|
||||
});
|
||||
expect(sdkClient.ModifyDynamicDNS).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ ...ipv4Input, domain: 'https://kwitsukasa.top' }],
|
||||
[{ ...ipv4Input, domain: 'kwitsukasa.top:53' }],
|
||||
[{ ...ipv4Input, subDomain: '' }],
|
||||
[{ ...ipv4Input, subDomain: '*.nas' }],
|
||||
[{ ...ipv4Input, targetAddress: '198.51.100.10:8211' }],
|
||||
[
|
||||
{
|
||||
...ipv4Input,
|
||||
recordType: 'A' as const,
|
||||
targetAddress: '2409:8a31:5e1:6020::1',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...ipv4Input,
|
||||
recordType: 'AAAA' as const,
|
||||
targetAddress: '198.51.100.10',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...ipv4Input,
|
||||
recordType: 'AAAA' as const,
|
||||
targetAddress: '::',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...ipv4Input,
|
||||
recordType: 'AAAA' as const,
|
||||
targetAddress: '::1',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...ipv4Input,
|
||||
recordType: 'AAAA' as const,
|
||||
targetAddress: 'fe80::1',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...ipv4Input,
|
||||
recordType: 'AAAA' as const,
|
||||
targetAddress: 'fd00::1',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...ipv4Input,
|
||||
recordType: 'AAAA' as const,
|
||||
targetAddress: 'ff02::1',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
...ipv4Input,
|
||||
recordType: 'AAAA' as const,
|
||||
targetAddress: '::ffff:198.51.100.10',
|
||||
},
|
||||
],
|
||||
[{ ...ipv4Input, expectedRecordId: '9007199254740992' }],
|
||||
[
|
||||
{
|
||||
...ipv4Input,
|
||||
expectedRecordId: 123 as unknown as string,
|
||||
},
|
||||
],
|
||||
])(
|
||||
'rejects invalid input before creating the SDK client: %j',
|
||||
async (input) => {
|
||||
const { client, factory } = createClient();
|
||||
|
||||
await expect(client.reconcile(input)).rejects.toMatchObject({
|
||||
code: 'DNSPOD_INVALID_INPUT',
|
||||
retryable: false,
|
||||
});
|
||||
expect(factory).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
{ code: 'RequestLimitExceeded', message: 'raw' },
|
||||
'DNSPOD_RATE_LIMITED',
|
||||
'DNSPod provider request was rate limited',
|
||||
true,
|
||||
],
|
||||
[
|
||||
{ code: 'InternalError', message: 'raw' },
|
||||
'DNSPOD_PROVIDER_RETRYABLE',
|
||||
'DNSPod provider request failed temporarily',
|
||||
true,
|
||||
],
|
||||
[
|
||||
{ code: 'ETIMEDOUT', message: 'raw' },
|
||||
'DNSPOD_PROVIDER_RETRYABLE',
|
||||
'DNSPod provider request failed temporarily',
|
||||
true,
|
||||
],
|
||||
[
|
||||
{ message: 'raw', statusCode: 503 },
|
||||
'DNSPOD_PROVIDER_RETRYABLE',
|
||||
'DNSPod provider request failed temporarily',
|
||||
true,
|
||||
],
|
||||
[
|
||||
{ code: 'AuthFailure.SecretIdNotFound', message: 'raw' },
|
||||
'DNSPOD_AUTH_FAILED',
|
||||
'DNSPod provider authentication failed',
|
||||
false,
|
||||
],
|
||||
[
|
||||
{ code: 'UnauthorizedOperation', message: 'raw' },
|
||||
'DNSPOD_PERMISSION_DENIED',
|
||||
'DNSPod provider permission was denied',
|
||||
false,
|
||||
],
|
||||
[
|
||||
{ code: 'AuthFailure.UnauthorizedOperation', message: 'raw' },
|
||||
'DNSPOD_PERMISSION_DENIED',
|
||||
'DNSPod provider permission was denied',
|
||||
false,
|
||||
],
|
||||
])(
|
||||
'maps one SDK failure to redacted stable code %s',
|
||||
async (error, code, message, retryable) => {
|
||||
const { client, sdkClient } = createClient();
|
||||
sdkClient.DescribeRecordFilterList.mockRejectedValue(error);
|
||||
|
||||
await expect(client.reconcile(ipv4Input)).rejects.toMatchObject({
|
||||
code,
|
||||
message,
|
||||
retryable,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('maps uncategorized permanent SDK failures to one redacted stable error', async () => {
|
||||
const { client, sdkClient } = createClient();
|
||||
sdkClient.DescribeRecordFilterList.mockRejectedValue({
|
||||
code: 'InvalidParameter',
|
||||
message: 'the raw provider rejection must stay private',
|
||||
requestId: 'provider-request-id',
|
||||
});
|
||||
|
||||
await expect(client.reconcile(ipv4Input)).rejects.toMatchObject({
|
||||
code: 'DNSPOD_PROVIDER_REJECTED',
|
||||
message: 'DNSPod provider request was rejected',
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps serialized failures and results free of credentials, provider details, FQDN, and ports', async () => {
|
||||
const secretId = 'sensitive-secret-id';
|
||||
const secretKey = 'sensitive-secret-key';
|
||||
const domain = 'private.example.com';
|
||||
const subDomain = 'hidden';
|
||||
const rawMessage = `provider leaked ${secretId} ${secretKey} ${subDomain}.${domain} 198.51.100.10:8211`;
|
||||
const failing = createClient(createSdkClient(), {
|
||||
NETWORK_DDNS_DNSPOD_ENABLED: 'true',
|
||||
NETWORK_DDNS_DNSPOD_SECRET_ID: secretId,
|
||||
NETWORK_DDNS_DNSPOD_SECRET_KEY: secretKey,
|
||||
});
|
||||
failing.sdkClient.DescribeRecordFilterList.mockRejectedValue({
|
||||
code: 'AuthFailure',
|
||||
message: rawMessage,
|
||||
});
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await failing.client.reconcile({
|
||||
domain,
|
||||
recordType: 'A',
|
||||
subDomain,
|
||||
targetAddress: '198.51.100.10',
|
||||
});
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
expect(caught).toBeInstanceOf(NetworkDnsPodClientError);
|
||||
const serializedError = JSON.stringify({
|
||||
...(caught as NetworkDnsPodClientError),
|
||||
message: (caught as NetworkDnsPodClientError).message,
|
||||
});
|
||||
for (const forbidden of [
|
||||
secretId,
|
||||
secretKey,
|
||||
rawMessage,
|
||||
`${subDomain}.${domain}`,
|
||||
'198.51.100.10:8211',
|
||||
]) {
|
||||
expect(serializedError).not.toContain(forbidden);
|
||||
}
|
||||
|
||||
const successful = createClient();
|
||||
successful.sdkClient.DescribeRecordFilterList.mockResolvedValue({
|
||||
RecordList: [createRecord()],
|
||||
});
|
||||
const serializedResult = JSON.stringify(
|
||||
await successful.client.reconcile(ipv4Input),
|
||||
);
|
||||
expect(serializedResult).not.toContain('test-secret-id');
|
||||
expect(serializedResult).not.toContain('test-secret-key');
|
||||
expect(serializedResult).not.toContain('nas.kwitsukasa.top');
|
||||
expect(serializedResult).not.toContain('198.51.100.10:8211');
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 = '';
|
||||
|
||||
@ -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,
|
||||
]),
|
||||
);
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Reference in New Issue
Block a user