feat: 增加TCP NATMap DDNS与端点消息源

This commit is contained in:
sunlei 2026-07-27 14:39:05 +08:00
parent cd157e773f
commit 83b874322a
19 changed files with 1572 additions and 99 deletions

8
API.md
View File

@ -137,13 +137,13 @@ Wire contract 同时保留 schema-v1 兼容路径和严格 schema-v2。v2 外层
TCP current 必须同时满足 present、NATMap intent、synced、Router、DNAT、NATMap active、非空 generation、candidate/current/last-observed 同 tuple以及接收时仍新鲜且未倒退的 lease否则只保留候选/最近观测并撤下 current。candidate、current、last-observed、last-published、静态错误、Keeper 错误和 NATMap 错误分别持久化。事件按 `eventId` 幂等追加并记录 `udp_stun|tcp_natmap` mechanism。
v2 endpoint event 与 matching reported 是两条独立 MQTT 消息。event、report 和 ownership 切换统一按 Agent state、channel、history 顺序加锁。正常 event-first 路径先提交 history不提前创建会被 5 秒扫描器消费的 UDP Outboxhistory 耐久保存来源 revision 和由 mechanism、tuple、observed/validated/valid-until 原始值生成的完整 lease identitymatching reported 只有在 revision、组/协议/mechanism、tuple 和未被 JS `Date` 截断的 lease 身份完全相同时,才在 current 同一事务中以原 `eventId` stage 现有 STUN 事件,提交后 wake。report-first 使用同一精确关联门禁。TCP 在本阶段只写历史,不复用 UDP STUN 消息源。组删除只有所有通道分别确认 exact absent 后才完成先完成的通道单独软删除并清空活动键仍有通道的组继续保留。v1 单 UDP 组继续使用 helper/router/route/Keeper/current 的原有 exact-absence 门禁。
v2 endpoint event 与 matching reported 是两条独立 MQTT 消息。event、report 和 ownership 切换统一按 Agent state、channel、history 顺序加锁。正常 event-first 路径先提交 history不提前创建会被 5 秒扫描器消费的 Outboxhistory 耐久保存来源 revision 和由 mechanism、tuple、observed/validated/valid-until 原始值生成的完整 lease identitymatching reported 只有在 revision、组/协议/mechanism、tuple 和未被 JS `Date` 截断的 lease 身份完全相同时,才在 current 同一事务中以原 `eventId` stage 对应事件,提交后 wake。report-first 使用同一精确关联门禁。UDP 仅对有效端口变化使用既有 `network.stun.mapping-port-changed`TCP 对公网 IPv4 或端口任一变化使用独立 `network.tcp.natmap-endpoint-changed`;首次发布、同 tuple 续租、撤下和同 tuple 恢复不 stage TCP 事件。组删除只有所有通道分别确认 exact absent 后才完成先完成的通道单独软删除并清空活动键仍有通道的组继续保留。v1 单 UDP 组继续使用 helper/router/route/Keeper/current 的原有 exact-absence 门禁。
Admin 首次进入网络管理页通过 HTTP 读取快照,随后使用 `/system/network/events/stream` 接收 `network-state-changed`。API 只在 `reported`、`status`、`events` 对应事务提交且语义状态实际变化后发出事件MQTT QoS 1 重投、仅推进 `lastHeartbeatAt` 的状态心跳,以及相同 tuple 只推进 `validatedAt/validUntil` 的续租继续持久化,但不触发 history、SSE、DDNS 或消息 Outbox。公网 IP/端口、candidate/last-observed tuple、Keeper/NATMap/同步/错误/删除状态或 Agent 在线会话变化仍发布事件。一个入站消息不论改变多少通道都最多产生一次提交后 SSE。SSE 心跳只维持连接并复用最近一次真实状态事件 ID尚无状态事件时显式发送空 ID浏览器重连通过 `Last-Event-ID``lastEventId` 重放有限窗口,游标失效时收到一次 `snapshot-required` 并重新读取 HTTP 快照。前端不直接订阅 MQTT也不使用定时轮询。
TCP 通道使用独立 NATMap 开关UDP 通道使用独立 Keeper 开关TCP 不提供 STUNUDP 只有 `externalPort === internalPort` 时可启用 Keeper。`NETWORK_TCP_NATMAP_RELEASE_MODE=off|draining|canary|on` 控制 TCP 变更范围canary 端口来自 `NETWORK_TCP_NATMAP_CANARY_PORTS`。当前端点只有在 `currentValidUntil` 未过期时才返回为可用值;租约过期不会删除 candidate、last-observed、last-published 或 `network_endpoint_history`。API 不直接访问小米路由器、不修改 NAS 路由真实小米规则、FriendlyWrt DNAT/NATMap、raw UDP 与回程规则由固定 Agent/helper/Companion 处理。
自动 DDNS 支持 `A``AAAA`。A 记录绑定 `sourceType=port_forward_ipv4` 与一条合格的同源端口 UDP KeeperAAAA 记录固定使用 `sourceType=agent_ipv6`,不得携带 `portForwardId`。来源暂不可用时记录进入 `waiting_source`,不会向腾讯云写入空地址。协调器只修改腾讯云云解析 DNS 中已存在、已启用、默认线路且唯一的同类型记录,写入时保留 RecordId、线路和 TTL并回读相同 RecordId 验证结果DNS 值不包含端口。删除接口只删除本地自动更新绑定不删除云端记录。Provider 状态接口只返回开关、配置完整性和官方 provider 标识,不返回 SecretId、SecretKey 或 SDK 原始错误。
自动 DDNS 支持 `A``AAAA`。A 记录绑定 `sourceType=port_forward_ipv4` 与一条合格的 UDP Keeper 或 TCP NATMap 协议通道来源选项返回 `groupId/protocol/mechanism`,名称为 `组名 / UDP Keeper``组名 / TCP NATMap`AAAA 记录固定使用 `sourceType=agent_ipv6`,不得携带 `portForwardId`。来源暂不可用时记录进入 `waiting_source`,不会向腾讯云写入空地址。协调器只修改腾讯云云解析 DNS 中已存在、已启用、默认线路且唯一的同类型记录,写入时保留 RecordId、线路和 TTL并回读相同 RecordId 验证结果DNS 值不包含端口。仅端口变化不调用 provider`syncStatus=synced`、`appliedAddress` 等于当前通道 IPv4 且当前公网端口有效时DDNS 响应额外返回 `accessEndpoint=FQDN:port`。删除接口只删除本地自动更新绑定不删除云端记录。Provider 状态接口只返回开关、配置完整性和官方 provider 标识,不返回 SecretId、SecretKey 或 SDK 原始错误。
Agent 状态响应额外包含可选的 `currentPublicIpv6/currentIpv6ObservedAt`。只有在线且未超过 `NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` 的规范化全局 IPv6 才能作为 AAAA 来源;缺少 IPv6 不影响现有端口转发、UDP Keeper 或 Agent 在线状态。
@ -471,9 +471,11 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
响应仅返回管理契约字段source definition/field/variable 白名单;通用 `subscription-options` 按来源声明的集合返回统一 `value/label/disabled/disabledReasonCode/dependsOnValue` 候选项;旧 STUN options 入口在兼容期内继续返回旧版 Admin 所需的专用字段。subscription 的 `sourceConfig` 只回显当前来源公开声明且值为字符串的字段template、preview、binding/target 和 target option 仍使用各自视图白名单。不会返回 adapter、entity/repository、`activeKey`、digest、软删除字段、账号内部 ID、事件 payload/delivery/lease/retry 状态、凭据、access token、Provider/OneBot/MQTT 原始对象。系统事件只能通过 Nest 内部 Outbox stager 暂存,不存在 publish、event、delivery、fan-out、retry 或 worker HTTP 发布接口。
TCP NATMap 独立消息源为 `network.tcp.natmap-endpoint-changed`、版本 `1`,订阅配置精确为 `{ tcpChannelId, ddnsRecordId }`,两个字段都必须是字符串 Snowflake ID且 DDNS 必须是启用、未删除、绑定同一 TCP 通道的 A 记录。变量精确为 `endpoint`、`fqdn`、`publicIpv4`、`publicPort`、`previousPublicIpv4`、`previousPublicPort`、`portForwardName``endpoint` 使用事件端口冻结为 `FQDN:newPort`。DDNS 尚未同步到事件 IPv4 时为 `waiting_ddns`,当前 tuple 已变化或租约过期为 `superseded`,通道撤下、删除、停用 NATMap 或 DDNS 改绑为 `cancelled`。现有 STUN 来源定义与变量保持不变。
#### 内部事件与投递生命周期
- 只有一个既有有效端口直接变化为另一个有效端口的 `changed` 事件,才会在 endpoint-history 同一事务内以生产者 `eventId` 幂等写入 `qqbot_message_event` Outbox。首次 `published`、租约续期、仅 IPv4 变化、`withdrawn`、`restored`、重复事件和回滚事务都不产生消息事件;事务提交后才调用 `requestDrain()`
- UDP 只有一个既有有效端口直接变化为另一个有效端口的 `changed` 事件,才写既有 STUN OutboxTCP 则在既有有效 tuple 的公网 IPv4 或端口任一变化时写独立 NATMap endpoint Outbox。首次 `published`、同 tuple 续期、`withdrawn`、同 tuple `restored`、重复事件和回滚事务都不产生 TCP 消息事件。事件使用生产者 `eventId` 幂等matching report/event 的 history、current/baseline 与 Outbox 保持既有事务关联门禁,事务提交后才调用 `requestDrain()`
- Outbox 扇出状态为 `accepted`、`processing`、`retry`、`completed`、`failed`;投递状态为 `waiting_ddns`、`pending`、`processing`、`retry`、`success`、`failed`、`superseded`、`cancelled`。每个 runner 每次最多领取 50 行并设置 30 秒租约;启动后立即恢复且每 5 秒扫描。过期 `processing` 租约可由重启后的进程重新领取。
- DDNS 未就绪的投递进入 `waiting_ddns` 并每 60 秒复检。只有对应 A 记录的 optimistic `synced` / `appliedAddress` 更新成功持久化后,`notifyDdnsSynced()` 才提前推进相关任务;发送前仍重检当前 endpoint、DDNS、订阅、绑定、目标和账号。更新 endpoint 使旧未完成任务成为 `superseded`,配置停用或删除使其成为 `cancelled`
- 扇出和投递的临时错误从 10 秒开始指数退避,单次最长 15 分钟,并在事件发生 24 小时后截止。`sendStrictPlainText()` 只选择投递冻结的 `selfId`,把正文作为一个 OneBot `text` segment 发送,并在成功时关联 `qqbot_send_log`。数据库唯一键保证事件和事件-目标任务幂等OneBot 超时可重试,因此外部结果不明确时仍是可能重复的至少一次投递,而不是跨系统恰好一次。

View File

@ -84,11 +84,11 @@ System 网络管理以 MySQL 中的逻辑端口转发组和 TCP/UDP 协议通道
MQTT v2 分别保存 candidate、current、last-observed、last-published、通道 report 原始 RFC3339Nano 水位以及静态路径、Keeper 和 NATMap 错误。TCP current 只有在 desired present、NATMap intent、sync、Router、DNAT、NATMap 进程、generation、同 tuple 证据和新鲜 lease 全部通过时才发布;相同 tuple 续租只推进 validation/lease不新增历史、不触发 DDNS、Outbox 或 Admin 刷新。同 revision 的旧 report 不得覆盖较新的通道状态;合法外层 snapshot 完成协调尝试后推进 applied revision 与独立 applied schema 水位,单通道 conflict 只隔离该通道。Agent 接受 v2 后会耐久锁存唯一 owner在未来实现 Agent 可确认的降级握手前API 单边降级固定拒绝。
v2 endpoint event 通常先于 matching reported 到达,因此 event-first 只提交 historyUDP 端口变更必须等 matching current 在 reported 事务中提交后才写现有 STUN OutboxTCP 在独立消息源上线前只写 `tcp_natmap` history。event、report 与 ownership 切换统一按 Agent state、channel、history 顺序加锁history 耐久保存来源 revision 和完整 lease identity只有 revision、tuple 与未被时间精度截断的 lease 身份全部匹配才允许 stage避免迟到事件误投递。所有 SSE、DDNS reconcile、投递 wake 和 desired republish 都在数据库事务成功后最多触发一次;组只有所有协议通道都确认 exact absent 后才软删除。Agent 失联或 MQTT 暂不可用时合法请求仍保存为 pending恢复后按 revision 自动收敛;瞬时数据库错误或 SUBACK 失败依赖持久会话重投,非法负载确认后丢弃。当前公网端点受 `currentValidUntil` 约束,过期后列表隐藏 current但保留候选、最近观测、发布基线与历史。
v2 endpoint event 通常先于 matching reported 到达,因此 event-first 先提交 historyUDP 有效端口变化写入既有 STUN OutboxTCP 公网 IPv4 或端口任一变化写入独立的 `network.tcp.natmap-endpoint-changed` Outbox二者都必须等 matching current 通过后才能 stage。event、report 与 ownership 切换统一按 Agent state、channel、history 顺序加锁history 耐久保存来源 revision 和完整 lease identity只有 revision、tuple 与未被时间精度截断的 lease 身份全部匹配才允许 stage避免迟到事件误投递。首次发布、同 tuple 续租、撤下和同 tuple 恢复不会产生 TCP 消息事件。所有 SSE、DDNS reconcile、投递 wake 和 desired republish 都在数据库事务成功后最多触发一次;组只有所有协议通道都确认 exact absent 后才软删除。Agent 失联或 MQTT 暂不可用时合法请求仍保存为 pending恢复后按 revision 自动收敛;瞬时数据库错误或 SUBACK 失败依赖持久会话重投,非法负载确认后丢弃。当前公网端点受 `currentValidUntil` 约束,过期后列表隐藏 current但保留候选、最近观测、发布基线与历史。
同一模块提供腾讯云云解析 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 的技术服务名。
同一模块提供腾讯云云解析 DNS 的双栈自动 DDNS。A 记录可从合格 UDP Keeper 或 TCP NATMap 通道的有效公网 IPv4 取值AAAA 记录只从在线 Agent 最近上报的全局 IPv6 取值DNS 值始终不包含端口。A 来源以 `组名 / UDP Keeper``组名 / TCP NATMap` 展示;同步地址等于当前通道 IPv4 且当前公网端口有效时,管理响应额外返回 `accessEndpoint=FQDN:port`。仅端口变化只更新该派生端点,不调用腾讯云。协调器只修改已存在、已启用、默认线路且唯一的 A/AAAA 记录,保留 RecordId、线路和 TTL并在写入后回读确认。删除 Admin 绑定只停止本地自动更新,不删除云端 DNS 记录。凭据只从 API 私有运行环境的 `NETWORK_DDNS_DNSPOD_SECRET_ID/SECRET_KEY` 读取,不进入 Admin、数据库、MQTT、Agent、日志或 Git`DNSPOD` 是腾讯云官方 SDK 的技术服务名。
QQBot 系统消息推送由全局消息订阅和模板、账号范围发布绑定及耐久事件/投递共同管理;订阅表单字段、候选集合和资源匹配由所选系统消息源 adapter 声明,核心模块不写死 STUN 的 `portForwardId/ddnsRecordId`。账号接口严格使用路由 `selfId`,不会回退到其他机器人。群聊和私聊目标分别使用 `group` / `private`Snowflake、QQ 账号和目标 ID 在 HTTP 与数据库边界始终保持字符串。系统事件只能通过内部 Outbox stager 暂存,不提供 publish/event/worker HTTP 路由管理响应仅返回字段白名单不暴露账号凭据、Provider/OneBot/MQTT 运行对象、原始事件载荷或内部持久化键。
QQBot 系统消息推送由全局消息订阅和模板、账号范围发布绑定及耐久事件/投递共同管理;订阅表单字段、候选集合和资源匹配由所选系统消息源 adapter 声明,核心模块不写死 STUN 的 `portForwardId/ddnsRecordId` 或 TCP NATMap 的 `tcpChannelId/ddnsRecordId`。TCP 来源变量精确为 `endpoint/fqdn/publicIpv4/publicPort/previousPublicIpv4/previousPublicPort/portForwardName`,只有所选同通道 A 记录同步到事件 IPv4 后才能发送。账号接口严格使用路由 `selfId`,不会回退到其他机器人。群聊和私聊目标分别使用 `group` / `private`Snowflake、QQ 账号和目标 ID 在 HTTP 与数据库边界始终保持字符串。系统事件只能通过内部 Outbox stager 暂存,不提供 publish/event/worker HTTP 路由管理响应仅返回字段白名单不暴露账号凭据、Provider/OneBot/MQTT 运行对象、原始事件载荷或内部持久化键。
`qqbot_message_event` 同时承担事件 Outbox扇出状态为 `accepted`、`processing`、`retry`、`completed`、`failed`;按目标冻结的 `qqbot_message_delivery` 状态为 `waiting_ddns`、`pending`、`processing`、`retry`、`success`、`failed`、`superseded`、`cancelled`。扇出和投递每次各领取最多 50 行,处理租约为 30 秒;进程启动后立即恢复,并每 5 秒扫描一次。`waiting_ddns` 每 60 秒持久化复检;临时错误从 10 秒开始指数退避,单次最长 15 分钟,并以事件发生后 24 小时为截止时间。

View File

@ -22,6 +22,7 @@ import { NetworkPortForwardGroupController } from '@/modules/admin/platform-conf
import { NetworkPortForwardGroupService } from '@/modules/admin/platform-config/network-management/network-port-forward-group.service';
import { NetworkManagementService } from '@/modules/admin/platform-config/network-management/network-management.service';
import { NetworkStunMessageSourceAdapter } from '@/modules/admin/platform-config/network-management/network-stun-message-source.adapter';
import { NetworkTcpNatmapMessageSourceAdapter } from '@/modules/admin/platform-config/network-management/network-tcp-natmap-message-source.adapter';
import { NetworkTcpReleasePolicyService } from '@/modules/admin/platform-config/network-management/network-tcp-release-policy.service';
import { SystemLogController } from '@/modules/admin/platform-config/system-log/system-log.controller';
import { SystemLogService } from '@/modules/admin/platform-config/system-log/system-log.service';
@ -96,6 +97,7 @@ export const ADMIN_PLATFORM_CONFIG_PROVIDERS = [
NetworkDnsPodClient,
NetworkDdnsService,
NetworkStunMessageSourceAdapter,
NetworkTcpNatmapMessageSourceAdapter,
NetworkTcpReleasePolicyService,
NetworkAgentMqttService,
];

View File

@ -1,3 +1,4 @@
import { isIP } from 'node:net';
import {
Inject,
Injectable,
@ -788,14 +789,12 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
});
}
if (
item.protocol === 'udp' &&
currentTupleBefore !== currentTupleAfter &&
currentTupleAfter
) {
deliveryAccepted =
(await this.stageMatchingV2UdpHistory(manager, mapping)) ||
deliveryAccepted;
if (currentTupleBefore !== currentTupleAfter && currentTupleAfter) {
const accepted =
item.protocol === 'tcp'
? await this.stageMatchingV2TcpHistory(manager, mapping)
: await this.stageMatchingV2UdpHistory(manager, mapping);
deliveryAccepted = accepted || deliveryAccepted;
}
}
}
@ -1190,6 +1189,23 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
);
}
private async stageMatchingV2TcpHistory(
manager: EntityManager,
mapping: NetworkPortForward,
): Promise<boolean> {
const histories = await manager.getRepository(NetworkEndpointHistory).find({
order: { occurredAt: 'DESC', id: 'DESC' },
take: 2,
where: { mappingId: mapping.id, mechanism: 'tcp_natmap' },
});
return await this.stageV2TcpEndpointChange(
manager,
mapping,
histories[0],
histories[1],
);
}
private async stageV2UdpPortChange(
manager: EntityManager,
mapping: NetworkPortForward,
@ -1224,6 +1240,44 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
);
}
private async stageV2TcpEndpointChange(
manager: EntityManager,
mapping: NetworkPortForward,
currentHistory: NetworkEndpointHistory | undefined,
previousHistory: NetworkEndpointHistory | undefined,
): Promise<boolean> {
if (
!currentHistory ||
currentHistory.eventType !== 'changed' ||
!previousHistory ||
previousHistory.eventType === 'withdrawn' ||
!this.isV2HistoryMatchingCurrent(mapping, currentHistory) ||
isIP(previousHistory.publicIpv4 || '') !== 4 ||
isIP(currentHistory.publicIpv4 || '') !== 4 ||
!this.isValidPort(previousHistory.publicPort) ||
!this.isValidPort(currentHistory.publicPort) ||
(previousHistory.publicIpv4 === currentHistory.publicIpv4 &&
previousHistory.publicPort === currentHistory.publicPort)
) {
return false;
}
return (
(await this.eventStager.stage(manager, {
eventId: currentHistory.eventId,
occurredAt: new Date(currentHistory.occurredAt).toISOString(),
payload: {
previousPublicIpv4: previousHistory.publicIpv4 as string,
previousPublicPort: previousHistory.publicPort,
publicIpv4: currentHistory.publicIpv4 as string,
publicPort: currentHistory.publicPort,
tcpChannelId: mapping.id,
},
resourceKey: mapping.id,
sourceKey: 'network.tcp.natmap-endpoint-changed',
})) === 'accepted'
);
}
private isV2HistoryMatchingCurrent(
mapping: NetworkPortForward,
history: NetworkEndpointHistory,
@ -1545,16 +1599,23 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
});
try {
await repository.save(history);
const deliveryAccepted = this.canStageV2UdpEventAgainstCurrent(
const deliveryAccepted = this.canStageV2EventAgainstCurrent(
mapping,
event,
)
? await this.stageV2UdpPortChange(
manager,
mapping,
history,
previousHistory || undefined,
)
? event.protocol === 'tcp'
? await this.stageV2TcpEndpointChange(
manager,
mapping,
history,
previousHistory || undefined,
)
: await this.stageV2UdpPortChange(
manager,
mapping,
history,
previousHistory || undefined,
)
: false;
return { changed: true, deliveryAccepted };
} catch (error) {
@ -1564,13 +1625,14 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy {
});
}
private canStageV2UdpEventAgainstCurrent(
private canStageV2EventAgainstCurrent(
mapping: NetworkPortForward,
event: NetworkEndpointEventV2,
): boolean {
const expectedMechanism =
event.protocol === 'tcp' ? 'tcp_natmap' : 'udp_stun';
return (
event.protocol === 'udp' &&
event.mechanism === 'udp_stun' &&
event.mechanism === expectedMechanism &&
event.type === 'changed' &&
!!event.endpoint &&
mapping.reportedRevision === String(event.revision) &&

View File

@ -23,7 +23,9 @@ import {
} from './network-dnspod.client';
import { NetworkManagementEventStreamService } from './network-management-event-stream.service';
import { NetworkPortForward } from './network-management.entity';
import { NetworkPortForwardGroup } from './network-port-forward-group.entity';
import { classifyStunEndpointSource } from './network-source-eligibility';
import { classifyTcpNatmapEndpointSource } from './network-tcp-natmap-source-eligibility';
import type {
NetworkDdnsListQuery,
NetworkDdnsRecordInput,
@ -85,6 +87,8 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
private readonly recordRepository: Repository<NetworkDdnsRecord>,
@InjectRepository(NetworkPortForward)
private readonly mappingRepository: Repository<NetworkPortForward>,
@InjectRepository(NetworkPortForwardGroup)
private readonly groupRepository: Repository<NetworkPortForwardGroup>,
@InjectRepository(NetworkAgentState)
private readonly stateRepository: Repository<NetworkAgentState>,
private readonly configService: ConfigService,
@ -166,11 +170,25 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
if (query.recordType !== 'A') {
throwVbenError('DDNS 记录类型无效', HttpStatus.BAD_REQUEST);
}
const mappings = await this.mappingRepository.find({
order: { id: 'ASC', name: 'ASC' },
where: { isDeleted: false },
});
return mappings.map((mapping) => this.portForwardSourceOption(mapping));
const [mappings, groups] = await Promise.all([
this.mappingRepository.find({
order: { id: 'ASC', name: 'ASC' },
where: { isDeleted: false },
}),
this.groupRepository.find({
order: { id: 'ASC', name: 'ASC' },
where: { isDeleted: false },
}),
]);
const groupsById = new Map(
groups.map((group) => [String(group.id), group]),
);
return mappings.map((mapping) =>
this.portForwardSourceOption(
mapping,
groupsById.get(String(mapping.groupId)),
),
);
}
getProviderStatus() {
@ -638,7 +656,12 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
const mapping = await this.mappingRepository.findOne({
where: { id: record.portForwardId, isDeleted: false },
});
if (mapping) return this.portForwardSourceOption(mapping);
if (mapping) {
const group = await this.groupRepository.findOne({
where: { id: mapping.groupId, isDeleted: false },
});
return this.portForwardSourceOption(mapping, group || undefined);
}
return this.missingPortForwardSourceOption(record.portForwardId);
}
return this.missingPortForwardSourceOption(
@ -648,21 +671,37 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
private portForwardSourceOption(
mapping: NetworkPortForward,
group?: NetworkPortForwardGroup,
): NetworkDdnsSourceOption {
const { disabledReasonCode, eligible } =
classifyStunEndpointSource(mapping);
const mechanism = mapping.protocol === 'tcp' ? 'tcp_natmap' : 'udp_stun';
const sourceEligibility =
mapping.protocol === 'tcp'
? classifyTcpNatmapEndpointSource(mapping)
: classifyStunEndpointSource(mapping);
const disabledReasonCode = group
? sourceEligibility.disabledReasonCode
: 'SOURCE_NOT_FOUND';
const eligible = disabledReasonCode === null;
const leaseValid =
isIP(mapping.currentPublicIpv4 || '') === 4 &&
isValidPort(mapping.currentPublicPort) &&
!!mapping.currentValidUntil &&
new Date(mapping.currentValidUntil).getTime() > Date.now();
const sourceUsable = eligible && leaseValid;
return {
currentAddress: sourceUsable ? mapping.currentPublicIpv4 || null : null,
...(sourceUsable
? { currentPort: mapping.currentPublicPort as number }
: {}),
disabledReasonCode,
eligible: disabledReasonCode === null,
eligible,
externalPort: mapping.externalPort,
groupId: String(mapping.groupId),
id: String(mapping.id),
name: mapping.name,
mechanism,
name: `${group?.name || mapping.name} / ${
mechanism === 'tcp_natmap' ? 'TCP NATMap' : 'UDP Keeper'
}`,
observedAt: sourceUsable ? mapping.currentObservedAt || null : null,
protocol: mapping.protocol,
sourceType: 'port_forward_ipv4',
@ -844,9 +883,20 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
const mapping = await this.mappingRepository.findOne({
where: { id: portForwardId as string, isDeleted: false },
});
if (!mapping || !classifyStunEndpointSource(mapping).eligible) {
const group = mapping
? await this.groupRepository.findOne({
where: { id: mapping.groupId, isDeleted: false },
})
: null;
const sourceEligible =
mapping?.protocol === 'tcp'
? classifyTcpNatmapEndpointSource(mapping).eligible
: mapping
? classifyStunEndpointSource(mapping).eligible
: false;
if (!mapping || !group || !sourceEligible) {
throwVbenError(
'A 记录来源必须是已启用 Keeper 的同端口 UDP 转发',
'A 记录来源必须是已启用的 UDP Keeper 或 TCP NATMap 通道',
HttpStatus.BAD_REQUEST,
);
}
@ -890,14 +940,24 @@ export class NetworkDdnsService implements OnModuleInit, OnModuleDestroy {
private async serializeRecord(record: NetworkDdnsRecord) {
const source = await this.resolveRecordSource(record);
const fqdn =
record.subDomain === '@'
? record.domain
: `${record.subDomain}.${record.domain}`;
const accessEndpoint =
record.recordType === 'A' &&
record.syncStatus === 'synced' &&
!!record.appliedAddress &&
record.appliedAddress === source.currentAddress &&
isValidPort(source.currentPort)
? `${fqdn}:${source.currentPort}`
: null;
return {
...(accessEndpoint ? { accessEndpoint } : {}),
appliedAddress: record.appliedAddress || null,
domain: record.domain,
enabled: record.enabled,
fqdn:
record.subDomain === '@'
? record.domain
: `${record.subDomain}.${record.domain}`,
fqdn,
id: String(record.id),
lastErrorCode: record.lastErrorCode || null,
lastErrorMessage: record.lastErrorMessage || null,
@ -1123,6 +1183,15 @@ function isValidDnsName(
);
}
function isValidPort(value?: null | number): value is number {
return (
typeof value === 'number' &&
Number.isInteger(value) &&
value >= 1 &&
value <= 65_535
);
}
function normalizeGlobalIpv6(value?: null | string): null | string {
if (!value || isIP(value) !== 6) return null;
let normalized: string;

View File

@ -77,10 +77,13 @@ export type NetworkDdnsListQuery = {
export type NetworkDdnsSourceOption = {
currentAddress: null | string;
currentPort?: number;
disabledReasonCode: null | string;
eligible: boolean;
externalPort?: number;
groupId?: string;
id: string;
mechanism?: EndpointMechanism;
name: string;
observedAt: null | KtDateTime;
protocol?: PortForwardProtocol;

View File

@ -0,0 +1,511 @@
import { isIP } from 'node:net';
import {
Injectable,
type OnModuleDestroy,
type OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { SystemMessageSourceRegistry } from '@/modules/qqbot/core/application/message-push/system-message-source.registry';
import {
SystemMessageContractError,
type SystemMessageDeliveryReadiness,
type SystemMessageScalar,
type SystemMessageSourceAdapter,
type SystemMessageSourceDefinition,
type SystemMessageSourceOptionsResponse,
} from '@/modules/qqbot/core/contract/message-push/qqbot-message-push.types';
import { NetworkDdnsRecord } from './network-ddns.entity';
import { NetworkPortForward } from './network-management.entity';
import { NetworkPortForwardGroup } from './network-port-forward-group.entity';
import {
classifyTcpNatmapEndpointSource,
type TcpNatmapEndpointSourceDisabledReason,
} from './network-tcp-natmap-source-eligibility';
const SOURCE_KEY = 'network.tcp.natmap-endpoint-changed';
const SNOWFLAKE_ID_PATTERN = /^[1-9]\d{0,23}$/;
const SUBSCRIPTION_KEYS = ['ddnsRecordId', 'tcpChannelId'];
const EVENT_KEYS = [
'previousPublicIpv4',
'previousPublicPort',
'publicIpv4',
'publicPort',
'tcpChannelId',
];
type TcpNatmapSubscriptionConfig = {
ddnsRecordId: string;
tcpChannelId: string;
};
type TcpNatmapEventPayload = {
previousPublicIpv4: string;
previousPublicPort: number;
publicIpv4: string;
publicPort: number;
tcpChannelId: string;
};
type ResolvedSubscription = {
config: TcpNatmapSubscriptionConfig;
ddnsRecord: NetworkDdnsRecord;
group: NetworkPortForwardGroup;
mapping: NetworkPortForward;
sourceSummary: string;
};
@Injectable()
export class NetworkTcpNatmapMessageSourceAdapter
implements OnModuleDestroy, OnModuleInit, SystemMessageSourceAdapter
{
private registered = false;
readonly definition: SystemMessageSourceDefinition = {
description: '当 TCP NATMap 公网端点变更且 IPv4 DDNS 已同步时发送消息。',
displayName: 'TCP NATMap 端点变更',
sourceKey: SOURCE_KEY,
subscriptionFields: [
{
key: 'tcpChannelId',
label: 'TCP NATMap 通道',
optionCollection: 'tcpChannels',
required: true,
type: 'select',
},
{
dependsOn: 'tcpChannelId',
key: 'ddnsRecordId',
label: 'IPv4 DDNS 记录',
optionCollection: 'ddnsRecords',
required: true,
type: 'select',
},
],
variables: [
{
description: '服务端组合的域名与公网端口',
example: 'pal.kwitsukasa.top:38213',
key: 'endpoint',
label: '访问端点',
type: 'string',
},
{
description: '所选 DDNS 完整域名',
example: 'pal.kwitsukasa.top',
key: 'fqdn',
label: '域名',
type: 'string',
},
{
description: '新的公网 IPv4',
example: '203.0.113.10',
key: 'publicIpv4',
label: '公网 IPv4',
type: 'string',
},
{
description: '新的公网端口',
example: '38213',
key: 'publicPort',
label: '公网端口',
type: 'number',
},
{
description: '变化前的公网 IPv4',
example: '198.51.100.9',
key: 'previousPublicIpv4',
label: '原公网 IPv4',
type: 'string',
},
{
description: '变化前的公网端口',
example: '38111',
key: 'previousPublicPort',
label: '原公网端口',
type: 'number',
},
{
description: '逻辑端口转发名称',
example: '帕鲁新世界',
key: 'portForwardName',
label: '端口转发名称',
type: 'string',
},
],
version: 1,
};
constructor(
@InjectRepository(NetworkPortForward)
private readonly mappingRepository: Repository<NetworkPortForward>,
@InjectRepository(NetworkPortForwardGroup)
private readonly groupRepository: Repository<NetworkPortForwardGroup>,
@InjectRepository(NetworkDdnsRecord)
private readonly ddnsRepository: Repository<NetworkDdnsRecord>,
private readonly sourceRegistry: SystemMessageSourceRegistry,
) {}
onModuleInit(): void {
if (this.registered) return;
this.sourceRegistry.register(this);
this.registered = true;
}
onModuleDestroy(): void {
if (!this.registered) return;
this.sourceRegistry.unregister(this.definition.sourceKey, this);
this.registered = false;
}
eventResourceKey(payload: Record<string, SystemMessageScalar>): string {
if (typeof payload.tcpChannelId !== 'string') {
throw new SystemMessageContractError('invalid_source_config');
}
return payload.tcpChannelId;
}
async normalizeSubscriptionConfig(input: unknown): Promise<{
canonicalConfig: Record<string, string>;
resourceKey: string;
sourceSummary: string;
}> {
const resolved = await this.resolveSubscription(input);
return {
canonicalConfig: resolved.config,
resourceKey: resolved.config.tcpChannelId,
sourceSummary: resolved.sourceSummary,
};
}
async inspectSubscription(config: Record<string, unknown>): Promise<{
invalidReasonCode: null | string;
sourceSummary: string;
valid: boolean;
}> {
try {
const resolved = await this.resolveSubscription(config);
return {
invalidReasonCode: null,
sourceSummary: resolved.sourceSummary,
valid: true,
};
} catch (error) {
if (!(error instanceof SystemMessageContractError)) throw error;
return {
invalidReasonCode: error.code,
sourceSummary: '未选择有效的 TCP NATMap 通道与 DDNS',
valid: false,
};
}
}
async listSubscriptionOptions(): Promise<SystemMessageSourceOptionsResponse> {
const [mappings, groups, records] = await Promise.all([
this.mappingRepository.find({ order: { id: 'ASC', name: 'ASC' } }),
this.groupRepository.find({ order: { id: 'ASC', name: 'ASC' } }),
this.ddnsRepository.find({ order: { id: 'ASC', name: 'ASC' } }),
]);
const mappingsById = new Map(
mappings.map((mapping) => [String(mapping.id), mapping]),
);
const groupsById = new Map(
groups.map((group) => [String(group.id), group]),
);
return {
ddnsRecords: records.map((record) => {
const mapping = record.portForwardId
? mappingsById.get(String(record.portForwardId))
: undefined;
const group = mapping
? groupsById.get(String(mapping.groupId))
: undefined;
const disabledReasonCode = ddnsOptionReason(record, mapping, group);
return {
...(record.portForwardId
? { dependsOnValue: String(record.portForwardId) }
: {}),
disabled: disabledReasonCode !== null,
disabledReasonCode,
label: [record.name, ddnsFqdn(record), disabledReasonCode]
.filter((value) => value !== null)
.join(' · '),
value: String(record.id),
};
}),
tcpChannels: mappings.map((mapping) => {
const group = groupsById.get(String(mapping.groupId));
const source = classifyTcpNatmapEndpointSource(mapping);
const groupMissing = !group || group.isDeleted;
const disabledReasonCode = groupMissing
? 'SOURCE_DELETING'
: source.disabledReasonCode;
return {
disabled: disabledReasonCode !== null,
disabledReasonCode,
label: `${group?.name || mapping.name} / ${mapping.protocol === 'tcp' ? 'TCP NATMap' : 'UDP Keeper'}`,
value: String(mapping.id),
};
}),
};
}
subscriptionResourceKey(config: Record<string, unknown>): null | string {
if (!isPlainRecord(config) || typeof config.tcpChannelId !== 'string') {
return null;
}
return config.tcpChannelId;
}
validateEventPayload(
payload: Record<string, unknown>,
): Record<string, SystemMessageScalar> {
assertExactKeys(payload, EVENT_KEYS);
const tcpChannelId = normalizeSnowflakeId(payload.tcpChannelId);
const publicIpv4 = normalizeIpv4(payload.publicIpv4);
const publicPort = normalizePort(payload.publicPort);
const previousPublicIpv4 = normalizeIpv4(payload.previousPublicIpv4);
const previousPublicPort = normalizePort(payload.previousPublicPort);
if (
publicIpv4 === previousPublicIpv4 &&
publicPort === previousPublicPort
) {
throw new SystemMessageContractError('invalid_source_config');
}
return {
previousPublicIpv4,
previousPublicPort,
publicIpv4,
publicPort,
tcpChannelId,
};
}
async resolveDelivery(input: {
eventPayload: Record<string, SystemMessageScalar>;
subscriptionConfig: Record<string, unknown>;
}): Promise<SystemMessageDeliveryReadiness> {
let resolved: ResolvedSubscription;
try {
resolved = await this.resolveSubscription(input.subscriptionConfig);
} catch (error) {
if (!(error instanceof SystemMessageContractError)) throw error;
return { reasonCode: error.code, status: 'cancelled' };
}
let event: TcpNatmapEventPayload;
try {
event = this.validateEventPayload(
input.eventPayload,
) as TcpNatmapEventPayload;
} catch (error) {
if (!(error instanceof SystemMessageContractError)) throw error;
return { reasonCode: error.code, status: 'cancelled' };
}
if (event.tcpChannelId !== resolved.config.tcpChannelId) {
return { reasonCode: 'invalid_source_config', status: 'cancelled' };
}
if (!hasCurrentEndpointTuple(resolved.mapping)) {
return { reasonCode: 'endpoint_withdrawn', status: 'cancelled' };
}
if (
!hasFreshCurrentEndpoint(resolved.mapping) ||
resolved.mapping.currentPublicIpv4 !== event.publicIpv4 ||
resolved.mapping.currentPublicPort !== event.publicPort
) {
return { reasonCode: 'endpoint_superseded', status: 'superseded' };
}
const variables = deliveryVariables(resolved, event);
if (
resolved.ddnsRecord.syncStatus !== 'synced' ||
resolved.ddnsRecord.appliedAddress !== event.publicIpv4
) {
return {
reasonCode: 'ddns_not_synced',
status: 'waiting_ddns',
variables,
};
}
return { reasonCode: null, status: 'ready', variables };
}
private async resolveSubscription(
input: unknown,
): Promise<ResolvedSubscription> {
assertExactKeys(input, SUBSCRIPTION_KEYS);
const config = {
ddnsRecordId: normalizeSnowflakeId(input.ddnsRecordId),
tcpChannelId: normalizeSnowflakeId(input.tcpChannelId),
};
const mapping = await this.mappingRepository.findOne({
where: { id: config.tcpChannelId },
});
if (!mapping) {
throw new SystemMessageContractError('mapping_not_found');
}
const source = classifyTcpNatmapEndpointSource(mapping);
if (source.disabledReasonCode) {
throw new SystemMessageContractError(
messageSourceEligibilityReason(source.disabledReasonCode),
);
}
const group = await this.groupRepository.findOne({
where: { id: mapping.groupId },
});
if (!group || group.isDeleted) {
throw new SystemMessageContractError('mapping_not_managed');
}
const ddnsRecord = await this.ddnsRepository.findOne({
where: { id: config.ddnsRecordId },
});
const ddnsReason = ddnsMessageSourceReason(ddnsRecord, mapping);
if (ddnsReason) {
throw new SystemMessageContractError(ddnsReason);
}
const resolvedDdns = ddnsRecord as NetworkDdnsRecord;
return {
config,
ddnsRecord: resolvedDdns,
group,
mapping,
sourceSummary: `${group.name} / TCP NATMap · ${ddnsFqdn(resolvedDdns)}`,
};
}
}
function assertExactKeys(
value: unknown,
expectedKeys: readonly string[],
): asserts value is Record<string, unknown> {
if (
!isPlainRecord(value) ||
value === null ||
Object.keys(value).sort().join('\0') !== [...expectedKeys].sort().join('\0')
) {
throw new SystemMessageContractError('invalid_source_config');
}
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function normalizeSnowflakeId(value: unknown): string {
if (typeof value !== 'string' || !SNOWFLAKE_ID_PATTERN.test(value)) {
throw new SystemMessageContractError('invalid_source_config');
}
return value;
}
function normalizeIpv4(value: unknown): string {
if (typeof value !== 'string' || isIP(value) !== 4) {
throw new SystemMessageContractError('invalid_source_config');
}
return value;
}
function normalizePort(value: unknown): number {
if (
typeof value !== 'number' ||
!Number.isInteger(value) ||
value < 1 ||
value > 65_535
) {
throw new SystemMessageContractError('invalid_source_config');
}
return value;
}
function messageSourceEligibilityReason(
reason: TcpNatmapEndpointSourceDisabledReason,
): 'mapping_not_managed' | 'mapping_not_tcp' | 'natmap_disabled' {
switch (reason) {
case 'NATMAP_DISABLED':
return 'natmap_disabled';
case 'SOURCE_DELETING':
return 'mapping_not_managed';
case 'TCP_REQUIRED':
return 'mapping_not_tcp';
}
}
function ddnsOptionReason(
record: NetworkDdnsRecord,
mapping: NetworkPortForward | undefined,
group: NetworkPortForwardGroup | undefined,
): null | string {
if (record.isDeleted) return 'ddns_deleted';
if (!record.enabled) return 'ddns_disabled';
if (record.recordType !== 'A') return 'ddns_a_required';
if (record.sourceType !== 'port_forward_ipv4') {
return 'ddns_source_type_invalid';
}
if (
!mapping ||
String(record.portForwardId) !== String(mapping.id) ||
!group ||
group.isDeleted
) {
return 'ddns_mapping_mismatch';
}
return classifyTcpNatmapEndpointSource(mapping).disabledReasonCode;
}
function ddnsMessageSourceReason(
record: NetworkDdnsRecord | null,
mapping: NetworkPortForward,
):
| 'ddns_disabled'
| 'ddns_mapping_mismatch'
| 'ddns_not_found'
| 'ddns_not_ipv4'
| null {
if (!record || record.isDeleted) return 'ddns_not_found';
if (!record.enabled) return 'ddns_disabled';
if (record.recordType !== 'A' || record.sourceType !== 'port_forward_ipv4') {
return 'ddns_not_ipv4';
}
if (String(record.portForwardId) !== String(mapping.id)) {
return 'ddns_mapping_mismatch';
}
return null;
}
function ddnsFqdn(record: NetworkDdnsRecord): string {
const domain = record.domain.trim().toLowerCase().replace(/\.$/, '');
const subDomain = record.subDomain.trim().toLowerCase().replace(/\.$/, '');
return subDomain === '@' ? domain : `${subDomain}.${domain}`;
}
function hasCurrentEndpointTuple(mapping: NetworkPortForward): boolean {
return (
isIP(mapping.currentPublicIpv4 || '') === 4 &&
typeof mapping.currentPublicPort === 'number' &&
Number.isInteger(mapping.currentPublicPort) &&
mapping.currentPublicPort >= 1 &&
mapping.currentPublicPort <= 65_535
);
}
function hasFreshCurrentEndpoint(mapping: NetworkPortForward): boolean {
return (
hasCurrentEndpointTuple(mapping) &&
!!mapping.currentValidUntil &&
new Date(mapping.currentValidUntil).getTime() > Date.now()
);
}
function deliveryVariables(
resolved: ResolvedSubscription,
event: TcpNatmapEventPayload,
): Record<string, boolean | number | string> {
const fqdn = ddnsFqdn(resolved.ddnsRecord);
return {
endpoint: `${fqdn}:${event.publicPort}`,
fqdn,
portForwardName: resolved.group.name,
previousPublicIpv4: event.previousPublicIpv4,
previousPublicPort: event.previousPublicPort,
publicIpv4: event.publicIpv4,
publicPort: event.publicPort,
};
}

View File

@ -0,0 +1,31 @@
import type { NetworkPortForward } from './network-management.entity';
export type TcpNatmapEndpointSourceDisabledReason =
| 'NATMAP_DISABLED'
| 'SOURCE_DELETING'
| 'TCP_REQUIRED';
export type TcpNatmapEndpointSourceEligibility = {
disabledReasonCode: null | TcpNatmapEndpointSourceDisabledReason;
eligible: boolean;
};
export function classifyTcpNatmapEndpointSource(
mapping: Pick<
NetworkPortForward,
'desiredPresence' | 'isDeleted' | 'natmapDesiredEnabled' | 'protocol'
>,
): TcpNatmapEndpointSourceEligibility {
let disabledReasonCode: null | TcpNatmapEndpointSourceDisabledReason = null;
if (mapping.isDeleted || mapping.desiredPresence !== 'present') {
disabledReasonCode = 'SOURCE_DELETING';
} else if (mapping.protocol !== 'tcp') {
disabledReasonCode = 'TCP_REQUIRED';
} else if (!mapping.natmapDesiredEnabled) {
disabledReasonCode = 'NATMAP_DISABLED';
}
return {
disabledReasonCode,
eligible: disabledReasonCode === null,
};
}

View File

@ -18,6 +18,11 @@ import { SystemMessageDeliveryRunnerService } from './system-message-delivery-ru
import { SystemMessageFanoutService } from './system-message-fanout.service';
const NETWORK_STUN_SOURCE = 'network.stun.mapping-port-changed';
const NETWORK_TCP_NATMAP_SOURCE = 'network.tcp.natmap-endpoint-changed';
const NETWORK_DDNS_MESSAGE_SOURCES = [
NETWORK_STUN_SOURCE,
NETWORK_TCP_NATMAP_SOURCE,
] as const;
@Injectable()
export class SystemMessageDeliveryCoordinatorService
@ -92,39 +97,43 @@ export class SystemMessageDeliveryCoordinatorService
)
return;
const advanced = await this.dataSource.transaction(async (manager) => {
const subscriptions = await manager
.getRepository(QqbotMessageSubscription)
.find({
where: {
enabled: true,
isDeleted: false,
sourceKey: NETWORK_STUN_SOURCE,
let affected = 0;
for (const sourceKey of NETWORK_DDNS_MESSAGE_SOURCES) {
const subscriptions = await manager
.getRepository(QqbotMessageSubscription)
.find({
where: {
enabled: true,
isDeleted: false,
sourceKey,
},
});
const subscriptionIds = subscriptions
.filter(
(subscription) =>
typeof subscription.sourceConfig?.ddnsRecordId === 'string' &&
subscription.sourceConfig.ddnsRecordId === input.ddnsRecordId,
)
.map((subscription) => subscription.id);
if (!subscriptionIds.length) continue;
const events = await manager
.getRepository(QqbotMessageEvent)
.find({ where: { sourceKey } });
const eventIds = events
.filter((event) => event.payload.publicIpv4 === input.appliedAddress)
.map((event) => event.id);
if (!eventIds.length) continue;
const result = await manager.getRepository(QqbotMessageDelivery).update(
{
messageEventId: In(eventIds),
status: 'waiting_ddns',
subscriptionId: In(subscriptionIds),
},
});
const subscriptionIds = subscriptions
.filter(
(subscription) =>
typeof subscription.sourceConfig?.ddnsRecordId === 'string' &&
subscription.sourceConfig.ddnsRecordId === input.ddnsRecordId,
)
.map((subscription) => subscription.id);
if (!subscriptionIds.length) return 0;
const events = await manager
.getRepository(QqbotMessageEvent)
.find({ where: { sourceKey: NETWORK_STUN_SOURCE } });
const eventIds = events
.filter((event) => event.payload.publicIpv4 === input.appliedAddress)
.map((event) => event.id);
if (!eventIds.length) return 0;
const result = await manager.getRepository(QqbotMessageDelivery).update(
{
messageEventId: In(eventIds),
status: 'waiting_ddns',
subscriptionId: In(subscriptionIds),
},
{ nextAttemptAt: new KtDateTime() },
);
return result.affected || 0;
{ nextAttemptAt: new KtDateTime() },
);
affected += result.affected || 0;
}
return affected;
});
if (advanced > 0) this.requestDrain();
}

View File

@ -2848,6 +2848,151 @@ describe('NetworkAgentMqttService', () => {
expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled();
});
it('stages a TCP endpoint-change fact only after the matching report commits', async () => {
const harness = createV2Harness();
const previousEndpoint = v2Endpoint('tcp_natmap', {
publicIpv4: '8.8.8.7',
publicPort: 45_100,
});
harness.histories.push(
v2EndpointHistory({
endpointIdentity: endpointLeaseIdentityV2(previousEndpoint),
endpointValidatedAt: new KtDateTime(previousEndpoint.validatedAt),
endpointValidUntil: new KtDateTime(previousEndpoint.validUntil),
eventId: 'v2-tcp-event-0',
mappingId: '101',
mechanism: 'tcp_natmap',
publicIpv4: previousEndpoint.publicIpv4,
publicPort: previousEndpoint.publicPort,
}),
);
const currentEndpoint = v2Endpoint('tcp_natmap');
await harness.service.consumeMessage(
'kt/network/v2/agents/nas-main/events',
v2EndpointEvent({
channelId: '101',
endpoint: currentEndpoint,
eventId: 'v2-tcp-event-1',
mechanism: 'tcp_natmap',
protocol: 'tcp',
}),
);
expect(harness.stagedEvents).toHaveLength(0);
expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled();
await harness.service.consumeMessage(
'kt/network/v2/agents/nas-main/reported',
v2Reported(harness),
);
expect(harness.stagedEvents).toEqual([
{
eventId: 'v2-tcp-event-1',
occurredAt: '2099-07-27T00:00:16.000Z',
payload: {
previousPublicIpv4: '8.8.8.7',
previousPublicPort: 45_100,
publicIpv4: '8.8.8.8',
publicPort: 45_101,
tcpChannelId: '101',
},
resourceKey: '101',
sourceKey: 'network.tcp.natmap-endpoint-changed',
},
]);
expect(harness.deliveryCoordinator.requestDrain).toHaveBeenCalledTimes(1);
});
it('stages a report-first TCP event when only the public IPv4 changes', async () => {
const harness = createV2Harness();
const previousEndpoint = v2Endpoint('tcp_natmap', {
publicIpv4: '8.8.8.7',
});
harness.histories.push(
v2EndpointHistory({
endpointIdentity: endpointLeaseIdentityV2(previousEndpoint),
endpointValidatedAt: new KtDateTime(previousEndpoint.validatedAt),
endpointValidUntil: new KtDateTime(previousEndpoint.validUntil),
eventId: 'v2-tcp-event-0',
mappingId: '101',
mechanism: 'tcp_natmap',
publicIpv4: previousEndpoint.publicIpv4,
publicPort: previousEndpoint.publicPort,
}),
);
await harness.service.consumeMessage(
'kt/network/v2/agents/nas-main/reported',
v2Reported(harness),
);
harness.deliveryCoordinator.requestDrain.mockClear();
await harness.service.consumeMessage(
'kt/network/v2/agents/nas-main/events',
v2EndpointEvent({
channelId: '101',
endpoint: v2Endpoint('tcp_natmap'),
eventId: 'v2-tcp-event-1',
mechanism: 'tcp_natmap',
protocol: 'tcp',
}),
);
expect(harness.stagedEvents[0]).toMatchObject({
payload: {
previousPublicIpv4: '8.8.8.7',
previousPublicPort: 45_101,
publicIpv4: '8.8.8.8',
publicPort: 45_101,
tcpChannelId: '101',
},
sourceKey: 'network.tcp.natmap-endpoint-changed',
});
expect(harness.deliveryCoordinator.requestDrain).toHaveBeenCalledTimes(1);
});
it.each(['published', 'restored', 'withdrawn'] as const)(
'does not stage a TCP %s lifecycle event',
async (type) => {
const harness = createV2Harness();
const previousEndpoint = v2Endpoint('tcp_natmap', {
publicIpv4: '8.8.8.7',
publicPort: 45_100,
});
harness.histories.push(
v2EndpointHistory({
endpointIdentity: endpointLeaseIdentityV2(previousEndpoint),
eventId: 'v2-tcp-event-0',
mappingId: '101',
mechanism: 'tcp_natmap',
publicIpv4: previousEndpoint.publicIpv4,
publicPort: previousEndpoint.publicPort,
}),
);
await harness.service.consumeMessage(
'kt/network/v2/agents/nas-main/reported',
v2Reported(harness),
);
harness.deliveryCoordinator.requestDrain.mockClear();
await harness.service.consumeMessage(
'kt/network/v2/agents/nas-main/events',
v2EndpointEvent({
channelId: '101',
endpoint:
type === 'withdrawn' ? undefined : v2Endpoint('tcp_natmap'),
eventId: `v2-tcp-${type}-1`,
mechanism: 'tcp_natmap',
protocol: 'tcp',
type,
}),
);
expect(harness.stagedEvents).toHaveLength(0);
expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled();
},
);
it('rolls back v2 history and Outbox together when staging fails', async () => {
const harness = createV2Harness();
const currentEndpoint = v2Endpoint('udp_stun', {

View File

@ -11,10 +11,12 @@ import {
} 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';
import { NetworkPortForwardGroup } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.entity';
type Harness = {
client: jest.Mocked<Pick<NetworkDnsPodClient, 'getStatus' | 'reconcile'>>;
deliveryCoordinator: { notifyDdnsSynced: jest.Mock };
group: NetworkPortForwardGroup;
mapping: NetworkPortForward;
recordUpdate: jest.Mock;
records: NetworkDdnsRecord[];
@ -42,6 +44,15 @@ function matchesUpdateCriterion(actual: unknown, expected: unknown): boolean {
function createHarness(): Harness {
const records: NetworkDdnsRecord[] = [];
const group = Object.assign(new NetworkPortForwardGroup(), {
externalPort: 45_678,
id: '10',
internalPort: 45_678,
isDeleted: false,
name: '公网服务',
protocolMode: 'udp',
targetIpv4: '192.168.31.224',
});
const mapping = Object.assign(new NetworkPortForward(), {
currentObservedAt: new KtDateTime('2026-07-23T01:00:00.000Z'),
currentPublicIpv4: '8.8.8.8',
@ -49,6 +60,7 @@ function createHarness(): Harness {
currentValidUntil: new KtDateTime('2026-07-23T02:00:00.000Z'),
desiredPresence: 'present',
externalPort: 45_678,
groupId: group.id,
id: '100',
internalPort: 45_678,
isDeleted: false,
@ -126,6 +138,11 @@ function createHarness(): Harness {
findOne: async ({ where }) =>
where.id === mapping.id && !mapping.isDeleted ? mapping : null,
} as unknown as Repository<NetworkPortForward>;
const groupRepository = {
find: async () => [group],
findOne: async ({ where }) =>
where.id === group.id && !group.isDeleted ? group : null,
} as unknown as Repository<NetworkPortForwardGroup>;
const stateRepository = {
findOne: async () => state,
} as unknown as Repository<NetworkAgentState>;
@ -157,6 +174,7 @@ function createHarness(): Harness {
const service = new NetworkDdnsService(
recordRepository,
mappingRepository,
groupRepository,
stateRepository,
config,
client as unknown as NetworkDnsPodClient,
@ -166,6 +184,7 @@ function createHarness(): Harness {
return {
client,
deliveryCoordinator,
group,
mapping,
recordUpdate,
records,
@ -223,7 +242,11 @@ describe('NetworkDdnsService', () => {
expect.objectContaining({
currentAddress: '8.8.8.8',
eligible: true,
groupId: '10',
id: '100',
mechanism: 'udp_stun',
name: '公网服务 / UDP Keeper',
protocol: 'udp',
sourceType: 'port_forward_ipv4',
}),
]);
@ -239,6 +262,158 @@ describe('NetworkDdnsService', () => {
]);
});
it('accepts a managed TCP NATMap channel as an IPv4 DDNS source', async () => {
const harness = createHarness();
harness.group.protocolMode = 'tcp';
Object.assign(harness.mapping, {
keeperDesiredEnabled: false,
natmapDesiredEnabled: true,
protocol: 'tcp',
});
await expect(
harness.service.sourceOptions({ recordType: 'A' }),
).resolves.toEqual([
expect.objectContaining({
currentAddress: '8.8.8.8',
eligible: true,
groupId: '10',
mechanism: 'tcp_natmap',
name: '公网服务 / TCP NATMap',
protocol: 'tcp',
}),
]);
await expect(
harness.service.create({
domain: 'kwitsukasa.top',
enabled: false,
name: 'TCP A',
portForwardId: '100',
recordType: 'A',
sourceType: 'port_forward_ipv4',
subDomain: 'tcp',
}),
).resolves.toMatchObject({
portForwardId: '100',
source: expect.objectContaining({ mechanism: 'tcp_natmap' }),
});
});
it('derives accessEndpoint from a synchronized A record without writing DNS for a port-only change', async () => {
const harness = createHarness();
harness.group.protocolMode = 'tcp';
Object.assign(harness.mapping, {
keeperDesiredEnabled: false,
natmapDesiredEnabled: true,
protocol: 'tcp',
});
await prepareEnabledA(harness);
Object.assign(harness.records[0], {
appliedAddress: '8.8.8.8',
providerRecordId: '300',
sourceAddress: '8.8.8.8',
syncStatus: 'synced',
});
await expect(harness.service.list()).resolves.toMatchObject({
items: [{ accessEndpoint: 'pal.kwitsukasa.top:45678' }],
});
harness.mapping.currentPublicPort = 45_679;
await harness.service.reconcileNow('200');
expect(harness.client.reconcile).not.toHaveBeenCalled();
await expect(harness.service.list()).resolves.toMatchObject({
items: [{ accessEndpoint: 'pal.kwitsukasa.top:45679' }],
});
});
it('reconciles a changed TCP public IPv4 and exposes the endpoint only after provider readback', async () => {
const harness = createHarness();
harness.group.protocolMode = 'tcp';
Object.assign(harness.mapping, {
keeperDesiredEnabled: false,
natmapDesiredEnabled: true,
protocol: 'tcp',
});
harness.client.reconcile.mockResolvedValueOnce({
appliedAddress: '8.8.8.8',
changed: true,
providerRecordId: '300',
});
await prepareEnabledA(harness);
Object.assign(harness.records[0], {
appliedAddress: '8.8.8.7',
providerRecordId: '300',
sourceAddress: '8.8.8.7',
syncStatus: 'synced',
});
await harness.service.reconcileNow('200');
expect(harness.client.reconcile).toHaveBeenCalledWith(
expect.objectContaining({ targetAddress: '8.8.8.8' }),
);
await expect(harness.service.list()).resolves.toMatchObject({
items: [
{
accessEndpoint: 'pal.kwitsukasa.top:45678',
appliedAddress: '8.8.8.8',
},
],
});
});
it('withdraws a TCP source to waiting_source without changing NATMap state', async () => {
const harness = createHarness();
harness.group.protocolMode = 'tcp';
Object.assign(harness.mapping, {
keeperDesiredEnabled: false,
natmapDesiredEnabled: true,
natmapStatus: 'active',
protocol: 'tcp',
});
await prepareEnabledA(harness);
harness.mapping.currentPublicIpv4 = null;
harness.mapping.currentPublicPort = null;
await harness.service.reconcileNow('200', true);
expect(harness.records[0]).toMatchObject({
sourceAddress: null,
syncStatus: 'waiting_source',
});
expect(harness.mapping.natmapStatus).toBe('active');
expect(harness.client.reconcile).not.toHaveBeenCalled();
});
it('keeps TCP NATMap runtime state authoritative when DDNS synchronization fails', async () => {
const harness = createHarness();
harness.group.protocolMode = 'tcp';
Object.assign(harness.mapping, {
keeperDesiredEnabled: false,
natmapDesiredEnabled: true,
natmapStatus: 'active',
protocol: 'tcp',
});
harness.client.reconcile.mockRejectedValueOnce(
new NetworkDnsPodClientError('DNSPOD_RATE_LIMITED', 'rate limited', true),
);
await prepareEnabledA(harness);
await harness.service.reconcileNow('200', true);
expect(harness.records[0]).toMatchObject({
lastErrorCode: 'provider_rate_limited',
syncStatus: 'failed',
});
expect(harness.mapping).toMatchObject({
currentPublicIpv4: '8.8.8.8',
currentPublicPort: 45_678,
natmapDesiredEnabled: true,
natmapStatus: 'active',
});
});
it('never exposes a residual lease from an ineligible IPv4 source', async () => {
const harness = createHarness();
harness.mapping.keeperDesiredEnabled = false;

View File

@ -14,6 +14,7 @@ import { NetworkEndpointHistory } from '../../../src/modules/admin/platform-conf
import { NetworkPortForwardGroup } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.entity';
import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity';
import { NetworkManagementService } from '../../../src/modules/admin/platform-config/network-management/network-management.service';
import { NetworkTcpNatmapMessageSourceAdapter } from '../../../src/modules/admin/platform-config/network-management/network-tcp-natmap-message-source.adapter';
describe('network management persistence module', () => {
it('registers the five exact database entity tables', () => {
@ -225,6 +226,7 @@ describe('network management persistence module', () => {
NetworkDdnsService,
NetworkDnsPodClient,
NetworkAgentMqttService,
NetworkTcpNatmapMessageSourceAdapter,
]),
);
expect(providers).toEqual(
@ -233,6 +235,7 @@ describe('network management persistence module', () => {
NetworkDdnsService,
NetworkDnsPodClient,
NetworkAgentMqttService,
NetworkTcpNatmapMessageSourceAdapter,
]),
);
});

View File

@ -0,0 +1,286 @@
import type { Repository } from 'typeorm';
import { KtDateTime } from '../../../src/common';
import { NetworkDdnsRecord } from '../../../src/modules/admin/platform-config/network-management/network-ddns.entity';
import { NetworkPortForward } from '../../../src/modules/admin/platform-config/network-management/network-management.entity';
import { NetworkPortForwardGroup } from '../../../src/modules/admin/platform-config/network-management/network-port-forward-group.entity';
import { NetworkTcpNatmapMessageSourceAdapter } from '../../../src/modules/admin/platform-config/network-management/network-tcp-natmap-message-source.adapter';
import { SystemMessageSourceRegistry } from '../../../src/modules/qqbot/core/application/message-push/system-message-source.registry';
type Harness = {
adapter: NetworkTcpNatmapMessageSourceAdapter;
ddns: NetworkDdnsRecord;
group: NetworkPortForwardGroup;
mapping: NetworkPortForward;
registry: SystemMessageSourceRegistry;
};
function createHarness(): Harness {
const group = Object.assign(new NetworkPortForwardGroup(), {
id: '2041700000000000001',
isDeleted: false,
name: '帕鲁新世界',
});
const mapping = Object.assign(new NetworkPortForward(), {
currentPublicIpv4: '203.0.113.10',
currentPublicPort: 38213,
currentValidUntil: new KtDateTime('2026-07-27T13:00:00.000Z'),
desiredPresence: 'present' as const,
groupId: group.id,
id: '2041700000000000002',
isDeleted: false,
natmapDesiredEnabled: true,
name: group.name,
protocol: 'tcp' as const,
});
const ddns = Object.assign(new NetworkDdnsRecord(), {
appliedAddress: '203.0.113.10',
domain: 'kwitsukasa.top',
enabled: true,
id: '2041700000000000003',
isDeleted: false,
name: '帕鲁 TCP 域名',
portForwardId: mapping.id,
recordType: 'A' as const,
sourceType: 'port_forward_ipv4' as const,
subDomain: 'pal',
syncStatus: 'synced' as const,
});
const groups = [group];
const mappings = [mapping];
const records = [ddns];
const groupRepository = {
find: jest.fn(async () => groups),
findOne: jest.fn(
async ({ where }) => groups.find((item) => item.id === where.id) || null,
),
} as unknown as Repository<NetworkPortForwardGroup>;
const mappingRepository = {
find: jest.fn(async () => mappings),
findOne: jest.fn(
async ({ where }) =>
mappings.find((item) => item.id === where.id) || null,
),
} as unknown as Repository<NetworkPortForward>;
const ddnsRepository = {
find: jest.fn(async () => records),
findOne: jest.fn(
async ({ where }) => records.find((item) => item.id === where.id) || null,
),
} as unknown as Repository<NetworkDdnsRecord>;
const registry = new SystemMessageSourceRegistry();
return {
adapter: new NetworkTcpNatmapMessageSourceAdapter(
mappingRepository,
groupRepository,
ddnsRepository,
registry,
),
ddns,
group,
mapping,
registry,
};
}
function eventPayload(overrides: Record<string, unknown> = {}) {
return {
previousPublicIpv4: '198.51.100.9',
previousPublicPort: 38111,
publicIpv4: '203.0.113.10',
publicPort: 38213,
tcpChannelId: '2041700000000000002',
...overrides,
};
}
function subscriptionConfig(overrides: Record<string, unknown> = {}) {
return {
ddnsRecordId: '2041700000000000003',
tcpChannelId: '2041700000000000002',
...overrides,
};
}
describe('NetworkTcpNatmapMessageSourceAdapter', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-07-27T12:00:00.000Z'));
});
afterEach(() => {
jest.useRealTimers();
});
it('registers an independent exact source definition', () => {
const { adapter, registry } = createHarness();
adapter.onModuleInit();
expect(registry.get('network.tcp.natmap-endpoint-changed')).toBe(adapter);
expect(adapter.definition.subscriptionFields.map(({ key }) => key)).toEqual(
['tcpChannelId', 'ddnsRecordId'],
);
expect(adapter.definition.variables.map(({ key }) => key)).toEqual([
'endpoint',
'fqdn',
'publicIpv4',
'publicPort',
'previousPublicIpv4',
'previousPublicPort',
'portForwardName',
]);
});
it('normalizes exactly two same-channel subscription fields', async () => {
const { adapter } = createHarness();
await expect(
adapter.normalizeSubscriptionConfig(subscriptionConfig()),
).resolves.toEqual({
canonicalConfig: subscriptionConfig(),
resourceKey: '2041700000000000002',
sourceSummary: '帕鲁新世界 / TCP NATMap · pal.kwitsukasa.top',
});
await expect(
adapter.normalizeSubscriptionConfig(
subscriptionConfig({ portForwardId: '2041700000000000002' }),
),
).rejects.toMatchObject({ code: 'invalid_source_config' });
});
it('exposes dependent TCP channel and same-channel DDNS options', async () => {
const { adapter } = createHarness();
await expect(adapter.listSubscriptionOptions()).resolves.toMatchObject({
ddnsRecords: [
{
dependsOnValue: '2041700000000000002',
disabled: false,
value: '2041700000000000003',
},
],
tcpChannels: [
{
disabled: false,
label: '帕鲁新世界 / TCP NATMap',
value: '2041700000000000002',
},
],
});
});
it('freezes the exact delivery variables from the event tuple and DDNS FQDN', async () => {
const { adapter } = createHarness();
await expect(
adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: subscriptionConfig(),
}),
).resolves.toEqual({
reasonCode: null,
status: 'ready',
variables: {
endpoint: 'pal.kwitsukasa.top:38213',
fqdn: 'pal.kwitsukasa.top',
portForwardName: '帕鲁新世界',
previousPublicIpv4: '198.51.100.9',
previousPublicPort: 38111,
publicIpv4: '203.0.113.10',
publicPort: 38213,
},
});
});
it('waits for matching DDNS, supersedes stale tuples, and cancels withdrawal or rebinding', async () => {
const waiting = createHarness();
waiting.ddns.appliedAddress = '198.51.100.8';
await expect(
waiting.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: subscriptionConfig(),
}),
).resolves.toMatchObject({
reasonCode: 'ddns_not_synced',
status: 'waiting_ddns',
});
const expired = createHarness();
expired.mapping.currentValidUntil = new KtDateTime(
'2026-07-27T11:59:59.999Z',
);
await expect(
expired.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: subscriptionConfig(),
}),
).resolves.toEqual({
reasonCode: 'endpoint_superseded',
status: 'superseded',
});
const superseded = createHarness();
superseded.mapping.currentPublicPort = 39000;
await expect(
superseded.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: subscriptionConfig(),
}),
).resolves.toEqual({
reasonCode: 'endpoint_superseded',
status: 'superseded',
});
const withdrawn = createHarness();
withdrawn.mapping.currentPublicIpv4 = null;
withdrawn.mapping.currentPublicPort = null;
await expect(
withdrawn.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: subscriptionConfig(),
}),
).resolves.toEqual({
reasonCode: 'endpoint_withdrawn',
status: 'cancelled',
});
const rebound = createHarness();
rebound.ddns.portForwardId = '2041700000000000009';
await expect(
rebound.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: subscriptionConfig(),
}),
).resolves.toMatchObject({
reasonCode: 'ddns_mapping_mismatch',
status: 'cancelled',
});
const disabled = createHarness();
disabled.mapping.natmapDesiredEnabled = false;
await expect(
disabled.adapter.resolveDelivery({
eventPayload: eventPayload(),
subscriptionConfig: subscriptionConfig(),
}),
).resolves.toEqual({
reasonCode: 'natmap_disabled',
status: 'cancelled',
});
});
it('rejects non-changing or malformed event tuples', () => {
const { adapter } = createHarness();
expect(() =>
adapter.validateEventPayload(
eventPayload({
previousPublicIpv4: '203.0.113.10',
previousPublicPort: 38213,
}),
),
).toThrow('invalid_source_config');
expect(() =>
adapter.validateEventPayload(eventPayload({ publicPort: '38213' })),
).toThrow('invalid_source_config');
});
});

View File

@ -0,0 +1,41 @@
import { classifyTcpNatmapEndpointSource } from '../../../src/modules/admin/platform-config/network-management/network-tcp-natmap-source-eligibility';
function source(overrides: Record<string, unknown> = {}) {
return {
desiredPresence: 'present' as const,
isDeleted: false,
natmapDesiredEnabled: true,
protocol: 'tcp' as const,
...overrides,
};
}
describe('classifyTcpNatmapEndpointSource', () => {
it('accepts only a managed TCP channel with NATMap enabled', () => {
expect(classifyTcpNatmapEndpointSource(source())).toEqual({
disabledReasonCode: null,
eligible: true,
});
});
it.each([
[
{ desiredPresence: 'absent' },
{ disabledReasonCode: 'SOURCE_DELETING', eligible: false },
],
[
{ isDeleted: true },
{ disabledReasonCode: 'SOURCE_DELETING', eligible: false },
],
[
{ protocol: 'udp' },
{ disabledReasonCode: 'TCP_REQUIRED', eligible: false },
],
[
{ natmapDesiredEnabled: false },
{ disabledReasonCode: 'NATMAP_DISABLED', eligible: false },
],
])('classifies %j without changing STUN semantics', (input, expected) => {
expect(classifyTcpNatmapEndpointSource(source(input))).toEqual(expected);
});
});

View File

@ -426,16 +426,24 @@ describe('QQBot message-push management controllers', () => {
});
});
it('resolves options and subscription config from arbitrary source metadata', async () => {
it('resolves dependent TCP NATMap options through the generic source endpoints', async () => {
const definition = {
description: '后台任务完成',
displayName: '任务完成',
sourceKey: 'system.job.completed',
description: 'TCP NATMap 端点变更',
displayName: 'TCP NATMap 端点变更',
sourceKey: 'network.tcp.natmap-endpoint-changed',
subscriptionFields: [
{
key: 'jobId',
label: '任务',
optionCollection: 'jobs',
key: 'tcpChannelId',
label: 'TCP NATMap 通道',
optionCollection: 'tcpChannels',
required: true,
type: 'select',
},
{
dependsOn: 'tcpChannelId',
key: 'ddnsRecordId',
label: 'IPv4 DDNS 记录',
optionCollection: 'ddnsRecords',
required: true,
type: 'select',
},
@ -446,29 +454,47 @@ describe('QQBot message-push management controllers', () => {
registry.get.mockReturnValueOnce({
definition,
listSubscriptionOptions: jest.fn().mockResolvedValue({
jobs: [
ddnsRecords: [
{
credential: 'must-not-leak',
dependsOnValue: 'tcp-channel-1',
disabled: false,
disabledReasonCode: null,
label: '夜间同步',
value: 'job-1',
label: 'Pal TCP · pal.example.test',
value: 'ddns-1',
},
],
tcpChannels: [
{
disabled: false,
disabledReasonCode: null,
label: '帕鲁新世界 / TCP NATMap',
value: 'tcp-channel-1',
},
],
}),
});
const options = await request(apiUrl)
.get(
'/qqbot/message-push/sources/system.job.completed/subscription-options',
'/qqbot/message-push/sources/network.tcp.natmap-endpoint-changed/subscription-options',
)
.expect(200);
expect(options.body.data).toEqual({
jobs: [
ddnsRecords: [
{
dependsOnValue: 'tcp-channel-1',
disabled: false,
disabledReasonCode: null,
label: 'Pal TCP · pal.example.test',
value: 'ddns-1',
},
],
tcpChannels: [
{
disabled: false,
disabledReasonCode: null,
label: '夜间同步',
value: 'job-1',
label: '帕鲁新世界 / TCP NATMap',
value: 'tcp-channel-1',
},
],
});
@ -479,7 +505,8 @@ describe('QQBot message-push management controllers', () => {
...(subscriptionView() as unknown as Record<string, unknown>),
sourceConfig: {
credential: 'must-not-leak',
jobId: 'job-1',
ddnsRecordId: 'ddns-1',
tcpChannelId: 'tcp-channel-1',
},
sourceKey: definition.sourceKey,
sourceName: definition.displayName,
@ -492,7 +519,8 @@ describe('QQBot message-push management controllers', () => {
.get('/qqbot/message-push/subscriptions')
.expect(200);
expect(subscriptionsResponse.body.data.items[0].sourceConfig).toEqual({
jobId: 'job-1',
ddnsRecordId: 'ddns-1',
tcpChannelId: 'tcp-channel-1',
});
});

View File

@ -6,6 +6,7 @@ import { QqbotMessageEvent } from '../../../../src/modules/qqbot/core/infrastruc
import { QqbotMessageSubscription } from '../../../../src/modules/qqbot/core/infrastructure/persistence/message-push/qqbot-message-subscription.entity';
const SOURCE_KEY = 'network.stun.mapping-port-changed';
const TCP_SOURCE_KEY = 'network.tcp.natmap-endpoint-changed';
const NOW = new Date('2026-07-24T03:04:05.000Z');
type CoordinatorStore = {
@ -78,6 +79,13 @@ function createCoordinatorHarness(): CoordinatorHarness {
status: 'waiting_ddns',
subscriptionId: 'subscription-match',
},
{
id: 'delivery-tcp-match',
messageEventId: 'event-tcp-match',
nextAttemptAt: 'future-tcp-match',
status: 'waiting_ddns',
subscriptionId: 'subscription-tcp-match',
},
],
events: [
{
@ -100,6 +108,11 @@ function createCoordinatorHarness(): CoordinatorHarness {
payload: { publicIpv4: 8_888 },
sourceKey: SOURCE_KEY,
},
{
id: 'event-tcp-match',
payload: { publicIpv4: '8.8.8.8' },
sourceKey: TCP_SOURCE_KEY,
},
],
subscriptions: [
{
@ -109,6 +122,13 @@ function createCoordinatorHarness(): CoordinatorHarness {
sourceConfig: { ddnsRecordId: '9007199254740993' },
sourceKey: SOURCE_KEY,
},
{
enabled: true,
id: 'subscription-tcp-match',
isDeleted: false,
sourceConfig: { ddnsRecordId: '9007199254740993' },
sourceKey: TCP_SOURCE_KEY,
},
{
enabled: false,
id: 'subscription-disabled',
@ -263,7 +283,7 @@ describe('SystemMessageDeliveryCoordinatorService DDNS integration', () => {
expect(harness.store.deliveries).toEqual(
before.deliveries.map((delivery) =>
delivery.id === 'delivery-match'
delivery.id === 'delivery-match' || delivery.id === 'delivery-tcp-match'
? { ...delivery, nextAttemptAt: new KtDateTime(NOW) }
: delivery,
),
@ -271,6 +291,7 @@ describe('SystemMessageDeliveryCoordinatorService DDNS integration', () => {
expect(harness.operations).toEqual([
'transaction:start',
'delivery:update',
'delivery:update',
'transaction:commit',
'drain',
]);
@ -282,6 +303,9 @@ describe('SystemMessageDeliveryCoordinatorService DDNS integration', () => {
harness.store.deliveries.find(
(delivery) => delivery.id === 'delivery-match',
)!.status = 'processing';
harness.store.deliveries.find(
(delivery) => delivery.id === 'delivery-tcp-match',
)!.status = 'processing';
const before = cloneStore(harness.store);
await harness.coordinator.notifyDdnsSynced({

View File

@ -27,6 +27,7 @@ import { QqbotMessageSubscription } from '../../../../src/modules/qqbot/core/inf
const NOW = new Date('2026-07-24T00:00:00.000Z');
const SOURCE_KEY = 'network.stun.mapping-port-changed';
const TCP_SOURCE_KEY = 'network.tcp.natmap-endpoint-changed';
const RESOURCE_KEY = '9007199254740993';
type Store = {
@ -201,7 +202,7 @@ function matches(
});
}
function setup(seed: Partial<Store> = {}) {
function setup(seed: Partial<Store> = {}, sourceKey = SOURCE_KEY) {
let state: Store = {
accounts: seed.accounts ?? [account()],
bindings: seed.bindings ?? [binding()],
@ -215,7 +216,7 @@ function setup(seed: Partial<Store> = {}) {
definition: {
description: 'test',
displayName: 'test',
sourceKey: SOURCE_KEY,
sourceKey,
subscriptionFields: [],
variables: [
{
@ -971,6 +972,40 @@ describe('System message delivery runner direct preflight contracts', () => {
expect(harness.getState().deliveries[0].status).toBe('success');
});
it('11 handles a due TCP NATMap waiting row through the generic source registry', async () => {
const harness = setup(
{
deliveries: [delivery({ status: 'waiting_ddns' })],
events: [
event({
payload: {
publicIpv4: '203.0.113.10',
publicPort: 45_101,
tcpChannelId: RESOURCE_KEY,
},
sourceKey: TCP_SOURCE_KEY,
}),
],
subscriptions: [
subscription({
sourceConfig: {
ddnsRecordId: '9007199254740995',
tcpChannelId: RESOURCE_KEY,
},
sourceKey: TCP_SOURCE_KEY,
}),
],
},
TCP_SOURCE_KEY,
);
await harness.runner.runOnce(NOW);
expect(harness.adapter.resolveDelivery).toHaveBeenCalledTimes(1);
expect(harness.sender.sendStrictPlainText).toHaveBeenCalledTimes(1);
expect(harness.getState().deliveries[0].status).toBe('success');
});
it.each([
['endpoint_port_changed', 'superseded'],
['endpoint_ipv4_changed', 'superseded'],

View File

@ -23,6 +23,7 @@ import {
const NOW = new Date('2026-07-24T00:00:00.000Z');
const SOURCE_KEY = 'network.stun.mapping-port-changed';
const TCP_SOURCE_KEY = 'network.tcp.natmap-endpoint-changed';
const JOB_SOURCE_KEY = 'system.job.completed';
const RESOURCE_KEY = '9007199254740993';
@ -800,6 +801,52 @@ describe('SystemMessageFanoutService', () => {
);
});
it('fans out the independent TCP NATMap source without source-specific core logic', async () => {
const adapter = sourceAdapter(TCP_SOURCE_KEY);
adapter.eventResourceKey.mockImplementation((payload) =>
typeof payload.tcpChannelId === 'string' ? payload.tcpChannelId : '',
);
adapter.subscriptionResourceKey.mockImplementation((config) =>
typeof config.tcpChannelId === 'string' ? config.tcpChannelId : null,
);
adapter.resolveDelivery.mockResolvedValue({
reasonCode: null,
status: 'ready',
variables: { endpoint: 'tcp.example.com:45101' },
});
const fixture = setup(
{
events: [
event({
payload: {
publicIpv4: '203.0.113.10',
publicPort: 45_101,
tcpChannelId: RESOURCE_KEY,
},
sourceKey: TCP_SOURCE_KEY,
}),
],
subscriptions: [
subscription({
sourceConfig: {
ddnsRecordId: '9007199254740995',
tcpChannelId: RESOURCE_KEY,
},
sourceKey: TCP_SOURCE_KEY,
}),
],
templates: [template({ sourceKey: TCP_SOURCE_KEY })],
},
adapter,
);
await expect(fixture.service.runOnce(NOW)).resolves.toBe(1);
expect(fixture.deliveries()[0]).toMatchObject({
renderedMessage: 'endpoint=tcp.example.com:45101',
variableSnapshot: { endpoint: 'tcp.example.com:45101' },
});
});
it('schedules ready and waiting-DDNS snapshots without sending OneBot', async () => {
const fixture = setup();
fixture.adapter.resolveDelivery.mockResolvedValueOnce({

View File

@ -42,20 +42,20 @@ describe('SystemMessageSourceRegistry', () => {
it('sorts definitions and unregisters only the same adapter instance', () => {
const registry = new SystemMessageSourceRegistry();
const first = createAdapter('z.source');
const second = createAdapter('a.source');
const replacement = createAdapter('z.source');
const first = createAdapter('network.tcp.natmap-endpoint-changed');
const second = createAdapter('network.stun.mapping-port-changed');
const replacement = createAdapter('network.tcp.natmap-endpoint-changed');
registry.register(first);
registry.register(second);
registry.unregister('z.source', replacement);
registry.unregister('network.tcp.natmap-endpoint-changed', replacement);
expect(registry.list().map((definition) => definition.sourceKey)).toEqual([
'a.source',
'z.source',
'network.stun.mapping-port-changed',
'network.tcp.natmap-endpoint-changed',
]);
registry.unregister('z.source', first);
registry.unregister('network.tcp.natmap-endpoint-changed', first);
expect(registry.list().map((definition) => definition.sourceKey)).toEqual([
'a.source',
'network.stun.mapping-port-changed',
]);
});
});