From cd157e773f2b6b8e6b16bf0bcaef4c70693527cf Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 27 Jul 2026 14:09:45 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5TCP=20NATMap=E4=B8=8A?= =?UTF-8?q?=E6=8A=A5=E4=B8=8E=E7=AB=AF=E7=82=B9=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 60 +- README.md | 32 +- sql/network-management-init.sql | 17 +- sql/network-tcp-natmap-v2-verify.sql | 57 + sql/network-tcp-natmap-v2.sql | 204 ++- sql/refactor-v3/00-full-schema.sql | 17 +- .../network-agent-mqtt.service.ts | 960 ++++++++++- .../network-agent-state.entity.ts | 14 +- .../network-agent-v2.types.ts | 837 ++++++++-- .../network-endpoint-history.entity.ts | 34 +- .../network-management.entity.ts | 48 +- .../network-management.types.ts | 1 + .../network-agent-mqtt.service.spec.ts | 1468 ++++++++++++++++- .../network-agent-v2.types.spec.ts | 127 +- .../network-management.entity.spec.ts | 110 +- .../network-tcp-natmap-v2-sql.spec.ts | 110 +- 16 files changed, 3779 insertions(+), 317 deletions(-) diff --git a/API.md b/API.md index afa293c..fa8ca58 100644 --- a/API.md +++ b/API.md @@ -111,17 +111,37 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 | `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`。 +逻辑组接口用于当前 Admin;同一逻辑组可包含相同端口的 TCP、UDP 或双协议通道: + +| 方法 | 路径 | 认证 | 说明 | +| -------- | --------------------------------------------------------------------------------- | ------- | ----------------------------------- | +| `GET` | `/system/network/port-forward-group/list` | `super` | 分页查询逻辑组和两个协议通道 | +| `POST` | `/system/network/port-forward-group` | `super` | 新增 `tcp / udp / tcp_udp` 逻辑组 | +| `PUT` | `/system/network/port-forward-group/:groupId` | `super` | 原子修改组字段和协议模式 | +| `DELETE` | `/system/network/port-forward-group/:groupId` | `super` | 为组内全部通道写入 absent tombstone | +| `POST` | `/system/network/port-forward-group/:groupId/channels/:protocol/retry` | `super` | 重试指定协议通道 | +| `GET` | `/system/network/port-forward-group/:groupId/channels/:protocol/endpoint-history` | `super` | 查询指定协议和 mechanism 的端点历史 | +| `POST` | `/system/network/port-forward-group/:groupId/channels/tcp/natmap/enable` | `super` | 启用 TCP NATMap | +| `POST` | `/system/network/port-forward-group/:groupId/channels/tcp/natmap/disable` | `super` | 停用 TCP NATMap | +| `POST` | `/system/network/port-forward-group/:groupId/channels/udp/keeper/enable` | `super` | 启用 UDP Keeper | +| `POST` | `/system/network/port-forward-group/:groupId/channels/udp/keeper/disable` | `super` | 停用 UDP Keeper | +| `POST` | `/system/network/port-forward-group/:groupId/channels/udp/keeper/probe` | `super` | 立即请求一次 UDP 探测 | + +旧 `/port-forward` 路由保留平面通道兼容语义;多通道组必须使用逻辑组接口。新增和修改请求只接受各 DTO 白名单字段,目标 NAS IPv4 固定来自 `NETWORK_AGENT_TARGET_IPV4`,未知字段返回 400。Snowflake ID 与 revision 在 HTTP JSON 中保留为字符串。所有动态响应设置 `Cache-Control: no-store`。 端点历史行使用 `portForwardId` 关联端口转发,撤下原因返回为 `withdrawalReason`。Agent 状态同时返回统一的 `lastErrorCode/lastErrorMessage`(reconciliation 优先于 MQTT)和细分错误字段,便于 Admin 展示与诊断。 -API 数据库是唯一事实源。每次合法期望变更在同一事务中锁定 `network_agent_state`、全局 revision 只增加一次并保存稳定 `desiredIssuedAt`;事务提交后再发布 `kt/network/v1/agents/{agentId}/desired` 的 QoS 1 retained 完整快照。PUBACK 只推进 `publishedRevision`,Agent 的完整 `reported` 才推进 `appliedRevision` 和逐条实际状态。MQTT 或 Agent 离线不回滚已接受的期望状态。 +API 数据库是唯一事实源。每次合法期望变更在同一事务中锁定 `network_agent_state`、全局 revision 只增加一次并保存稳定 `desiredIssuedAt`;事务提交后再向当前 owner 的 `kt/network/v1|v2/agents/{agentId}/desired` 发布 QoS 1 retained 完整快照。PUBACK 只推进 `publishedRevision`,Agent 的完整 `reported` 才推进 `appliedRevision` 和逐通道实际状态。Agent 声明 v2 capability 且 release policy 允许后,API 清除旧 retained owner、切换到 v2,并忽略此后迟到的 v1 reported/events。MQTT 或 Agent 离线不回滚已接受的期望状态。 -Wire contract 采用 `kt-network-agent/internal/contract` schema-v1:desired mapping 使用 `state=present|absent`;reported 使用 `appliedRevision`、`desiredDigest`、helper 状态和逐条 router/route/Keeper 证据;endpoint event 使用唯一 `eventId`。API 不发布数据库备注,也不在 MQTT、HTTP 或数据库中接收/保存路由器密码和 token。事件按 `eventId` 幂等追加;删除只有在 Agent 明确回报 absent、synced、router/route 均不存在、Keeper 期望关闭且实际 disabled、current endpoint 为空,并且 helper 已确认且 `helperAppliedRevision === appliedRevision` 后才完成,随后生成新 revision 移除 tombstone。 +Wire contract 同时保留 schema-v1 兼容路径和严格 schema-v2。v2 外层使用 snapshot revision/digest,逐通道使用独立 desired revision/digest;错误的外层 identity 整包拒绝,单通道 identity/digest 冲突只隔离该通道,不阻断合法 sibling。合法外层 snapshot 完成一次协调处理后推进 applied revision 与独立 applied schema 水位;该值不代表全部通道成功。通道保存原始 RFC3339Nano report 水位,阻止同 revision、同毫秒内乱序的旧 report 回退运行态与错误。Agent 首次接受 v2 后会耐久锁存 owner;在实现 Agent 可确认的显式降级握手前,API-only downgrade 固定返回拒绝,v1 reported/events 也不能越过已持久化的 v2 applied schema 水位。 -Admin 首次进入网络管理页通过 HTTP 读取快照,随后使用 `/system/network/events/stream` 接收 `network-state-changed`。API 只在 `reported`、`status`、`events` 对应事务提交且语义状态实际变化后发出事件;MQTT QoS 1 重投、仅推进 `lastHeartbeatAt` 的状态心跳以及仅推进 `currentObservedAt/currentValidUntil/lastObservedAt` 的租约续期继续持久化,但不触发刷新。公网 IP/端口、Keeper/同步/错误/删除状态或 Agent 在线会话变化仍发布事件。SSE 心跳只维持连接并复用最近一次真实状态事件 ID,尚无状态事件时显式发送空 ID,避免 Nest 自动生成游标;浏览器重连通过 `Last-Event-ID` 或 `lastEventId` 重放有限窗口,游标失效时收到一次 `snapshot-required` 并重新读取 HTTP 快照。前端不直接订阅 MQTT,也不使用定时轮询。 +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。 -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 处理。 +v2 endpoint event 与 matching reported 是两条独立 MQTT 消息。event、report 和 ownership 切换统一按 Agent state、channel、history 顺序加锁。正常 event-first 路径先提交 history,不提前创建会被 5 秒扫描器消费的 UDP Outbox;history 耐久保存来源 revision 和由 mechanism、tuple、observed/validated/valid-until 原始值生成的完整 lease identity,matching 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 门禁。 + +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 不提供 STUN,UDP 只有 `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 Keeper;AAAA 记录固定使用 `sourceType=agent_ipv6`,不得携带 `portForwardId`。来源暂不可用时记录进入 `waiting_source`,不会向腾讯云写入空地址。协调器只修改腾讯云云解析 DNS 中已存在、已启用、默认线路且唯一的同类型记录,写入时保留 RecordId、线路和 TTL,并回读相同 RecordId 验证结果;DNS 值不包含端口。删除接口只删除本地自动更新绑定,不删除云端记录。Provider 状态接口只返回开关、配置完整性和官方 provider 标识,不返回 SecretId、SecretKey 或 SDK 原始错误。 @@ -129,21 +149,21 @@ Agent 状态响应额外包含可选的 `currentPublicIpv6/currentIpv6ObservedAt ## 环境变量分组 -| 分组 | 关键变量 | -| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | -| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` | -| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | -| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` | -| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` | -| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`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 | `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_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` | +| 分组 | 关键变量 | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | +| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` | +| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | +| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` | +| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` | +| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`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 | `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_TCP_NATMAP_RELEASE_MODE`、`NETWORK_TCP_NATMAP_CANARY_PORTS`、`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` | 真实密码、Token、OAuth secret 和生产 env 不提交到 Git。 diff --git a/README.md b/README.md index 2f2027d..463f25e 100644 --- a/README.md +++ b/README.md @@ -56,19 +56,19 @@ ci/ Jenkins Agent/Docker 辅助文件 主要配置分组: -| 分组 | 变量 | -| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | -| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` | -| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | -| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` | -| 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_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` | +| 分组 | 变量 | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | +| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` | +| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | +| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` | +| 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_TCP_NATMAP_RELEASE_MODE`、`NETWORK_TCP_NATMAP_CANARY_PORTS`、`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` | `DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。 @@ -80,7 +80,11 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。 -System 网络管理以 MySQL 中的 TCP/UDP 端口转发期望状态为唯一事实源。`super` 通过统一 CRUD 和 UDP Keeper 动作修改期望状态;API 在事务内单调提升 revision,提交后使用固定 `kt/network/v1/agents/{agentId}` MQTT topic、QoS 1 retained 完整快照通知 NAS `kt-network-agent`,自身不登录路由器、不接收路由器密码,也不执行 raw socket。Agent 失联或 MQTT 暂不可用时合法请求仍保存为 pending,恢复后按 revision 自动收敛;消费端发生瞬时数据库错误或 SUBACK 失败时主动重连并依赖 broker 重投,非法负载则确认后丢弃,避免 poison message 阻塞。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 控制链路未接线。 +System 网络管理以 MySQL 中的逻辑端口转发组和 TCP/UDP 协议通道为唯一事实源。`super` 可在同一外部端口配置 `TCP`、`UDP` 或 `TCP+UDP`,并分别启停 TCP NATMap 与 UDP Keeper;TCP 发布范围由 `NETWORK_TCP_NATMAP_RELEASE_MODE` 和 canary 端口控制。API 在事务内单调提升 revision,提交后按当前 schema owner 使用 `kt/network/v1|v2/agents/{agentId}`、QoS 1 retained 完整快照通知 NAS `kt-network-agent`,自身不登录路由器、不接收路由器密码,也不执行 raw socket。Agent 声明 v2 capability 且发布门禁允许后,v2 成为唯一 desired/reported/events 写入者,迟到的 v1 reported/events 不再覆盖状态。 + +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 只提交 history;UDP 端口变更必须等 matching current 在 reported 事务中提交后才写现有 STUN Outbox,TCP 在独立消息源上线前只写 `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,但保留候选、最近观测、发布基线与历史。 同一模块提供腾讯云云解析 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 的技术服务名。 diff --git a/sql/network-management-init.sql b/sql/network-management-init.sql index e443f2f..6c6f547 100644 --- a/sql/network-management-init.sql +++ b/sql/network-management-init.sql @@ -34,6 +34,8 @@ CREATE TABLE IF NOT EXISTS `network_port_forward` ( `desired_revision` BIGINT NOT NULL DEFAULT 0, `desired_issued_at` DATETIME(6) NOT NULL, `reported_revision` BIGINT NOT NULL DEFAULT 0, + `last_reported_at` DATETIME(6) NULL, + `last_reported_at_wire` VARCHAR(64) NULL, `sync_status` VARCHAR(16) NOT NULL DEFAULT 'pending', `keeper_status` VARCHAR(16) NOT NULL DEFAULT 'disabled', `natmap_status` VARCHAR(16) NOT NULL DEFAULT 'disabled', @@ -41,15 +43,19 @@ CREATE TABLE IF NOT EXISTS `network_port_forward` ( `current_public_port` INT NULL, `current_observed_at` DATETIME(6) NULL, `current_validated_at` DATETIME(6) NULL, + `current_validated_at_wire` VARCHAR(64) NULL, `current_valid_until` DATETIME(6) NULL, + `current_endpoint_identity` CHAR(64) NULL, `last_observed_ipv4` VARCHAR(15) NULL, `last_observed_port` INT NULL, `last_observed_at` DATETIME(6) NULL, `last_observed_validated_at` DATETIME(6) NULL, + `last_observed_validated_at_wire` VARCHAR(64) NULL, `candidate_public_ipv4` VARCHAR(15) NULL, `candidate_public_port` INT NULL, `candidate_observed_at` DATETIME(6) NULL, `candidate_validated_at` DATETIME(6) NULL, + `candidate_validated_at_wire` VARCHAR(64) NULL, `last_published_public_ipv4` VARCHAR(15) NULL, `last_published_public_port` INT NULL, `last_published_at` DATETIME(6) NULL, @@ -111,16 +117,17 @@ CREATE TABLE IF NOT EXISTS `network_agent_state` ( `max_supported_schema_version` INT UNSIGNED NOT NULL DEFAULT 1, `tcp_natmap_capable` TINYINT(1) NOT NULL DEFAULT 0, `applied_revision` BIGINT NOT NULL DEFAULT 0, + `applied_schema_version` INT UNSIGNED NOT NULL DEFAULT 1, `online` TINYINT(1) NOT NULL DEFAULT 0, - `version` VARCHAR(64) NULL, + `version` VARCHAR(128) 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_mqtt_error_message` VARCHAR(512) NULL, `last_reconcile_error_code` VARCHAR(64) NULL, - `last_reconcile_error_message` VARCHAR(500) NULL, + `last_reconcile_error_message` VARCHAR(512) NULL, `create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (`agent_id`) @@ -164,8 +171,12 @@ CREATE TABLE IF NOT EXISTS `network_endpoint_history` ( `mapping_id` BIGINT NOT NULL, `event_type` VARCHAR(16) NOT NULL, `mechanism` VARCHAR(16) NOT NULL DEFAULT 'udp_stun', + `source_revision` BIGINT NULL, + `endpoint_identity` CHAR(64) NULL, `public_ipv4` VARCHAR(15) NULL, `public_port` INT NULL, + `endpoint_validated_at` DATETIME(6) NULL, + `endpoint_valid_until` DATETIME(6) NULL, `first_observed_at` DATETIME(6) NOT NULL, `last_observed_at` DATETIME(6) NOT NULL, `occurred_at` DATETIME(6) NOT NULL, diff --git a/sql/network-tcp-natmap-v2-verify.sql b/sql/network-tcp-natmap-v2-verify.sql index 6cc3ab4..5822c80 100644 --- a/sql/network-tcp-natmap-v2-verify.sql +++ b/sql/network-tcp-natmap-v2-verify.sql @@ -25,6 +25,52 @@ WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_TYPE = 'bigint' AND IS_NULLABLE = 'NO'; +SELECT 'network_port_forward_last_reported_at' AS check_name, COUNT(*) AS matched_rows +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'network_port_forward' + AND COLUMN_NAME = 'last_reported_at' + AND COLUMN_TYPE = 'datetime(6)' + AND IS_NULLABLE = 'YES'; + +SELECT 'network_port_forward_last_reported_at_wire' AS check_name, COUNT(*) AS matched_rows +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'network_port_forward' + AND COLUMN_NAME = 'last_reported_at_wire' + AND COLUMN_TYPE = 'varchar(64)' + AND IS_NULLABLE = 'YES'; + +SELECT 'network_port_forward_wire_identity_columns' AS check_name, COUNT(*) AS matched_rows +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'network_port_forward' + AND ( + (COLUMN_NAME = 'candidate_validated_at_wire' AND COLUMN_TYPE = 'varchar(64)' AND IS_NULLABLE = 'YES') + OR (COLUMN_NAME = 'current_validated_at_wire' AND COLUMN_TYPE = 'varchar(64)' AND IS_NULLABLE = 'YES') + OR (COLUMN_NAME = 'current_endpoint_identity' AND COLUMN_TYPE = 'char(64)' AND IS_NULLABLE = 'YES') + OR (COLUMN_NAME = 'last_observed_validated_at_wire' AND COLUMN_TYPE = 'varchar(64)' AND IS_NULLABLE = 'YES') + ); + +SELECT 'network_agent_state_applied_schema_version' AS check_name, COUNT(*) AS matched_rows +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'network_agent_state' + AND COLUMN_NAME = 'applied_schema_version' + AND COLUMN_TYPE = 'int unsigned' + AND IS_NULLABLE = 'NO' + AND COLUMN_DEFAULT = '1'; + +SELECT 'network_agent_state_v2_text_widths' AS check_name, COUNT(*) AS matched_rows +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'network_agent_state' + AND ( + (COLUMN_NAME = 'version' AND COLUMN_TYPE = 'varchar(128)' AND IS_NULLABLE = 'YES') + OR (COLUMN_NAME = 'last_mqtt_error_message' AND COLUMN_TYPE = 'varchar(512)' AND IS_NULLABLE = 'YES') + OR (COLUMN_NAME = 'last_reconcile_error_message' AND COLUMN_TYPE = 'varchar(512)' AND IS_NULLABLE = 'YES') + ); + SELECT 'network_port_forward_v2_indexes' AS check_name, INDEX_NAME, NON_UNIQUE FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() @@ -41,3 +87,14 @@ WHERE TABLE_SCHEMA = DATABASE() AND COLUMN_NAME = 'mechanism' AND COLUMN_TYPE = 'varchar(16)' AND IS_NULLABLE = 'NO'; + +SELECT 'network_endpoint_history_correlation_columns' AS check_name, COUNT(*) AS matched_rows +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'network_endpoint_history' + AND ( + (COLUMN_NAME = 'source_revision' AND COLUMN_TYPE = 'bigint' AND IS_NULLABLE = 'YES') + OR (COLUMN_NAME = 'endpoint_identity' AND COLUMN_TYPE = 'char(64)' AND IS_NULLABLE = 'YES') + OR (COLUMN_NAME = 'endpoint_validated_at' AND COLUMN_TYPE = 'datetime(6)' AND IS_NULLABLE = 'YES') + OR (COLUMN_NAME = 'endpoint_valid_until' AND COLUMN_TYPE = 'datetime(6)' AND IS_NULLABLE = 'YES') + ); diff --git a/sql/network-tcp-natmap-v2.sql b/sql/network-tcp-natmap-v2.sql index 7288bef..852f423 100644 --- a/sql/network-tcp-natmap-v2.sql +++ b/sql/network-tcp-natmap-v2.sql @@ -72,6 +72,34 @@ PREPARE network_port_forward_natmap_status_stmt FROM @network_port_forward_natma EXECUTE network_port_forward_natmap_status_stmt; DEALLOCATE PREPARE network_port_forward_natmap_status_stmt; +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' + AND COLUMN_NAME = 'last_reported_at' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_port_forward` ADD COLUMN `last_reported_at` DATETIME(6) NULL AFTER `reported_revision`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' + AND COLUMN_NAME = 'last_reported_at_wire' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_port_forward` ADD COLUMN `last_reported_at_wire` VARCHAR(64) NULL AFTER `last_reported_at`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + SET @network_tcp_natmap_v2_column_exists := ( SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' @@ -212,6 +240,62 @@ PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; EXECUTE network_tcp_natmap_v2_alter_stmt; DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' + AND COLUMN_NAME = 'candidate_validated_at_wire' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_port_forward` ADD COLUMN `candidate_validated_at_wire` VARCHAR(64) NULL AFTER `candidate_validated_at`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' + AND COLUMN_NAME = 'current_validated_at_wire' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_port_forward` ADD COLUMN `current_validated_at_wire` VARCHAR(64) NULL AFTER `current_validated_at`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' + AND COLUMN_NAME = 'current_endpoint_identity' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_port_forward` ADD COLUMN `current_endpoint_identity` CHAR(64) NULL AFTER `current_valid_until`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' + AND COLUMN_NAME = 'last_observed_validated_at_wire' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_port_forward` ADD COLUMN `last_observed_validated_at_wire` VARCHAR(64) NULL AFTER `last_observed_validated_at`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + SET @network_tcp_natmap_v2_column_exists := ( SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' @@ -310,6 +394,65 @@ PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; EXECUTE network_tcp_natmap_v2_alter_stmt; DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_agent_state' + AND COLUMN_NAME = 'applied_schema_version' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_agent_state` ADD COLUMN `applied_schema_version` INT UNSIGNED NOT NULL DEFAULT 1 AFTER `applied_revision`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_needs_resize := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_agent_state' + AND COLUMN_NAME = 'version' + AND (COLUMN_TYPE <> 'varchar(128)' OR IS_NULLABLE <> 'YES') +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_needs_resize = 1, + 'ALTER TABLE `network_agent_state` MODIFY COLUMN `version` VARCHAR(128) NULL', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_needs_resize := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_agent_state' + AND COLUMN_NAME = 'last_mqtt_error_message' + AND (COLUMN_TYPE <> 'varchar(512)' OR IS_NULLABLE <> 'YES') +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_needs_resize = 1, + 'ALTER TABLE `network_agent_state` MODIFY COLUMN `last_mqtt_error_message` VARCHAR(512) NULL', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_needs_resize := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_agent_state' + AND COLUMN_NAME = 'last_reconcile_error_message' + AND (COLUMN_TYPE <> 'varchar(512)' OR IS_NULLABLE <> 'YES') +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_needs_resize = 1, + 'ALTER TABLE `network_agent_state` MODIFY COLUMN `last_reconcile_error_message` VARCHAR(512) NULL', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + SET @network_endpoint_history_mechanism_exists := ( SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history' @@ -324,16 +467,73 @@ PREPARE network_endpoint_history_mechanism_stmt FROM @network_endpoint_history_m EXECUTE network_endpoint_history_mechanism_stmt; DEALLOCATE PREPARE network_endpoint_history_mechanism_stmt; +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history' + AND COLUMN_NAME = 'source_revision' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_endpoint_history` ADD COLUMN `source_revision` BIGINT NULL AFTER `mechanism`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history' + AND COLUMN_NAME = 'endpoint_identity' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_endpoint_history` ADD COLUMN `endpoint_identity` CHAR(64) NULL AFTER `source_revision`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history' + AND COLUMN_NAME = 'endpoint_validated_at' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_endpoint_history` ADD COLUMN `endpoint_validated_at` DATETIME(6) NULL AFTER `public_port`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + +SET @network_tcp_natmap_v2_column_exists := ( + SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history' + AND COLUMN_NAME = 'endpoint_valid_until' +); +SET @network_tcp_natmap_v2_alter_sql := IF( + @network_tcp_natmap_v2_column_exists = 0, + 'ALTER TABLE `network_endpoint_history` ADD COLUMN `endpoint_valid_until` DATETIME(6) NULL AFTER `endpoint_validated_at`', + 'SELECT 1' +); +PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql; +EXECUTE network_tcp_natmap_v2_alter_stmt; +DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt; + INSERT INTO `network_port_forward_group` ( `id`, `name`, `remark`, `external_port`, `internal_port`, `protocol_mode`, `target_ipv4`, `is_deleted`, `create_time`, `update_time` ) SELECT channel.id, channel.name, channel.remark, channel.external_port, - channel.internal_port, 'udp', channel.target_ipv4, channel.is_deleted, + channel.internal_port, channel.protocol, channel.target_ipv4, channel.is_deleted, channel.create_time, channel.update_time FROM `network_port_forward` channel LEFT JOIN `network_port_forward_group` grouped ON grouped.id = channel.id -WHERE grouped.id IS NULL; +WHERE channel.group_id IS NULL + AND grouped.id IS NULL; UPDATE `network_port_forward` channel SET `group_id` = channel.id diff --git a/sql/refactor-v3/00-full-schema.sql b/sql/refactor-v3/00-full-schema.sql index 5494100..f9ff5c4 100644 --- a/sql/refactor-v3/00-full-schema.sql +++ b/sql/refactor-v3/00-full-schema.sql @@ -157,6 +157,8 @@ CREATE TABLE IF NOT EXISTS network_port_forward ( desired_revision BIGINT NOT NULL DEFAULT 0, desired_issued_at DATETIME(6) NOT NULL, reported_revision BIGINT NOT NULL DEFAULT 0, + last_reported_at DATETIME(6) NULL, + last_reported_at_wire VARCHAR(64) NULL, sync_status VARCHAR(16) NOT NULL DEFAULT 'pending', keeper_status VARCHAR(16) NOT NULL DEFAULT 'disabled', natmap_status VARCHAR(16) NOT NULL DEFAULT 'disabled', @@ -164,15 +166,19 @@ CREATE TABLE IF NOT EXISTS network_port_forward ( current_public_port INT NULL, current_observed_at DATETIME(6) NULL, current_validated_at DATETIME(6) NULL, + current_validated_at_wire VARCHAR(64) NULL, current_valid_until DATETIME(6) NULL, + current_endpoint_identity CHAR(64) NULL, last_observed_ipv4 VARCHAR(15) NULL, last_observed_port INT NULL, last_observed_at DATETIME(6) NULL, last_observed_validated_at DATETIME(6) NULL, + last_observed_validated_at_wire VARCHAR(64) NULL, candidate_public_ipv4 VARCHAR(15) NULL, candidate_public_port INT NULL, candidate_observed_at DATETIME(6) NULL, candidate_validated_at DATETIME(6) NULL, + candidate_validated_at_wire VARCHAR(64) NULL, last_published_public_ipv4 VARCHAR(15) NULL, last_published_public_port INT NULL, last_published_at DATETIME(6) NULL, @@ -232,16 +238,17 @@ CREATE TABLE IF NOT EXISTS network_agent_state ( max_supported_schema_version INT UNSIGNED NOT NULL DEFAULT 1, tcp_natmap_capable TINYINT(1) NOT NULL DEFAULT 0, applied_revision BIGINT NOT NULL DEFAULT 0, + applied_schema_version INT UNSIGNED NOT NULL DEFAULT 1, online TINYINT(1) NOT NULL DEFAULT 0, - version VARCHAR(64) NULL, + version VARCHAR(128) 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_mqtt_error_message VARCHAR(512) NULL, last_reconcile_error_code VARCHAR(64) NULL, - last_reconcile_error_message VARCHAR(500) NULL, + last_reconcile_error_message VARCHAR(512) NULL, create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; @@ -252,8 +259,12 @@ CREATE TABLE IF NOT EXISTS network_endpoint_history ( mapping_id BIGINT NOT NULL, event_type VARCHAR(16) NOT NULL, mechanism VARCHAR(16) NOT NULL DEFAULT 'udp_stun', + source_revision BIGINT NULL, + endpoint_identity CHAR(64) NULL, public_ipv4 VARCHAR(15) NULL, public_port INT NULL, + endpoint_validated_at DATETIME(6) NULL, + endpoint_valid_until DATETIME(6) NULL, first_observed_at DATETIME(6) NOT NULL, last_observed_at DATETIME(6) NOT NULL, occurred_at DATETIME(6) NOT NULL, diff --git a/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts b/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts index 98cb215..57eefc6 100644 --- a/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts +++ b/src/modules/admin/platform-config/network-management/network-agent-mqtt.service.ts @@ -7,7 +7,7 @@ import { type OnModuleInit, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; -import { DataSource } from 'typeorm'; +import { DataSource, type EntityManager } from 'typeorm'; import * as mqtt from 'mqtt'; import type { IClientOptions, MqttClient } from 'mqtt'; import { KtDateTime } from '@/common'; @@ -26,8 +26,17 @@ import { NetworkPortForwardGroup } from './network-port-forward-group.entity'; import { NetworkTcpReleasePolicyService } from './network-tcp-release-policy.service'; import { buildDesiredSnapshotV2, + compareNetworkV2Timestamps, + endpointLeaseIdentityV2, NetworkV2MessageValidationError, + parseEndpointEventV2, + parseReportedSnapshotV2, parseStatusSnapshotV2, + type NetworkDesiredChannelV2, + type NetworkEndpointEventV2, + type NetworkEndpointLeaseV2, + type NetworkReportedChannelV2, + type NetworkReportedSnapshotV2, type NetworkStatusSnapshotV2, } from './network-agent-v2.types'; import { @@ -205,12 +214,21 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { let changed = false; let ddnsSourceChanged = false; + let deliveryWake = false; let source: NetworkStateChangeSource; if (topic === this.topic(1, 'reported')) { source = 'reported'; const result = await this.applyReported(parseReportedSnapshot(parsed)); changed = result.visibleStateChanged; ddnsSourceChanged = result.ddnsSourceChanged; + } else if (topic === this.topic(2, 'reported')) { + source = 'reported'; + const result = await this.applyReportedV2( + parseReportedSnapshotV2(payload), + ); + changed = result.visibleStateChanged; + ddnsSourceChanged = result.ddnsSourceChanged; + deliveryWake = result.deliveryAccepted; } else if (topic === this.topic(1, 'status')) { source = 'status'; const result = await this.applyStatus(parseStatusSnapshot(parsed)); @@ -227,16 +245,24 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { parseEndpointEvent(parsed), ); changed = endpointResult.changed; - if (endpointResult.deliveryAccepted) { - try { - this.deliveryCoordinator.requestDrain(); - } catch { - this.logger.warn('System message delivery wake failed'); - } - } + deliveryWake = endpointResult.deliveryAccepted; + } else if (topic === this.topic(2, 'events')) { + source = 'events'; + const endpointResult = await this.appendEndpointEventV2( + parseEndpointEventV2(payload), + ); + changed = endpointResult.changed; + deliveryWake = endpointResult.deliveryAccepted; } else { throw new NetworkMessageValidationError('Unexpected network MQTT topic'); } + if (deliveryWake) { + try { + this.deliveryCoordinator.requestDrain(); + } catch { + this.logger.warn('System message delivery wake failed'); + } + } if (changed) this.eventStream.publishCommitted(source); if (ddnsSourceChanged) this.ddnsService?.requestReconcile(); } @@ -250,7 +276,9 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { [this.topic(1, 'reported')]: { qos: 1 }, [this.topic(1, 'status')]: { qos: 1 }, [this.topic(1, 'events')]: { qos: 1 }, + [this.topic(2, 'reported')]: { qos: 1 }, [this.topic(2, 'status')]: { qos: 1 }, + [this.topic(2, 'events')]: { qos: 1 }, }, (error) => { if (this.shuttingDown || this.client !== client) return; @@ -340,8 +368,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { }); if (!state) return; const confirmed = BigInt(revision); - const desiredSchemaVersion = - state.desiredSchemaVersion === 2 ? 2 : 1; + const desiredSchemaVersion = state.desiredSchemaVersion === 2 ? 2 : 1; if ( confirmed <= BigInt(state.desiredRevision) && desiredSchemaVersion === schemaVersion && @@ -373,6 +400,13 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { ) { throw new NetworkMessageValidationError('Invalid reported revision'); } + if (state.desiredSchemaVersion >= 2 || state.appliedSchemaVersion >= 2) { + return { + ddnsSourceChanged: false, + desiredChanged: false, + visibleStateChanged: false, + }; + } if (BigInt(report.appliedRevision) < BigInt(state.appliedRevision)) { return { ddnsSourceChanged: false, @@ -400,6 +434,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { desiredMappings.map((mapping) => [mapping.id, mapping]), ); const finalizedIds = new Set(); + const finalizedGroupIds = new Set(); let finalizedDeletion = false; for (const item of report.mappings) { const mapping = mappingById.get(item.id); @@ -480,8 +515,10 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { !item.currentEndpoint ) { mapping.activeKey = null; + mapping.activeGroupProtocolKey = null; mapping.isDeleted = true; finalizedDeletion = true; + finalizedGroupIds.add(mapping.groupId); } if ( this.reportedPersistedMappingStateFingerprint(mapping) !== @@ -497,9 +534,32 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { } } + for (const groupId of finalizedGroupIds) { + const groupChannels = await mappingRepository.find({ + where: { groupId }, + }); + if ( + groupChannels.length === 0 || + groupChannels.some((mapping) => !mapping.isDeleted) + ) { + continue; + } + const groupRepository = manager.getRepository(NetworkPortForwardGroup); + const group = await groupRepository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { id: groupId }, + }); + if (group && !group.isDeleted) { + group.isDeleted = true; + await groupRepository.save(group); + visibleStateChanged = true; + } + } + if (BigInt(report.appliedRevision) > BigInt(state.appliedRevision)) { state.appliedRevision = String(report.appliedRevision); } + state.appliedSchemaVersion = 1; const failedMapping = report.mappings.find( (item) => item.syncStatus === 'conflict' || item.syncStatus === 'failed', @@ -531,6 +591,652 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { }; } + private async applyReportedV2(report: NetworkReportedSnapshotV2): Promise<{ + ddnsSourceChanged: boolean; + deliveryAccepted: boolean; + visibleStateChanged: boolean; + }> { + this.assertAgentId(report.agentId); + const result = await this.dataSource.transaction(async (manager) => { + const stateRepository = manager.getRepository(NetworkAgentState); + const mappingRepository = manager.getRepository(NetworkPortForward); + const groupRepository = manager.getRepository(NetworkPortForwardGroup); + const state = await stateRepository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { agentId: report.agentId }, + }); + if (!state) { + throw new NetworkMessageValidationError('Unknown network Agent'); + } + if (state.desiredSchemaVersion !== 2) { + return { + ddnsSourceChanged: false, + deliveryAccepted: false, + desiredChanged: false, + visibleStateChanged: false, + }; + } + if (BigInt(report.snapshotRevision) > BigInt(state.desiredRevision)) { + throw new NetworkMessageValidationError( + 'Invalid v2 reported snapshot revision', + ); + } + if ( + BigInt(report.snapshotRevision) < BigInt(state.desiredRevision) || + BigInt(report.snapshotRevision) < BigInt(state.appliedRevision) + ) { + return { + ddnsSourceChanged: false, + deliveryAccepted: false, + desiredChanged: false, + visibleStateChanged: false, + }; + } + + const unlockedDesiredChannels = await mappingRepository.find({ + where: { isDeleted: false }, + }); + const desiredChannels: NetworkPortForward[] = []; + for (const channelId of unlockedDesiredChannels + .map((channel) => channel.id) + .sort()) { + const channel = await mappingRepository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { id: channelId }, + }); + if (!channel || channel.isDeleted) { + throw new NetworkMessageValidationError( + 'V2 desired channel changed during reported transaction', + ); + } + desiredChannels.push(channel); + } + const desiredSnapshot = buildDesiredSnapshotV2(state, desiredChannels); + if (desiredSnapshot.snapshotDigest !== report.snapshotDigest) { + throw new NetworkMessageValidationError( + 'V2 reported snapshot digest does not match current revision', + ); + } + const desiredById = new Map( + desiredSnapshot.channels.map((channel) => [channel.channelId, channel]), + ); + const mappingById = new Map( + desiredChannels.map((channel) => [channel.id, channel]), + ); + const reportById = new Map( + report.channels.map((channel) => [channel.channelId, channel]), + ); + const failures: Array<{ code: string; message: null | string }> = []; + const unknownReportedChannels = report.channels.filter( + (channel) => !desiredById.has(channel.channelId), + ); + if (unknownReportedChannels.length > 0) { + failures.push({ + code: 'reported_channel_unknown', + message: 'V2 reported snapshot contains an unknown channel', + }); + } + let ddnsSourceChanged = false; + let deliveryAccepted = false; + let finalizedDeletion = false; + let visibleStateChanged = false; + const finalizedGroupIds = new Set(); + const isSameAppliedSnapshot = + BigInt(report.snapshotRevision) === BigInt(state.appliedRevision); + + for (const desiredChannel of desiredSnapshot.channels) { + const mapping = mappingById.get(desiredChannel.channelId); + if (!mapping) continue; + const item = reportById.get(desiredChannel.channelId); + const persistedBefore = + this.v2ReportedPersistedMappingFingerprint(mapping); + const refreshBefore = this.v2ReportedRefreshMappingFingerprint(mapping); + const publicIpv4Before = mapping.currentPublicIpv4 || null; + const lastReportedAtWire = + mapping.lastReportedAtWire || + (mapping.lastReportedAt + ? new Date(mapping.lastReportedAt).toISOString() + : null); + if ( + isSameAppliedSnapshot && + lastReportedAtWire && + compareNetworkV2Timestamps(report.reportedAt, lastReportedAtWire) < 0 + ) { + continue; + } + mapping.lastReportedAt = new KtDateTime(report.reportedAt); + mapping.lastReportedAtWire = report.reportedAt; + if (!item) { + this.applyV2ReportedConflict( + mapping, + undefined, + report.reportedAt, + 'reported_channel_missing', + 'V2 reported snapshot omitted the desired channel', + ); + failures.push({ + code: 'reported_channel_missing', + message: 'V2 reported snapshot omitted the desired channel', + }); + } else { + const conflict = this.v2ReportedChannelConflict( + mapping, + desiredChannel, + item, + ); + if (conflict) { + this.applyV2ReportedConflict( + mapping, + item, + report.reportedAt, + conflict.code, + conflict.message, + ); + failures.push(conflict); + } else { + mapping.reportedRevision = String(item.appliedDesiredRevision); + mapping.syncStatus = item.syncStatus; + mapping.lastErrorCode = item.errorCode || null; + mapping.lastErrorMessage = item.errorMessage || null; + if (item.protocol === 'tcp') { + mapping.natmapStatus = item.natmapStatus; + mapping.natmapLastErrorCode = item.natmapErrorCode || null; + mapping.natmapLastErrorMessage = item.natmapErrorMessage || null; + this.applyV2CandidateEndpoint( + mapping, + item.candidateEndpoint, + report.reportedAt, + ); + } else { + mapping.keeperStatus = item.keeperStatus; + mapping.keeperLastErrorCode = item.keeperErrorCode || null; + mapping.keeperLastErrorMessage = item.keeperErrorMessage || null; + } + this.applyV2LastObservedEndpoint( + mapping, + item.lastObservedEndpoint, + ); + const currentTupleBefore = this.currentEndpointTuple(mapping); + const currentEndpoint = this.isV2CurrentPublishable( + desiredChannel, + item, + report.reportedAt, + ) + ? item.currentEndpoint + : undefined; + this.applyV2CurrentEndpoint( + mapping, + currentEndpoint, + report.reportedAt, + ); + this.updateLastPublishedBaseline(mapping); + const currentTupleAfter = this.currentEndpointTuple(mapping); + + if (this.isV2AbsenceConfirmed(item)) { + mapping.activeKey = null; + mapping.activeGroupProtocolKey = null; + mapping.isDeleted = true; + finalizedDeletion = true; + finalizedGroupIds.add(mapping.groupId); + } else if ( + item.syncStatus === 'conflict' || + item.syncStatus === 'failed' + ) { + failures.push({ + code: item.errorCode || `sync_${item.syncStatus}`, + message: item.errorMessage || null, + }); + } + + if ( + item.protocol === 'udp' && + currentTupleBefore !== currentTupleAfter && + currentTupleAfter + ) { + deliveryAccepted = + (await this.stageMatchingV2UdpHistory(manager, mapping)) || + deliveryAccepted; + } + } + } + if ((mapping.currentPublicIpv4 || null) !== publicIpv4Before) { + ddnsSourceChanged = true; + } + if ( + this.v2ReportedPersistedMappingFingerprint(mapping) !== + persistedBefore + ) { + await mappingRepository.save(mapping); + } + if ( + this.v2ReportedRefreshMappingFingerprint(mapping) !== refreshBefore + ) { + visibleStateChanged = true; + } + } + + for (const groupId of finalizedGroupIds) { + const groupChannels = await mappingRepository.find({ + where: { groupId }, + }); + if ( + groupChannels.length === 0 || + groupChannels.some((channel) => !channel.isDeleted) + ) { + continue; + } + const group = await groupRepository.findOne({ + lock: { mode: 'pessimistic_write' }, + where: { id: groupId }, + }); + if (group && !group.isDeleted) { + group.isDeleted = true; + await groupRepository.save(group); + visibleStateChanged = true; + } + } + + const stateBefore = this.reportedAgentStateFingerprint(state); + if (BigInt(report.snapshotRevision) > BigInt(state.appliedRevision)) { + state.appliedRevision = String(report.snapshotRevision); + } + state.appliedSchemaVersion = 2; + const persistedFailure = desiredChannels.find( + (channel) => + channel.syncStatus === 'conflict' || channel.syncStatus === 'failed', + ); + const firstFailure = + failures[0] || + (persistedFailure + ? { + code: + persistedFailure.lastErrorCode || + `sync_${persistedFailure.syncStatus}`, + message: persistedFailure.lastErrorMessage || null, + } + : undefined); + state.lastReconcileErrorCode = firstFailure?.code || null; + state.lastReconcileErrorMessage = firstFailure?.message || null; + if (finalizedDeletion) { + state.desiredRevision = (BigInt(state.desiredRevision) + 1n).toString(); + state.desiredIssuedAt = new KtDateTime(); + } + if (this.reportedAgentStateFingerprint(state) !== stateBefore) { + await stateRepository.save(state); + visibleStateChanged = true; + } + return { + ddnsSourceChanged, + deliveryAccepted, + desiredChanged: finalizedDeletion, + visibleStateChanged, + }; + }); + if (result.desiredChanged) this.requestDesiredPublish(); + return { + ddnsSourceChanged: result.ddnsSourceChanged, + deliveryAccepted: result.deliveryAccepted, + visibleStateChanged: result.visibleStateChanged, + }; + } + + private v2ReportedChannelConflict( + mapping: NetworkPortForward, + desired: NetworkDesiredChannelV2, + reported: NetworkReportedChannelV2, + ): { code: string; message: string } | null { + if (reported.appliedDesiredDigest !== desired.channelDesiredDigest) { + return { + code: 'desired_digest_conflict', + message: 'V2 reported channel desired digest does not match', + }; + } + if ( + reported.appliedDesiredRevision !== desired.channelDesiredRevision || + reported.groupId !== mapping.groupId || + reported.protocol !== mapping.protocol || + reported.desiredPresence !== mapping.desiredPresence + ) { + return { + code: 'reported_channel_identity_conflict', + message: 'V2 reported channel identity does not match desired state', + }; + } + if ( + (reported.protocol === 'tcp' && + (desired.protocol !== 'tcp' || + reported.natmapDesiredEnabled !== desired.natmapDesiredEnabled)) || + (reported.protocol === 'udp' && + (desired.protocol !== 'udp' || + reported.keeperDesiredEnabled !== desired.keeperDesiredEnabled)) + ) { + return { + code: 'reported_channel_intent_conflict', + message: 'V2 reported channel intent does not match desired state', + }; + } + return null; + } + + private applyV2ReportedConflict( + mapping: NetworkPortForward, + reported: NetworkReportedChannelV2 | undefined, + reportedAt: string, + code: string, + message: string, + ): void { + if (reported) { + mapping.reportedRevision = String(reported.appliedDesiredRevision); + } + mapping.syncStatus = 'conflict'; + mapping.lastErrorCode = code; + mapping.lastErrorMessage = message; + this.applyV2CurrentEndpoint(mapping, undefined, reportedAt); + } + + private applyV2CandidateEndpoint( + mapping: NetworkPortForward, + endpoint: NetworkEndpointLeaseV2 | undefined, + reportedAt: string, + ): void { + if (!endpoint) { + const candidateEvidenceTime = + mapping.candidateValidatedAtWire || + (mapping.candidateValidatedAt + ? new Date(mapping.candidateValidatedAt).toISOString() + : null); + if ( + candidateEvidenceTime && + compareNetworkV2Timestamps(reportedAt, candidateEvidenceTime) < 0 + ) { + return; + } + mapping.candidatePublicIpv4 = null; + mapping.candidatePublicPort = null; + mapping.candidateObservedAt = null; + mapping.candidateValidatedAt = null; + mapping.candidateValidatedAtWire = null; + return; + } + if ( + (mapping.candidateValidatedAtWire || mapping.candidateValidatedAt) && + compareNetworkV2Timestamps( + endpoint.validatedAt, + mapping.candidateValidatedAtWire || + new Date(mapping.candidateValidatedAt as KtDateTime).toISOString(), + ) < 0 + ) { + return; + } + const sameTuple = + mapping.candidatePublicIpv4 === endpoint.publicIpv4 && + mapping.candidatePublicPort === endpoint.publicPort; + mapping.candidatePublicIpv4 = endpoint.publicIpv4; + mapping.candidatePublicPort = endpoint.publicPort; + if (!sameTuple || !mapping.candidateObservedAt) { + mapping.candidateObservedAt = new KtDateTime(endpoint.observedAt); + } + mapping.candidateValidatedAt = new KtDateTime(endpoint.validatedAt); + mapping.candidateValidatedAtWire = endpoint.validatedAt; + } + + private applyV2LastObservedEndpoint( + mapping: NetworkPortForward, + endpoint: NetworkEndpointLeaseV2 | undefined, + ): void { + if (!endpoint) return; + if ( + (mapping.lastObservedValidatedAtWire || + mapping.lastObservedValidatedAt) && + compareNetworkV2Timestamps( + endpoint.validatedAt, + mapping.lastObservedValidatedAtWire || + new Date(mapping.lastObservedValidatedAt as KtDateTime).toISOString(), + ) < 0 + ) { + return; + } + const sameTuple = + mapping.lastObservedIpv4 === endpoint.publicIpv4 && + mapping.lastObservedPort === endpoint.publicPort; + mapping.lastObservedIpv4 = endpoint.publicIpv4; + mapping.lastObservedPort = endpoint.publicPort; + if (!sameTuple || !mapping.lastObservedAt) { + mapping.lastObservedAt = new KtDateTime(endpoint.observedAt); + } + mapping.lastObservedValidatedAt = new KtDateTime(endpoint.validatedAt); + mapping.lastObservedValidatedAtWire = endpoint.validatedAt; + } + + private applyV2CurrentEndpoint( + mapping: NetworkPortForward, + endpoint: NetworkEndpointLeaseV2 | undefined, + reportedAt: string, + ): void { + if (!endpoint) { + const currentEvidenceAt = + mapping.currentValidatedAtWire || + (mapping.currentValidatedAt + ? new Date(mapping.currentValidatedAt).toISOString() + : null); + if ( + !currentEvidenceAt || + compareNetworkV2Timestamps(reportedAt, currentEvidenceAt) >= 0 + ) { + this.withdrawV2CurrentEndpoint(mapping); + } + return; + } + if ( + (mapping.currentValidatedAtWire || mapping.currentValidatedAt) && + compareNetworkV2Timestamps( + endpoint.validatedAt, + mapping.currentValidatedAtWire || + new Date(mapping.currentValidatedAt as KtDateTime).toISOString(), + ) < 0 + ) { + return; + } + const sameTuple = + mapping.currentPublicIpv4 === endpoint.publicIpv4 && + mapping.currentPublicPort === endpoint.publicPort; + mapping.currentPublicIpv4 = endpoint.publicIpv4; + mapping.currentPublicPort = endpoint.publicPort; + if (!sameTuple || !mapping.currentObservedAt) { + mapping.currentObservedAt = new KtDateTime(endpoint.observedAt); + } + mapping.currentValidatedAt = new KtDateTime(endpoint.validatedAt); + mapping.currentValidatedAtWire = endpoint.validatedAt; + mapping.currentValidUntil = new KtDateTime(endpoint.validUntil); + mapping.currentEndpointIdentity = endpointLeaseIdentityV2(endpoint); + } + + private withdrawV2CurrentEndpoint(mapping: NetworkPortForward): void { + mapping.currentPublicIpv4 = null; + mapping.currentPublicPort = null; + mapping.currentObservedAt = null; + mapping.currentValidatedAt = null; + mapping.currentValidatedAtWire = null; + mapping.currentValidUntil = null; + mapping.currentEndpointIdentity = null; + } + + private updateLastPublishedBaseline(mapping: NetworkPortForward): void { + if (!mapping.currentPublicIpv4 || !mapping.currentPublicPort) return; + if ( + mapping.lastPublishedPublicIpv4 === mapping.currentPublicIpv4 && + mapping.lastPublishedPublicPort === mapping.currentPublicPort + ) { + return; + } + mapping.lastPublishedPublicIpv4 = mapping.currentPublicIpv4; + mapping.lastPublishedPublicPort = mapping.currentPublicPort; + mapping.lastPublishedAt = mapping.currentValidatedAt + ? new KtDateTime(mapping.currentValidatedAt) + : new KtDateTime(); + } + + private isV2CurrentPublishable( + desired: NetworkDesiredChannelV2, + reported: NetworkReportedChannelV2, + reportedAt: string, + ): reported is NetworkReportedChannelV2 & { + currentEndpoint: NetworkEndpointLeaseV2; + } { + const current = reported.currentEndpoint; + const lastObserved = reported.lastObservedEndpoint; + if ( + desired.desiredPresence !== 'present' || + reported.syncStatus !== 'synced' || + !reported.routerPresent || + !current || + !lastObserved || + !this.sameV2EndpointTuple(current, lastObserved) || + !this.isV2EndpointLeaseFresh(current, reportedAt) || + !this.isV2EndpointLeaseFresh(lastObserved, reportedAt) + ) { + return false; + } + if (reported.protocol === 'tcp') { + return ( + desired.protocol === 'tcp' && + desired.natmapDesiredEnabled && + reported.natmapDesiredEnabled && + reported.dnatPresent && + reported.natmapStatus === 'active' && + !!reported.instanceGeneration?.trim() && + !!reported.candidateEndpoint && + this.sameV2EndpointTuple(current, reported.candidateEndpoint) && + this.isV2EndpointLeaseFresh(reported.candidateEndpoint, reportedAt) + ); + } + return ( + desired.protocol === 'udp' && + desired.keeperDesiredEnabled && + reported.keeperDesiredEnabled && + reported.routePresent && + reported.keeperStatus === 'active' + ); + } + + private isV2EndpointLeaseFresh( + endpoint: NetworkEndpointLeaseV2, + reportedAt: string, + ): boolean { + return ( + compareNetworkV2Timestamps(endpoint.validatedAt, reportedAt) <= 0 && + compareNetworkV2Timestamps(endpoint.validUntil, endpoint.validatedAt) > + 0 && + compareNetworkV2Timestamps(endpoint.validUntil, reportedAt) > 0 && + new Date(endpoint.validUntil).getTime() > Date.now() + ); + } + + private sameV2EndpointTuple( + left: NetworkEndpointLeaseV2, + right: NetworkEndpointLeaseV2, + ): boolean { + return ( + left.mechanism === right.mechanism && + left.publicIpv4 === right.publicIpv4 && + left.publicPort === right.publicPort + ); + } + + private currentEndpointTuple(mapping: NetworkPortForward): string { + return mapping.currentPublicIpv4 && mapping.currentPublicPort + ? `${mapping.currentPublicIpv4}:${mapping.currentPublicPort}` + : ''; + } + + private isV2AbsenceConfirmed(reported: NetworkReportedChannelV2): boolean { + if ( + reported.desiredPresence !== 'absent' || + reported.syncStatus !== 'synced' || + reported.routerPresent || + reported.currentEndpoint + ) { + return false; + } + if (reported.protocol === 'tcp') { + return ( + !reported.dnatPresent && + !reported.natmapDesiredEnabled && + reported.natmapStatus === 'disabled' && + !reported.candidateEndpoint + ); + } + return ( + !reported.routePresent && + !reported.keeperDesiredEnabled && + reported.keeperStatus === 'disabled' + ); + } + + private async stageMatchingV2UdpHistory( + manager: EntityManager, + mapping: NetworkPortForward, + ): Promise { + const histories = await manager.getRepository(NetworkEndpointHistory).find({ + order: { occurredAt: 'DESC', id: 'DESC' }, + take: 2, + where: { mappingId: mapping.id, mechanism: 'udp_stun' }, + }); + return await this.stageV2UdpPortChange( + manager, + mapping, + histories[0], + histories[1], + ); + } + + private async stageV2UdpPortChange( + manager: EntityManager, + mapping: NetworkPortForward, + currentHistory: NetworkEndpointHistory | undefined, + previousHistory: NetworkEndpointHistory | undefined, + ): Promise { + if ( + !currentHistory || + currentHistory.eventType !== 'changed' || + previousHistory?.eventType === 'withdrawn' || + !this.isV2HistoryMatchingCurrent(mapping, currentHistory) || + !this.isValidPort(previousHistory?.publicPort) || + !this.isValidPort(currentHistory.publicPort) || + previousHistory.publicPort === currentHistory.publicPort + ) { + return false; + } + return ( + (await this.eventStager.stage(manager, { + eventId: currentHistory.eventId, + occurredAt: new Date(currentHistory.occurredAt).toISOString(), + payload: { + changedAt: new Date(currentHistory.occurredAt).toISOString(), + currentPort: currentHistory.publicPort, + portForwardId: mapping.id, + previousPort: previousHistory.publicPort, + publicIpv4: currentHistory.publicIpv4, + }, + resourceKey: mapping.id, + sourceKey: 'network.stun.mapping-port-changed', + })) === 'accepted' + ); + } + + private isV2HistoryMatchingCurrent( + mapping: NetworkPortForward, + history: NetworkEndpointHistory, + ): boolean { + return ( + history.sourceRevision === mapping.reportedRevision && + history.publicIpv4 === mapping.currentPublicIpv4 && + history.publicPort === mapping.currentPublicPort && + !!history.endpointIdentity && + history.endpointIdentity === mapping.currentEndpointIdentity + ); + } + private applyReportedEndpoints( mapping: NetworkPortForward, endpoint: NetworkReportedSnapshot['mappings'][number]['currentEndpoint'], @@ -635,10 +1341,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { if (!state) { throw new NetworkMessageValidationError('Unknown network Agent'); } - if ( - ignoreAfterV2Capability && - state.maxSupportedSchemaVersion >= 2 - ) { + if (ignoreAfterV2Capability && state.maxSupportedSchemaVersion >= 2) { return { ddnsSourceChanged: false, visibleStateChanged: false }; } const observedAt = new Date(status.observedAt); @@ -698,46 +1401,8 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { }); } - async requestV2Downgrade(): Promise { - if (!this.tcpReleasePolicy().mayExplicitlyDowngradeToV1()) return false; - const changed = await this.dataSource.transaction(async (manager) => { - const stateRepository = manager.getRepository(NetworkAgentState); - const state = await stateRepository.findOne({ - lock: { mode: 'pessimistic_write' }, - where: { agentId: this.agentId() }, - }); - if ( - !state || - state.desiredSchemaVersion !== 2 || - state.publishedSchemaVersion !== 2 || - BigInt(state.publishedRevision) !== BigInt(state.desiredRevision) || - BigInt(state.appliedRevision) !== BigInt(state.desiredRevision) - ) { - return false; - } - const groups = await manager.getRepository(NetworkPortForwardGroup).find({ - lock: { mode: 'pessimistic_read' }, - where: { isDeleted: false }, - }); - if ( - groups.some( - (group) => - group.protocolMode === 'tcp' || group.protocolMode === 'tcp_udp', - ) - ) { - return false; - } - const tcpChannels = await manager.getRepository(NetworkPortForward).find({ - lock: { mode: 'pessimistic_read' }, - where: { protocol: 'tcp' }, - }); - if (tcpChannels.some((channel) => !channel.isDeleted)) return false; - state.desiredSchemaVersion = 1; - await stateRepository.save(state); - return true; - }); - if (changed) this.requestDesiredPublish(); - return changed; + requestV2Downgrade(): Promise { + return Promise.resolve(false); } private async appendEndpointEvent( @@ -746,11 +1411,15 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { this.assertAgentId(event.agentId); return await this.dataSource.transaction(async (manager) => { const state = await manager.getRepository(NetworkAgentState).findOne({ + lock: { mode: 'pessimistic_write' }, where: { agentId: event.agentId }, }); if (!state || BigInt(event.revision) > BigInt(state.desiredRevision)) { throw new NetworkMessageValidationError('Invalid event revision'); } + if (state.desiredSchemaVersion >= 2 || state.appliedSchemaVersion >= 2) { + return { changed: false, deliveryAccepted: false }; + } const mapping = await manager.getRepository(NetworkPortForward).findOne({ lock: { mode: 'pessimistic_write' }, where: { id: event.mappingId }, @@ -773,6 +1442,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { firstObservedAt: new KtDateTime(event.endpoint.observedAt), lastObservedAt: new KtDateTime(event.endpoint.observedAt), mappingId: event.mappingId, + mechanism: 'udp_stun', occurredAt: new KtDateTime(event.occurredAt), publicIpv4: event.endpoint.publicIpv4, publicPort: event.endpoint.publicPort, @@ -805,6 +1475,114 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { }); } + private async appendEndpointEventV2( + event: NetworkEndpointEventV2, + ): Promise<{ changed: boolean; deliveryAccepted: boolean }> { + this.assertAgentId(event.agentId); + return await this.dataSource.transaction(async (manager) => { + const state = await manager.getRepository(NetworkAgentState).findOne({ + lock: { mode: 'pessimistic_write' }, + where: { agentId: event.agentId }, + }); + if (!state) { + throw new NetworkMessageValidationError('Unknown network Agent'); + } + if (state.desiredSchemaVersion !== 2) { + return { changed: false, deliveryAccepted: false }; + } + const mapping = await manager.getRepository(NetworkPortForward).findOne({ + lock: { mode: 'pessimistic_write' }, + where: { id: event.channelId }, + }); + if ( + !mapping || + mapping.groupId !== event.groupId || + mapping.protocol !== event.protocol + ) { + throw new NetworkMessageValidationError( + 'V2 endpoint event channel identity does not match', + ); + } + if (BigInt(event.revision) > BigInt(mapping.desiredRevision)) { + throw new NetworkMessageValidationError( + 'Invalid v2 endpoint event revision', + ); + } + const repository = manager.getRepository(NetworkEndpointHistory); + if (await repository.findOne({ where: { eventId: event.eventId } })) { + return { changed: false, deliveryAccepted: false }; + } + const previousHistory = await repository.findOne({ + lock: { mode: 'pessimistic_read' }, + order: { occurredAt: 'DESC', id: 'DESC' }, + where: { + mappingId: event.channelId, + mechanism: event.mechanism, + }, + }); + const observedAt = event.endpoint?.observedAt || event.occurredAt; + const history = repository.create({ + endpointValidatedAt: event.endpoint + ? new KtDateTime(event.endpoint.validatedAt) + : null, + endpointValidUntil: event.endpoint + ? new KtDateTime(event.endpoint.validUntil) + : null, + endpointIdentity: event.endpoint + ? endpointLeaseIdentityV2(event.endpoint) + : null, + eventId: event.eventId, + eventType: event.type, + firstObservedAt: new KtDateTime(observedAt), + lastObservedAt: new KtDateTime(observedAt), + mappingId: event.channelId, + mechanism: event.mechanism, + occurredAt: new KtDateTime(event.occurredAt), + publicIpv4: event.endpoint?.publicIpv4 || null, + publicPort: event.endpoint?.publicPort || null, + reason: event.reason || null, + sourceRevision: String(event.revision), + }); + try { + await repository.save(history); + const deliveryAccepted = this.canStageV2UdpEventAgainstCurrent( + mapping, + event, + ) + ? await this.stageV2UdpPortChange( + manager, + mapping, + history, + previousHistory || undefined, + ) + : false; + return { changed: true, deliveryAccepted }; + } catch (error) { + if (!this.isDuplicateKeyError(error)) throw error; + return { changed: false, deliveryAccepted: false }; + } + }); + } + + private canStageV2UdpEventAgainstCurrent( + mapping: NetworkPortForward, + event: NetworkEndpointEventV2, + ): boolean { + return ( + event.protocol === 'udp' && + event.mechanism === 'udp_stun' && + event.type === 'changed' && + !!event.endpoint && + mapping.reportedRevision === String(event.revision) && + mapping.currentPublicIpv4 === event.endpoint.publicIpv4 && + mapping.currentPublicPort === event.endpoint.publicPort && + mapping.currentEndpointIdentity === + endpointLeaseIdentityV2(event.endpoint) && + this.isV2EndpointLeaseFresh(event.endpoint, event.occurredAt) && + new Date(mapping.currentValidUntil).getTime() > Date.now() + ); + } + private shouldStagePortChange( event: NetworkEndpointEvent, previousHistory: NetworkEndpointHistory | null, @@ -829,6 +1607,74 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { ); } + private v2ReportedPersistedMappingFingerprint( + mapping: NetworkPortForward, + ): string { + return JSON.stringify([ + mapping.activeGroupProtocolKey, + mapping.activeKey, + mapping.candidateObservedAt, + mapping.candidatePublicIpv4, + mapping.candidatePublicPort, + mapping.candidateValidatedAt, + mapping.candidateValidatedAtWire, + mapping.currentEndpointIdentity, + mapping.currentObservedAt, + mapping.currentPublicIpv4, + mapping.currentPublicPort, + mapping.currentValidatedAt, + mapping.currentValidatedAtWire, + mapping.currentValidUntil, + mapping.isDeleted, + mapping.keeperLastErrorCode, + mapping.keeperLastErrorMessage, + mapping.keeperStatus, + mapping.lastErrorCode, + mapping.lastErrorMessage, + mapping.lastObservedAt, + mapping.lastObservedIpv4, + mapping.lastObservedPort, + mapping.lastObservedValidatedAt, + mapping.lastObservedValidatedAtWire, + mapping.lastPublishedAt, + mapping.lastPublishedPublicIpv4, + mapping.lastPublishedPublicPort, + mapping.lastReportedAt, + mapping.lastReportedAtWire, + mapping.natmapLastErrorCode, + mapping.natmapLastErrorMessage, + mapping.natmapStatus, + mapping.reportedRevision, + mapping.syncStatus, + ]); + } + + private v2ReportedRefreshMappingFingerprint( + mapping: NetworkPortForward, + ): string { + return JSON.stringify([ + mapping.activeGroupProtocolKey, + mapping.activeKey, + mapping.candidatePublicIpv4, + mapping.candidatePublicPort, + mapping.currentPublicIpv4, + mapping.currentPublicPort, + mapping.isDeleted, + mapping.keeperLastErrorCode, + mapping.keeperLastErrorMessage, + mapping.keeperStatus, + mapping.lastErrorCode, + mapping.lastErrorMessage, + mapping.lastObservedIpv4, + mapping.lastObservedPort, + mapping.natmapLastErrorCode, + mapping.natmapLastErrorMessage, + mapping.natmapStatus, + mapping.reportedRevision, + mapping.syncStatus, + ]); + } + private reportedPersistedMappingStateFingerprint( mapping: NetworkPortForward, ): string { @@ -871,6 +1717,7 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { private reportedAgentStateFingerprint(state: NetworkAgentState): string { return JSON.stringify([ state.appliedRevision, + state.appliedSchemaVersion, state.desiredIssuedAt, state.desiredRevision, state.lastReconcileErrorCode, @@ -937,7 +1784,10 @@ export class NetworkAgentMqttService implements OnModuleInit, OnModuleDestroy { } private tcpReleasePolicy(): NetworkTcpReleasePolicyService { - return this.releasePolicy || new NetworkTcpReleasePolicyService(this.configService); + return ( + this.releasePolicy || + new NetworkTcpReleasePolicyService(this.configService) + ); } private topic( diff --git a/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts b/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts index 48be067..29091e5 100644 --- a/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts +++ b/src/modules/admin/platform-config/network-management/network-agent-state.entity.ts @@ -53,10 +53,18 @@ export class NetworkAgentState { @Column({ default: '0', name: 'applied_revision', type: 'bigint' }) appliedRevision: string; + @Column({ + default: 1, + name: 'applied_schema_version', + type: 'int', + unsigned: true, + }) + appliedSchemaVersion: number; + @Column({ default: false, type: 'boolean' }) online: boolean; - @Column({ length: 64, nullable: true }) + @Column({ length: 128, nullable: true }) version?: string | null; @KtDateTimeColumn({ name: 'started_at', nullable: true, type: 'datetime' }) @@ -83,13 +91,13 @@ export class NetworkAgentState { @Column({ length: 64, name: 'last_mqtt_error_code', nullable: true }) lastMqttErrorCode?: string | null; - @Column({ length: 500, name: 'last_mqtt_error_message', nullable: true }) + @Column({ length: 512, name: 'last_mqtt_error_message', nullable: true }) lastMqttErrorMessage?: string | null; @Column({ length: 64, name: 'last_reconcile_error_code', nullable: true }) lastReconcileErrorCode?: string | null; - @Column({ length: 500, name: 'last_reconcile_error_message', nullable: true }) + @Column({ length: 512, name: 'last_reconcile_error_message', nullable: true }) lastReconcileErrorMessage?: string | null; @KtCreateDateColumn({ name: 'create_time' }) diff --git a/src/modules/admin/platform-config/network-management/network-agent-v2.types.ts b/src/modules/admin/platform-config/network-management/network-agent-v2.types.ts index 9d6f039..df518c2 100644 --- a/src/modules/admin/platform-config/network-management/network-agent-v2.types.ts +++ b/src/modules/admin/platform-config/network-management/network-agent-v2.types.ts @@ -3,6 +3,7 @@ import { isIP } from 'node:net'; import { TextDecoder } from 'node:util'; import type { NetworkAgentState } from './network-agent-state.entity'; import type { NetworkPortForward } from './network-management.entity'; +import type { EndpointMechanism } from './network-management.types'; export const NETWORK_AGENT_V2_SCHEMA_VERSION = 2 as const; export const NETWORK_AGENT_V2_MAX_CHANNELS = 64; @@ -30,7 +31,7 @@ export type NetworkV2NatmapStatus = | 'stale' | 'starting' | 'stopping'; -export type NetworkV2EndpointMechanism = 'tcp_natmap' | 'udp_stun'; +export type NetworkV2EndpointMechanism = EndpointMechanism; export type NetworkV2EndpointEventType = | 'changed' | 'published' @@ -173,9 +174,36 @@ export class NetworkV2MessageValidationError extends Error {} const ID_PATTERN = /^\d{1,32}$/; const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const ERROR_CODE_PATTERN = /^[a-z0-9_]{1,64}$/; -const RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/; +const RFC3339_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/; type NetworkV2WirePayload = Buffer | string; +export function compareNetworkV2Timestamps( + left: string, + right: string, +): number { + const leftNanoseconds = rfc3339Nanoseconds(left); + const rightNanoseconds = rfc3339Nanoseconds(right); + return leftNanoseconds < rightNanoseconds + ? -1 + : leftNanoseconds > rightNanoseconds + ? 1 + : 0; +} + +export function endpointLeaseIdentityV2( + endpoint: NetworkEndpointLeaseV2, +): string { + return digest({ + mechanism: endpoint.mechanism, + observedAt: endpoint.observedAt, + publicIpv4: endpoint.publicIpv4, + publicPort: endpoint.publicPort, + validatedAt: endpoint.validatedAt, + validUntil: endpoint.validUntil, + }); +} + export function canonicalDesiredChannelDigestV2( channel: NetworkDesiredChannelV2, ): string { @@ -183,7 +211,10 @@ export function canonicalDesiredChannelDigestV2( } export function canonicalDesiredSnapshotDigestV2( - snapshot: Pick, + snapshot: Pick< + NetworkDesiredSnapshotV2, + 'agentId' | 'channels' | 'schemaVersion' + >, ): string { return digest({ schemaVersion: snapshot.schemaVersion, @@ -195,42 +226,48 @@ export function canonicalDesiredSnapshotDigestV2( } export function buildDesiredSnapshotV2( - state: Pick, + state: Pick< + NetworkAgentState, + 'agentId' | 'desiredIssuedAt' | 'desiredRevision' + >, channels: NetworkDesiredChannelSourceV2[], ): NetworkDesiredSnapshotV2 { - if (channels.length > NETWORK_AGENT_V2_MAX_CHANNELS) invalid('desired.channels'); - const desiredChannels = channels.map((channel) => { - const base = { - channelDesiredDigest: '', - channelDesiredRevision: safeRevisionFromString( - channel.desiredRevision, - 'channelDesiredRevision', - ), - channelId: String(channel.id), - desiredPresence: channel.desiredPresence, - externalPort: channel.externalPort, - groupId: String(channel.groupId), - internalPort: channel.internalPort, - name: channel.name, - }; - const desired: NetworkDesiredChannelV2 = - channel.protocol === 'tcp' - ? { - ...base, - natmapDesiredEnabled: channel.natmapDesiredEnabled, - protocol: 'tcp', - } - : { - ...base, - keeperDesiredEnabled: channel.keeperDesiredEnabled, - ...(channel.probeRequestId - ? { probeRequestId: channel.probeRequestId } - : {}), - protocol: 'udp', - }; - desired.channelDesiredDigest = canonicalDesiredChannelDigestV2(desired); - return desired; - }).sort(compareDesiredChannelsV2); + if (channels.length > NETWORK_AGENT_V2_MAX_CHANNELS) + invalid('desired.channels'); + const desiredChannels = channels + .map((channel) => { + const base = { + channelDesiredDigest: '', + channelDesiredRevision: safeRevisionFromString( + channel.desiredRevision, + 'channelDesiredRevision', + ), + channelId: String(channel.id), + desiredPresence: channel.desiredPresence, + externalPort: channel.externalPort, + groupId: String(channel.groupId), + internalPort: channel.internalPort, + name: channel.name, + }; + const desired: NetworkDesiredChannelV2 = + channel.protocol === 'tcp' + ? { + ...base, + natmapDesiredEnabled: channel.natmapDesiredEnabled, + protocol: 'tcp', + } + : { + ...base, + keeperDesiredEnabled: channel.keeperDesiredEnabled, + ...(channel.probeRequestId + ? { probeRequestId: channel.probeRequestId } + : {}), + protocol: 'udp', + }; + desired.channelDesiredDigest = canonicalDesiredChannelDigestV2(desired); + return desired; + }) + .sort(compareDesiredChannelsV2); if ( new Set(desiredChannels.map((channel) => channel.channelId)).size !== desiredChannels.length @@ -261,23 +298,42 @@ export function parseDesiredSnapshotV2( function validateDesiredSnapshotV2(value: unknown): NetworkDesiredSnapshotV2 { const record = exactRecord( value, - ['agentId', 'channels', 'issuedAt', 'schemaVersion', 'snapshotDigest', 'snapshotRevision'], + [ + 'agentId', + 'channels', + 'issuedAt', + 'schemaVersion', + 'snapshotDigest', + 'snapshotRevision', + ], [], 'desired', ); assertSchema(record.schemaVersion); - if (!Array.isArray(record.channels) || record.channels.length > NETWORK_AGENT_V2_MAX_CHANNELS) invalid('desired.channels'); + if ( + !Array.isArray(record.channels) || + record.channels.length > NETWORK_AGENT_V2_MAX_CHANNELS + ) + invalid('desired.channels'); const channels = record.channels.map(parseDesiredChannelV2); - if (new Set(channels.map((channel) => channel.channelId)).size !== channels.length) invalid('desired duplicate channel'); + if ( + new Set(channels.map((channel) => channel.channelId)).size !== + channels.length + ) + invalid('desired duplicate channel'); const snapshot: NetworkDesiredSnapshotV2 = { agentId: boundedString(record.agentId, 'agentId', 64), channels, issuedAt: isoString(record.issuedAt, 'issuedAt'), schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION, snapshotDigest: digestValue(record.snapshotDigest, 'snapshotDigest'), - snapshotRevision: positiveRevision(record.snapshotRevision, 'snapshotRevision'), + snapshotRevision: positiveRevision( + record.snapshotRevision, + 'snapshotRevision', + ), }; - if (canonicalDesiredSnapshotDigestV2(snapshot) !== snapshot.snapshotDigest) invalid('snapshotDigest'); + if (canonicalDesiredSnapshotDigestV2(snapshot) !== snapshot.snapshotDigest) + invalid('snapshotDigest'); return snapshot; } @@ -288,18 +344,41 @@ export function parseReportedSnapshotV2( } function validateReportedSnapshotV2(value: unknown): NetworkReportedSnapshotV2 { - const record = exactRecord(value, ['agentId', 'channels', 'reportedAt', 'schemaVersion', 'snapshotDigest', 'snapshotRevision'], [], 'reported'); + const record = exactRecord( + value, + [ + 'agentId', + 'channels', + 'reportedAt', + 'schemaVersion', + 'snapshotDigest', + 'snapshotRevision', + ], + [], + 'reported', + ); assertSchema(record.schemaVersion); - if (!Array.isArray(record.channels) || record.channels.length > NETWORK_AGENT_V2_MAX_CHANNELS) invalid('reported.channels'); + if ( + !Array.isArray(record.channels) || + record.channels.length > NETWORK_AGENT_V2_MAX_CHANNELS + ) + invalid('reported.channels'); const channels = record.channels.map(parseReportedChannelV2); - if (new Set(channels.map((channel) => channel.channelId)).size !== channels.length) invalid('reported duplicate channel'); + if ( + new Set(channels.map((channel) => channel.channelId)).size !== + channels.length + ) + invalid('reported duplicate channel'); return { agentId: boundedString(record.agentId, 'agentId', 64), channels, reportedAt: isoString(record.reportedAt, 'reportedAt'), schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION, snapshotDigest: digestValue(record.snapshotDigest, 'snapshotDigest'), - snapshotRevision: positiveRevision(record.snapshotRevision, 'snapshotRevision'), + snapshotRevision: positiveRevision( + record.snapshotRevision, + 'snapshotRevision', + ), }; } @@ -310,21 +389,51 @@ export function parseStatusSnapshotV2( } function validateStatusSnapshotV2(value: unknown): NetworkStatusSnapshotV2 { - const record = exactRecord(value, ['agentId', 'observedAt', 'online', 'schemaVersion', 'supportedSchemaVersions', 'tcpNatmapCapable'], ['errorCode', 'errorMessage', 'publicIpv6', 'startedAt', 'version'], 'status'); + const record = exactRecord( + value, + [ + 'agentId', + 'observedAt', + 'online', + 'schemaVersion', + 'supportedSchemaVersions', + 'tcpNatmapCapable', + ], + ['errorCode', 'errorMessage', 'publicIpv6', 'startedAt', 'version'], + 'status', + ); assertSchema(record.schemaVersion); - if (!Array.isArray(record.supportedSchemaVersions) || record.supportedSchemaVersions.length !== 2 || record.supportedSchemaVersions[0] !== 1 || record.supportedSchemaVersions[1] !== 2) invalid('supportedSchemaVersions'); + if ( + !Array.isArray(record.supportedSchemaVersions) || + record.supportedSchemaVersions.length !== 2 || + record.supportedSchemaVersions[0] !== 1 || + record.supportedSchemaVersions[1] !== 2 + ) + invalid('supportedSchemaVersions'); return { agentId: boundedString(record.agentId, 'agentId', 64), - ...(record.errorCode === undefined ? {} : { errorCode: errorCode(record.errorCode, 'errorCode') }), - ...(record.errorMessage === undefined ? {} : { errorMessage: boundedString(record.errorMessage, 'errorMessage', 512) }), + ...(record.errorCode === undefined + ? {} + : { errorCode: errorCode(record.errorCode, 'errorCode') }), + ...(record.errorMessage === undefined + ? {} + : { + errorMessage: boundedString(record.errorMessage, 'errorMessage', 512), + }), observedAt: isoString(record.observedAt, 'observedAt'), online: booleanValue(record.online, 'online'), - ...(record.publicIpv6 === undefined ? {} : { publicIpv6: publicIpv6(record.publicIpv6) }), + ...(record.publicIpv6 === undefined + ? {} + : { publicIpv6: publicIpv6(record.publicIpv6) }), schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION, - ...(record.startedAt === undefined ? {} : { startedAt: isoString(record.startedAt, 'startedAt') }), + ...(record.startedAt === undefined + ? {} + : { startedAt: isoString(record.startedAt, 'startedAt') }), supportedSchemaVersions: [1, 2], tcpNatmapCapable: booleanValue(record.tcpNatmapCapable, 'tcpNatmapCapable'), - ...(record.version === undefined ? {} : { version: boundedString(record.version, 'version', 128) }), + ...(record.version === undefined + ? {} + : { version: boundedString(record.version, 'version', 128) }), }; } @@ -335,14 +444,47 @@ export function parseEndpointEventV2( } function validateEndpointEventV2(value: unknown): NetworkEndpointEventV2 { - const record = exactRecord(value, ['agentId', 'channelId', 'eventId', 'groupId', 'mechanism', 'occurredAt', 'protocol', 'revision', 'schemaVersion', 'type'], ['endpoint', 'reason'], 'event'); + const record = exactRecord( + value, + [ + 'agentId', + 'channelId', + 'eventId', + 'groupId', + 'mechanism', + 'occurredAt', + 'protocol', + 'revision', + 'schemaVersion', + 'type', + ], + ['endpoint', 'reason'], + 'event', + ); assertSchema(record.schemaVersion); - const protocol = enumValue(record.protocol, ['tcp', 'udp'] as const, 'protocol'); - const mechanism = enumValue(record.mechanism, ['tcp_natmap', 'udp_stun'] as const, 'mechanism'); - const type = enumValue(record.type, ['changed', 'published', 'restored', 'withdrawn'] as const, 'type'); - if ((protocol === 'tcp') !== (mechanism === 'tcp_natmap')) invalid('event mechanism'); - const endpoint = record.endpoint === undefined ? undefined : parseEndpointV2(record.endpoint, mechanism); - if ((type === 'withdrawn') !== (endpoint === undefined)) invalid('event endpoint'); + const protocol = enumValue( + record.protocol, + ['tcp', 'udp'] as const, + 'protocol', + ); + const mechanism = enumValue( + record.mechanism, + ['tcp_natmap', 'udp_stun'] as const, + 'mechanism', + ); + const type = enumValue( + record.type, + ['changed', 'published', 'restored', 'withdrawn'] as const, + 'type', + ); + if ((protocol === 'tcp') !== (mechanism === 'tcp_natmap')) + invalid('event mechanism'); + const endpoint = + record.endpoint === undefined + ? undefined + : parseEndpointV2(record.endpoint, mechanism); + if ((type === 'withdrawn') !== (endpoint === undefined)) + invalid('event endpoint'); return { agentId: boundedString(record.agentId, 'agentId', 64), channelId: identifier(record.channelId, 'channelId'), @@ -352,7 +494,9 @@ function validateEndpointEventV2(value: unknown): NetworkEndpointEventV2 { mechanism, occurredAt: isoString(record.occurredAt, 'occurredAt'), protocol, - ...(record.reason === undefined ? {} : { reason: boundedString(record.reason, 'reason', 128) }), + ...(record.reason === undefined + ? {} + : { reason: boundedString(record.reason, 'reason', 128) }), revision: positiveRevision(record.revision, 'revision'), schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION, type, @@ -361,78 +505,547 @@ function validateEndpointEventV2(value: unknown): NetworkEndpointEventV2 { function parseDesiredChannelV2(value: unknown): NetworkDesiredChannelV2 { const protocol = protocolOf(value, 'desired channel'); - const required = ['channelDesiredDigest', 'channelDesiredRevision', 'channelId', 'desiredPresence', 'externalPort', 'groupId', 'internalPort', 'name', 'protocol']; - const record = exactRecord(value, protocol === 'tcp' ? [...required, 'natmapDesiredEnabled'] : [...required, 'keeperDesiredEnabled'], protocol === 'udp' ? ['probeRequestId'] : [], 'desired channel'); + const required = [ + 'channelDesiredDigest', + 'channelDesiredRevision', + 'channelId', + 'desiredPresence', + 'externalPort', + 'groupId', + 'internalPort', + 'name', + 'protocol', + ]; + const record = exactRecord( + value, + protocol === 'tcp' + ? [...required, 'natmapDesiredEnabled'] + : [...required, 'keeperDesiredEnabled'], + protocol === 'udp' ? ['probeRequestId'] : [], + 'desired channel', + ); const base = desiredBase(record); - const channel = protocol === 'tcp' - ? { ...base, natmapDesiredEnabled: booleanValue(record.natmapDesiredEnabled, 'natmapDesiredEnabled'), protocol } - : { ...base, keeperDesiredEnabled: booleanValue(record.keeperDesiredEnabled, 'keeperDesiredEnabled'), ...(record.probeRequestId === undefined ? {} : { probeRequestId: requestId(record.probeRequestId, 'probeRequestId') }), protocol }; - if (canonicalDesiredChannelDigestV2(channel) !== channel.channelDesiredDigest) invalid('channelDesiredDigest'); + const channel = + protocol === 'tcp' + ? { + ...base, + natmapDesiredEnabled: booleanValue( + record.natmapDesiredEnabled, + 'natmapDesiredEnabled', + ), + protocol, + } + : { + ...base, + keeperDesiredEnabled: booleanValue( + record.keeperDesiredEnabled, + 'keeperDesiredEnabled', + ), + ...(record.probeRequestId === undefined + ? {} + : { + probeRequestId: requestId( + record.probeRequestId, + 'probeRequestId', + ), + }), + protocol, + }; + if (canonicalDesiredChannelDigestV2(channel) !== channel.channelDesiredDigest) + invalid('channelDesiredDigest'); return channel; } function parseReportedChannelV2(value: unknown): NetworkReportedChannelV2 { const protocol = protocolOf(value, 'reported channel'); - const common = ['appliedDesiredDigest', 'appliedDesiredRevision', 'channelId', 'desiredPresence', 'groupId', 'protocol', 'routerPresent', 'syncStatus']; - const record = exactRecord(value, protocol === 'tcp' ? [...common, 'dnatPresent', 'natmapDesiredEnabled', 'natmapStatus'] : [...common, 'keeperDesiredEnabled', 'keeperStatus', 'routePresent'], protocol === 'tcp' ? ['candidateEndpoint', 'currentEndpoint', 'errorCode', 'errorMessage', 'instanceGeneration', 'lastObservedEndpoint', 'natmapErrorCode', 'natmapErrorMessage'] : ['currentEndpoint', 'errorCode', 'errorMessage', 'keeperErrorCode', 'keeperErrorMessage', 'lastObservedEndpoint', 'lastProbeRequestId'], 'reported channel'); + const common = [ + 'appliedDesiredDigest', + 'appliedDesiredRevision', + 'channelId', + 'desiredPresence', + 'groupId', + 'protocol', + 'routerPresent', + 'syncStatus', + ]; + const record = exactRecord( + value, + protocol === 'tcp' + ? [...common, 'dnatPresent', 'natmapDesiredEnabled', 'natmapStatus'] + : [...common, 'keeperDesiredEnabled', 'keeperStatus', 'routePresent'], + protocol === 'tcp' + ? [ + 'candidateEndpoint', + 'currentEndpoint', + 'errorCode', + 'errorMessage', + 'instanceGeneration', + 'lastObservedEndpoint', + 'natmapErrorCode', + 'natmapErrorMessage', + ] + : [ + 'currentEndpoint', + 'errorCode', + 'errorMessage', + 'keeperErrorCode', + 'keeperErrorMessage', + 'lastObservedEndpoint', + 'lastProbeRequestId', + ], + 'reported channel', + ); const base: ReportedChannelBaseV2 = { - appliedDesiredDigest: digestValue(record.appliedDesiredDigest, 'appliedDesiredDigest'), - appliedDesiredRevision: positiveRevision(record.appliedDesiredRevision, 'appliedDesiredRevision'), + appliedDesiredDigest: digestValue( + record.appliedDesiredDigest, + 'appliedDesiredDigest', + ), + appliedDesiredRevision: positiveRevision( + record.appliedDesiredRevision, + 'appliedDesiredRevision', + ), channelId: identifier(record.channelId, 'channelId'), - desiredPresence: enumValue(record.desiredPresence, ['absent', 'present'] as const, 'desiredPresence'), - ...(record.errorCode === undefined ? {} : { errorCode: errorCode(record.errorCode, 'errorCode') }), - ...(record.errorMessage === undefined ? {} : { errorMessage: boundedString(record.errorMessage, 'errorMessage', 512) }), - groupId: identifier(record.groupId, 'groupId'), protocol, + desiredPresence: enumValue( + record.desiredPresence, + ['absent', 'present'] as const, + 'desiredPresence', + ), + ...(record.errorCode === undefined + ? {} + : { errorCode: errorCode(record.errorCode, 'errorCode') }), + ...(record.errorMessage === undefined + ? {} + : { + errorMessage: boundedString(record.errorMessage, 'errorMessage', 512), + }), + groupId: identifier(record.groupId, 'groupId'), + protocol, routerPresent: booleanValue(record.routerPresent, 'routerPresent'), - syncStatus: enumValue(record.syncStatus, ['conflict', 'deleting', 'failed', 'pending', 'synced', 'syncing'] as const, 'syncStatus'), + syncStatus: enumValue( + record.syncStatus, + [ + 'conflict', + 'deleting', + 'failed', + 'pending', + 'synced', + 'syncing', + ] as const, + 'syncStatus', + ), + }; + if (protocol === 'tcp') + return { + ...base, + candidateEndpoint: optionalEndpoint( + record.candidateEndpoint, + 'tcp_natmap', + ), + currentEndpoint: optionalEndpoint(record.currentEndpoint, 'tcp_natmap'), + dnatPresent: booleanValue(record.dnatPresent, 'dnatPresent'), + instanceGeneration: optionalBounded( + record.instanceGeneration, + 'instanceGeneration', + 128, + ), + lastObservedEndpoint: optionalEndpoint( + record.lastObservedEndpoint, + 'tcp_natmap', + ), + natmapDesiredEnabled: booleanValue( + record.natmapDesiredEnabled, + 'natmapDesiredEnabled', + ), + natmapErrorCode: optionalErrorCode( + record.natmapErrorCode, + 'natmapErrorCode', + ), + natmapErrorMessage: optionalBounded( + record.natmapErrorMessage, + 'natmapErrorMessage', + 512, + ), + natmapStatus: enumValue( + record.natmapStatus, + [ + 'active', + 'disabled', + 'failed', + 'stale', + 'starting', + 'stopping', + ] as const, + 'natmapStatus', + ), + protocol, + }; + return { + ...base, + currentEndpoint: optionalEndpoint(record.currentEndpoint, 'udp_stun'), + keeperDesiredEnabled: booleanValue( + record.keeperDesiredEnabled, + 'keeperDesiredEnabled', + ), + keeperErrorCode: optionalErrorCode( + record.keeperErrorCode, + 'keeperErrorCode', + ), + keeperErrorMessage: optionalBounded( + record.keeperErrorMessage, + 'keeperErrorMessage', + 512, + ), + keeperStatus: enumValue( + record.keeperStatus, + ['active', 'disabled', 'failed', 'stale', 'starting'] as const, + 'keeperStatus', + ), + lastObservedEndpoint: optionalEndpoint( + record.lastObservedEndpoint, + 'udp_stun', + ), + lastProbeRequestId: + record.lastProbeRequestId === undefined + ? undefined + : requestId(record.lastProbeRequestId, 'lastProbeRequestId'), + protocol, + routePresent: booleanValue(record.routePresent, 'routePresent'), }; - if (protocol === 'tcp') return { ...base, candidateEndpoint: optionalEndpoint(record.candidateEndpoint, 'tcp_natmap'), currentEndpoint: optionalEndpoint(record.currentEndpoint, 'tcp_natmap'), dnatPresent: booleanValue(record.dnatPresent, 'dnatPresent'), instanceGeneration: optionalBounded(record.instanceGeneration, 'instanceGeneration', 128), lastObservedEndpoint: optionalEndpoint(record.lastObservedEndpoint, 'tcp_natmap'), natmapDesiredEnabled: booleanValue(record.natmapDesiredEnabled, 'natmapDesiredEnabled'), natmapErrorCode: optionalErrorCode(record.natmapErrorCode, 'natmapErrorCode'), natmapErrorMessage: optionalBounded(record.natmapErrorMessage, 'natmapErrorMessage', 512), natmapStatus: enumValue(record.natmapStatus, ['active', 'disabled', 'failed', 'stale', 'starting', 'stopping'] as const, 'natmapStatus'), protocol }; - return { ...base, currentEndpoint: optionalEndpoint(record.currentEndpoint, 'udp_stun'), keeperDesiredEnabled: booleanValue(record.keeperDesiredEnabled, 'keeperDesiredEnabled'), keeperErrorCode: optionalErrorCode(record.keeperErrorCode, 'keeperErrorCode'), keeperErrorMessage: optionalBounded(record.keeperErrorMessage, 'keeperErrorMessage', 512), keeperStatus: enumValue(record.keeperStatus, ['active', 'disabled', 'failed', 'stale', 'starting'] as const, 'keeperStatus'), lastObservedEndpoint: optionalEndpoint(record.lastObservedEndpoint, 'udp_stun'), lastProbeRequestId: record.lastProbeRequestId === undefined ? undefined : requestId(record.lastProbeRequestId, 'lastProbeRequestId'), protocol, routePresent: booleanValue(record.routePresent, 'routePresent') }; } function desiredBase(record: Record): DesiredChannelBaseV2 { - return { channelDesiredDigest: digestValue(record.channelDesiredDigest, 'channelDesiredDigest'), channelDesiredRevision: positiveRevision(record.channelDesiredRevision, 'channelDesiredRevision'), channelId: identifier(record.channelId, 'channelId'), desiredPresence: enumValue(record.desiredPresence, ['absent', 'present'] as const, 'desiredPresence'), externalPort: port(record.externalPort, 'externalPort'), groupId: identifier(record.groupId, 'groupId'), internalPort: port(record.internalPort, 'internalPort'), name: boundedString(record.name, 'name', 128) }; + return { + channelDesiredDigest: digestValue( + record.channelDesiredDigest, + 'channelDesiredDigest', + ), + channelDesiredRevision: positiveRevision( + record.channelDesiredRevision, + 'channelDesiredRevision', + ), + channelId: identifier(record.channelId, 'channelId'), + desiredPresence: enumValue( + record.desiredPresence, + ['absent', 'present'] as const, + 'desiredPresence', + ), + externalPort: port(record.externalPort, 'externalPort'), + groupId: identifier(record.groupId, 'groupId'), + internalPort: port(record.internalPort, 'internalPort'), + name: boundedString(record.name, 'name', 128), + }; } -function parseEndpointV2(value: unknown, expectedMechanism: NetworkV2EndpointMechanism): NetworkEndpointLeaseV2 { - const record = exactRecord(value, ['mechanism', 'observedAt', 'publicIpv4', 'publicPort', 'validatedAt', 'validUntil'], [], 'endpoint'); - const mechanism = enumValue(record.mechanism, ['tcp_natmap', 'udp_stun'] as const, 'mechanism'); +function parseEndpointV2( + value: unknown, + expectedMechanism: NetworkV2EndpointMechanism, +): NetworkEndpointLeaseV2 { + const record = exactRecord( + value, + [ + 'mechanism', + 'observedAt', + 'publicIpv4', + 'publicPort', + 'validatedAt', + 'validUntil', + ], + [], + 'endpoint', + ); + const mechanism = enumValue( + record.mechanism, + ['tcp_natmap', 'udp_stun'] as const, + 'mechanism', + ); if (mechanism !== expectedMechanism) invalid('endpoint mechanism'); const observedAt = isoString(record.observedAt, 'observedAt'); const validatedAt = isoString(record.validatedAt, 'validatedAt'); const validUntil = isoString(record.validUntil, 'validUntil'); - if (Date.parse(validUntil) <= Date.parse(observedAt)) invalid('endpoint validity'); - return { mechanism, observedAt, publicIpv4: publicIpv4(record.publicIpv4), publicPort: port(record.publicPort, 'publicPort'), validatedAt, validUntil }; + if (compareNetworkV2Timestamps(validUntil, observedAt) <= 0) + invalid('endpoint validity'); + return { + mechanism, + observedAt, + publicIpv4: publicIpv4(record.publicIpv4), + publicPort: port(record.publicPort, 'publicPort'), + validatedAt, + validUntil, + }; } function canonicalDesiredChannelV2(channel: NetworkDesiredChannelV2): object { - if (channel.protocol === 'tcp') return { channelId: channel.channelId, desiredPresence: channel.desiredPresence, externalPort: channel.externalPort, groupId: channel.groupId, internalPort: channel.internalPort, name: channel.name, natmapDesiredEnabled: channel.natmapDesiredEnabled, protocol: channel.protocol }; - return { channelId: channel.channelId, desiredPresence: channel.desiredPresence, externalPort: channel.externalPort, groupId: channel.groupId, internalPort: channel.internalPort, keeperDesiredEnabled: channel.keeperDesiredEnabled, name: channel.name, ...(channel.probeRequestId === undefined ? {} : { probeRequestId: channel.probeRequestId }), protocol: channel.protocol }; + if (channel.protocol === 'tcp') + return { + channelId: channel.channelId, + desiredPresence: channel.desiredPresence, + externalPort: channel.externalPort, + groupId: channel.groupId, + internalPort: channel.internalPort, + name: channel.name, + natmapDesiredEnabled: channel.natmapDesiredEnabled, + protocol: channel.protocol, + }; + return { + channelId: channel.channelId, + desiredPresence: channel.desiredPresence, + externalPort: channel.externalPort, + groupId: channel.groupId, + internalPort: channel.internalPort, + keeperDesiredEnabled: channel.keeperDesiredEnabled, + name: channel.name, + ...(channel.probeRequestId === undefined + ? {} + : { probeRequestId: channel.probeRequestId }), + protocol: channel.protocol, + }; } -function compareDesiredChannelsV2(left: NetworkDesiredChannelV2, right: NetworkDesiredChannelV2): number { return (left.channelId < right.channelId ? -1 : left.channelId > right.channelId ? 1 : 0) || (left.protocol < right.protocol ? -1 : left.protocol > right.protocol ? 1 : 0) || left.externalPort - right.externalPort; } -function parseWirePayloadV2(payload: NetworkV2WirePayload): unknown { let text: string; if (typeof payload === 'string') { if (Buffer.byteLength(payload, 'utf8') > NETWORK_AGENT_V2_MAX_MESSAGE_BYTES) invalid('message size'); text = payload; } else if (Buffer.isBuffer(payload)) { if (payload.length > NETWORK_AGENT_V2_MAX_MESSAGE_BYTES) invalid('message size'); try { text = new TextDecoder('utf-8', { fatal: true }).decode(payload); } catch { invalid('message UTF-8'); } } else { invalid('message payload'); } try { return JSON.parse(text); } catch { invalid('message JSON'); } } -function optionalEndpoint(value: unknown, mechanism: NetworkV2EndpointMechanism): NetworkEndpointLeaseV2 | undefined { return value === undefined ? undefined : parseEndpointV2(value, mechanism); } -function optionalBounded(value: unknown, name: string, max: number): string | undefined { return value === undefined ? undefined : boundedString(value, name, max); } -function optionalErrorCode(value: unknown, name: string): string | undefined { return value === undefined ? undefined : errorCode(value, name); } -function protocolOf(value: unknown, name: string): NetworkV2Protocol { if (typeof value !== 'object' || value === null || Array.isArray(value)) invalid(name); return enumValue((value as Record).protocol, ['tcp', 'udp'] as const, 'protocol'); } -function exactRecord(value: unknown, required: string[], optional: string[], name: string, allowPartial = false): Record { if (typeof value !== 'object' || value === null || Array.isArray(value)) invalid(name); const record = value as Record; const allowed = new Set([...required, ...optional]); if (Object.keys(record).some((key) => !allowed.has(key)) || (!allowPartial && required.some((key) => !(key in record)))) invalid(name); return record; } -function assertSchema(value: unknown): void { if (value !== NETWORK_AGENT_V2_SCHEMA_VERSION) invalid('schemaVersion'); } -function identifier(value: unknown, name: string): string { const result = stringValue(value, name, 32); if (!ID_PATTERN.test(result)) invalid(name); return result; } -function requestId(value: unknown, name: string): string { const result = stringValue(value, name, 128); if (!REQUEST_ID_PATTERN.test(result)) invalid(name); return result; } -function digestValue(value: unknown, name: string): string { const result = stringValue(value, name, 64); if (!/^[0-9a-f]{64}$/.test(result)) invalid(name); return result; } -function errorCode(value: unknown, name: string): string { const result = stringValue(value, name, 64); if (!ERROR_CODE_PATTERN.test(result)) invalid(name); return result; } -function boundedString(value: unknown, name: string, max: number): string { const result = stringValue(value, name, max); if (result.length === 0) invalid(name); return result; } -function stringValue(value: unknown, name: string, max: number): string { if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > max) invalid(name); return value; } -function positiveRevision(value: unknown, name: string): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) invalid(name); return value; } -function safeRevisionFromString(value: string, name: string): number { if (!/^\d+$/.test(value)) invalid(name); const parsed = BigInt(value); if (parsed <= 0n || parsed > BigInt(Number.MAX_SAFE_INTEGER)) invalid(name); return Number(parsed); } -function isoFromDateTime(value: unknown, name: string): string { const date = new Date(value as string | number | Date); if (Number.isNaN(date.getTime())) invalid(name); return date.toISOString(); } -function port(value: unknown, name: string): number { if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > 65535) invalid(name); return value; } -function booleanValue(value: unknown, name: string): boolean { if (typeof value !== 'boolean') invalid(name); return value; } -function enumValue(value: unknown, allowed: readonly T[], name: string): T { if (typeof value !== 'string' || !allowed.includes(value as T)) invalid(name); return value as T; } -function isoString(value: unknown, name: string): string { const result = stringValue(value, name, 64); const match = RFC3339_PATTERN.exec(result); if (!match) invalid(name); const year = Number(match[1]); const month = Number(match[2]); const day = Number(match[3]); const hour = Number(match[4]); const minute = Number(match[5]); const second = Number(match[6]); const offsetHour = match[8] === 'Z' ? 0 : Number(match[10]); const offsetMinute = match[8] === 'Z' ? 0 : Number(match[11]); const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); const days = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; if (month < 1 || month > 12 || day < 1 || day > days[month - 1] || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59 || Number.isNaN(Date.parse(result))) invalid(name); return result; } -function publicIpv4(value: unknown): string { const result = stringValue(value, 'publicIpv4', 15); const [a, b, c] = result.split('.').map(Number); if (isIP(result) !== 4 || a === 0 || a === 10 || a === 127 || a >= 224 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || (a === 192 && b === 0 && (c === 0 || c === 2)) || (a === 192 && b === 88 && c === 99) || (a === 198 && (b === 18 || b === 19)) || (a === 198 && b === 51 && c === 100) || (a === 203 && b === 0 && c === 113)) invalid('publicIpv4'); return result; } -function publicIpv6(value: unknown): string { const result = stringValue(value, 'publicIpv6', 45); if (isIP(result) !== 6) invalid('publicIpv6'); let normalized: string; try { const hostname = new URL(`http://[${result}]/`).hostname; normalized = hostname.slice(1, -1).toLowerCase(); } catch { invalid('publicIpv6'); } const firstHextet = Number.parseInt(normalized.split(':', 1)[0], 16); if (!Number.isInteger(firstHextet) || firstHextet < 0x2000 || firstHextet > 0x3fff) invalid('publicIpv6'); return normalized; } -function digest(value: unknown): string { return createHash('sha256').update(goJsonStringify(value)).digest('hex'); } -function goJsonStringify(value: unknown): string { return JSON.stringify(value).replace(//g, '\\u003e').replace(/&/g, '\\u0026').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); } -function invalid(name: string): never { throw new NetworkV2MessageValidationError(`invalid network v2 ${name}`); } +function compareDesiredChannelsV2( + left: NetworkDesiredChannelV2, + right: NetworkDesiredChannelV2, +): number { + return ( + (left.channelId < right.channelId + ? -1 + : left.channelId > right.channelId + ? 1 + : 0) || + (left.protocol < right.protocol + ? -1 + : left.protocol > right.protocol + ? 1 + : 0) || + left.externalPort - right.externalPort + ); +} +function parseWirePayloadV2(payload: NetworkV2WirePayload): unknown { + let text: string; + if (typeof payload === 'string') { + if (Buffer.byteLength(payload, 'utf8') > NETWORK_AGENT_V2_MAX_MESSAGE_BYTES) + invalid('message size'); + text = payload; + } else if (Buffer.isBuffer(payload)) { + if (payload.length > NETWORK_AGENT_V2_MAX_MESSAGE_BYTES) + invalid('message size'); + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(payload); + } catch { + invalid('message UTF-8'); + } + } else { + invalid('message payload'); + } + try { + return JSON.parse(text); + } catch { + invalid('message JSON'); + } +} +function optionalEndpoint( + value: unknown, + mechanism: NetworkV2EndpointMechanism, +): NetworkEndpointLeaseV2 | undefined { + return value === undefined ? undefined : parseEndpointV2(value, mechanism); +} +function optionalBounded( + value: unknown, + name: string, + max: number, +): string | undefined { + return value === undefined ? undefined : boundedString(value, name, max); +} +function optionalErrorCode(value: unknown, name: string): string | undefined { + return value === undefined ? undefined : errorCode(value, name); +} +function protocolOf(value: unknown, name: string): NetworkV2Protocol { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + invalid(name); + return enumValue( + (value as Record).protocol, + ['tcp', 'udp'] as const, + 'protocol', + ); +} +function exactRecord( + value: unknown, + required: string[], + optional: string[], + name: string, + allowPartial = false, +): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + invalid(name); + const record = value as Record; + const allowed = new Set([...required, ...optional]); + if ( + Object.keys(record).some((key) => !allowed.has(key)) || + (!allowPartial && required.some((key) => !(key in record))) + ) + invalid(name); + return record; +} +function assertSchema(value: unknown): void { + if (value !== NETWORK_AGENT_V2_SCHEMA_VERSION) invalid('schemaVersion'); +} +function identifier(value: unknown, name: string): string { + const result = stringValue(value, name, 32); + if (!ID_PATTERN.test(result)) invalid(name); + return result; +} +function requestId(value: unknown, name: string): string { + const result = stringValue(value, name, 128); + if (!REQUEST_ID_PATTERN.test(result)) invalid(name); + return result; +} +function digestValue(value: unknown, name: string): string { + const result = stringValue(value, name, 64); + if (!/^[0-9a-f]{64}$/.test(result)) invalid(name); + return result; +} +function errorCode(value: unknown, name: string): string { + const result = stringValue(value, name, 64); + if (!ERROR_CODE_PATTERN.test(result)) invalid(name); + return result; +} +function boundedString(value: unknown, name: string, max: number): string { + const result = stringValue(value, name, max); + if (result.length === 0) invalid(name); + return result; +} +function stringValue(value: unknown, name: string, max: number): string { + if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > max) + invalid(name); + return value; +} +function positiveRevision(value: unknown, name: string): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) + invalid(name); + return value; +} +function safeRevisionFromString(value: string, name: string): number { + if (!/^\d+$/.test(value)) invalid(name); + const parsed = BigInt(value); + if (parsed <= 0n || parsed > BigInt(Number.MAX_SAFE_INTEGER)) invalid(name); + return Number(parsed); +} +function isoFromDateTime(value: unknown, name: string): string { + const date = new Date(value as string | number | Date); + if (Number.isNaN(date.getTime())) invalid(name); + return date.toISOString(); +} +function port(value: unknown, name: string): number { + if ( + typeof value !== 'number' || + !Number.isInteger(value) || + value < 1 || + value > 65535 + ) + invalid(name); + return value; +} +function booleanValue(value: unknown, name: string): boolean { + if (typeof value !== 'boolean') invalid(name); + return value; +} +function enumValue( + value: unknown, + allowed: readonly T[], + name: string, +): T { + if (typeof value !== 'string' || !allowed.includes(value as T)) invalid(name); + return value as T; +} +function isoString(value: unknown, name: string): string { + const result = stringValue(value, name, 64); + const match = RFC3339_PATTERN.exec(result); + if (!match) invalid(name); + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6]); + const offsetHour = match[8] === 'Z' ? 0 : Number(match[10]); + const offsetMinute = match[8] === 'Z' ? 0 : Number(match[11]); + const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); + const days = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + if ( + month < 1 || + month > 12 || + day < 1 || + day > days[month - 1] || + hour > 23 || + minute > 59 || + second > 59 || + offsetHour > 23 || + offsetMinute > 59 || + Number.isNaN(Date.parse(result)) + ) + invalid(name); + return result; +} +function rfc3339Nanoseconds(value: string): bigint { + const match = RFC3339_PATTERN.exec(value); + if (!match) invalid('timestamp'); + const wholeSecond = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}${match[8]}`; + const wholeSecondMilliseconds = Date.parse(wholeSecond); + if (Number.isNaN(wholeSecondMilliseconds)) invalid('timestamp'); + const fractionalNanoseconds = BigInt((match[7] || '').padEnd(9, '0')); + return BigInt(wholeSecondMilliseconds) * 1_000_000n + fractionalNanoseconds; +} +function publicIpv4(value: unknown): string { + const result = stringValue(value, 'publicIpv4', 15); + const [a, b, c] = result.split('.').map(Number); + if ( + isIP(result) !== 4 || + a === 0 || + a === 10 || + a === 127 || + a >= 224 || + (a === 100 && b >= 64 && b <= 127) || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) || + (a === 192 && b === 0 && (c === 0 || c === 2)) || + (a === 192 && b === 88 && c === 99) || + (a === 198 && (b === 18 || b === 19)) || + (a === 198 && b === 51 && c === 100) || + (a === 203 && b === 0 && c === 113) + ) + invalid('publicIpv4'); + return result; +} +function publicIpv6(value: unknown): string { + const result = stringValue(value, 'publicIpv6', 45); + if (isIP(result) !== 6) invalid('publicIpv6'); + let normalized: string; + try { + const hostname = new URL(`http://[${result}]/`).hostname; + normalized = hostname.slice(1, -1).toLowerCase(); + } catch { + invalid('publicIpv6'); + } + const firstHextet = Number.parseInt(normalized.split(':', 1)[0], 16); + if ( + !Number.isInteger(firstHextet) || + firstHextet < 0x2000 || + firstHextet > 0x3fff + ) + invalid('publicIpv6'); + return normalized; +} +function digest(value: unknown): string { + return createHash('sha256').update(goJsonStringify(value)).digest('hex'); +} +function goJsonStringify(value: unknown): string { + return JSON.stringify(value) + .replace(//g, '\\u003e') + .replace(/&/g, '\\u0026') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029'); +} +function invalid(name: string): never { + throw new NetworkV2MessageValidationError(`invalid network v2 ${name}`); +} diff --git a/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts b/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts index 701a02e..026212f 100644 --- a/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts +++ b/src/modules/admin/platform-config/network-management/network-endpoint-history.entity.ts @@ -5,7 +5,10 @@ import { KtDateTime, KtDateTimeColumn, } from '@/common'; -import type { EndpointEventType } from './network-management.types'; +import type { + EndpointEventType, + EndpointMechanism, +} from './network-management.types'; @Entity('network_endpoint_history') @Index('uk_network_endpoint_history_event_id', ['eventId'], { unique: true }) @@ -24,7 +27,18 @@ export class NetworkEndpointHistory { eventType: EndpointEventType; @Column({ default: 'udp_stun', length: 16, name: 'mechanism' }) - mechanism: string; + mechanism: EndpointMechanism; + + @Column({ name: 'source_revision', nullable: true, type: 'bigint' }) + sourceRevision?: string | null; + + @Column({ + length: 64, + name: 'endpoint_identity', + nullable: true, + type: 'char', + }) + endpointIdentity?: string | null; @Column({ length: 15, name: 'public_ipv4', nullable: true }) publicIpv4?: string | null; @@ -32,6 +46,22 @@ export class NetworkEndpointHistory { @Column({ name: 'public_port', nullable: true, type: 'int' }) publicPort?: number | null; + @KtDateTimeColumn({ + name: 'endpoint_validated_at', + nullable: true, + precision: 6, + type: 'datetime', + }) + endpointValidatedAt?: KtDateTime | null; + + @KtDateTimeColumn({ + name: 'endpoint_valid_until', + nullable: true, + precision: 6, + type: 'datetime', + }) + endpointValidUntil?: KtDateTime | null; + @KtDateTimeColumn({ name: 'first_observed_at', type: 'datetime' }) firstObservedAt: KtDateTime; diff --git a/src/modules/admin/platform-config/network-management/network-management.entity.ts b/src/modules/admin/platform-config/network-management/network-management.entity.ts index 139a271..87d19af 100644 --- a/src/modules/admin/platform-config/network-management/network-management.entity.ts +++ b/src/modules/admin/platform-config/network-management/network-management.entity.ts @@ -15,9 +15,11 @@ import type { @Entity('network_port_forward') @Index('uk_network_port_forward_active_key', ['activeKey'], { unique: true }) -@Index('uk_network_port_forward_active_group_protocol_key', [ - 'activeGroupProtocolKey', -], { unique: true }) +@Index( + 'uk_network_port_forward_active_group_protocol_key', + ['activeGroupProtocolKey'], + { unique: true }, +) @Index('idx_network_port_forward_group', ['groupId', 'isDeleted', 'protocol']) export class NetworkPortForward { @PrimaryColumn({ type: 'bigint' }) @@ -83,6 +85,17 @@ export class NetworkPortForward { @Column({ default: '0', name: 'reported_revision', type: 'bigint' }) reportedRevision: string; + @KtDateTimeColumn({ + name: 'last_reported_at', + nullable: true, + precision: 6, + type: 'datetime', + }) + lastReportedAt?: KtDateTime | null; + + @Column({ length: 64, name: 'last_reported_at_wire', nullable: true }) + lastReportedAtWire?: string | null; + @Column({ default: 'pending', length: 16, name: 'sync_status' }) syncStatus: PortForwardSyncStatus; @@ -113,6 +126,13 @@ export class NetworkPortForward { }) currentValidatedAt?: KtDateTime | null; + @Column({ + length: 64, + name: 'current_validated_at_wire', + nullable: true, + }) + currentValidatedAtWire?: string | null; + @KtDateTimeColumn({ name: 'current_valid_until', nullable: true, @@ -120,6 +140,14 @@ export class NetworkPortForward { }) currentValidUntil?: KtDateTime | null; + @Column({ + length: 64, + name: 'current_endpoint_identity', + nullable: true, + type: 'char', + }) + currentEndpointIdentity?: string | null; + @Column({ length: 15, name: 'last_observed_ipv4', nullable: true }) lastObservedIpv4?: string | null; @@ -141,6 +169,13 @@ export class NetworkPortForward { }) lastObservedValidatedAt?: KtDateTime | null; + @Column({ + length: 64, + name: 'last_observed_validated_at_wire', + nullable: true, + }) + lastObservedValidatedAtWire?: string | null; + @Column({ length: 15, name: 'candidate_public_ipv4', nullable: true }) candidatePublicIpv4?: string | null; @@ -163,6 +198,13 @@ export class NetworkPortForward { }) candidateValidatedAt?: KtDateTime | null; + @Column({ + length: 64, + name: 'candidate_validated_at_wire', + nullable: true, + }) + candidateValidatedAtWire?: string | null; + @Column({ length: 15, name: 'last_published_public_ipv4', nullable: true }) lastPublishedPublicIpv4?: string | null; diff --git a/src/modules/admin/platform-config/network-management/network-management.types.ts b/src/modules/admin/platform-config/network-management/network-management.types.ts index ac3b77a..6082662 100644 --- a/src/modules/admin/platform-config/network-management/network-management.types.ts +++ b/src/modules/admin/platform-config/network-management/network-management.types.ts @@ -30,6 +30,7 @@ export type EndpointEventType = | 'published' | 'restored' | 'withdrawn'; +export type EndpointMechanism = 'tcp_natmap' | 'udp_stun'; export type NetworkStateChangeSource = | 'ddns' | 'events' diff --git a/test/admin/network-management/network-agent-mqtt.service.spec.ts b/test/admin/network-management/network-agent-mqtt.service.spec.ts index f4b76a9..fcfc683 100644 --- a/test/admin/network-management/network-agent-mqtt.service.spec.ts +++ b/test/admin/network-management/network-agent-mqtt.service.spec.ts @@ -12,6 +12,10 @@ import { NetworkEndpointHistory } from '../../../src/modules/admin/platform-conf 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'; +import { + buildDesiredSnapshotV2, + endpointLeaseIdentityV2, +} from '../../../src/modules/admin/platform-config/network-management/network-agent-v2.types'; import type { SystemMessageEventInput, SystemMessageEventStager, @@ -25,9 +29,11 @@ type MqttHarness = { client: MqttClient & EventEmitter; clientOptions: () => IClientOptions; deliveryCoordinator: { requestDrain: jest.Mock }; + group: NetworkPortForwardGroup; histories: NetworkEndpointHistory[]; historyFindOne: jest.Mock; historySave: jest.Mock; + mappingFindOne: jest.Mock; mappingSave: jest.Mock; operations: string[]; publishCommitted: jest.Mock; @@ -38,6 +44,7 @@ type MqttHarness = { stagedEvents: SystemMessageEventInput[]; stager: jest.Mocked; state: NetworkAgentState; + stateFindOne: jest.Mock; stateSave: jest.Mock; transactionCalls: () => number; }; @@ -56,6 +63,24 @@ type ConcurrentEndpointHarness = { stagedEvents: SystemMessageEventInput[]; }; +type V2MqttHarness = { + channels: NetworkPortForward[]; + deliveryCoordinator: { requestDrain: jest.Mock }; + groups: NetworkPortForwardGroup[]; + histories: NetworkEndpointHistory[]; + historySave: jest.Mock; + mappingFindOne: jest.Mock; + mappingSave: jest.Mock; + operations: string[]; + publishCommitted: jest.Mock; + requestDdnsReconcile: jest.Mock; + service: NetworkAgentMqttService; + stagedEvents: SystemMessageEventInput[]; + stager: jest.Mocked; + state: NetworkAgentState; + stateFindOne: jest.Mock; +}; + function createDeferred(): Deferred { let resolve: (value: T | PromiseLike) => void = () => undefined; const promise = new Promise((nextResolve) => { @@ -69,6 +94,7 @@ function createHarness(): MqttHarness { const state = Object.assign(new NetworkAgentState(), { agentId: 'nas-main', appliedRevision: '0', + appliedSchemaVersion: 1, desiredIssuedAt: new KtDateTime('2026-07-22T01:02:03.000Z'), desiredRevision: '7', online: false, @@ -76,6 +102,7 @@ function createHarness(): MqttHarness { targetIpv4: '192.168.31.224', }); const mapping = Object.assign(new NetworkPortForward(), { + activeGroupProtocolKey: '10:udp', activeKey: 'udp:9000', currentObservedAt: null, currentPublicIpv4: null, @@ -85,6 +112,7 @@ function createHarness(): MqttHarness { desiredPresence: 'present', desiredRevision: '7', externalPort: 9000, + groupId: '10', id: '100', internalPort: 9000, isDeleted: false, @@ -96,18 +124,39 @@ function createHarness(): MqttHarness { syncStatus: 'pending', targetIpv4: '192.168.31.224', }); + const group = Object.assign(new NetworkPortForwardGroup(), { + externalPort: 9000, + id: '10', + internalPort: 9000, + isDeleted: false, + name: 'rule', + protocolMode: 'udp', + targetIpv4: '192.168.31.224', + }); const histories: NetworkEndpointHistory[] = []; const stateSave = jest.fn(async (value) => Object.assign(state, value)); + const stateFindOne = jest.fn(async () => state); const stateRepository = { - findOne: async () => state, + findOne: stateFindOne, save: stateSave, } as unknown as Repository; const mappingSave = jest.fn(async (value) => Object.assign(mapping, value)); + const mappingFindOne = jest.fn(async ({ where }) => + where.id === mapping.id ? mapping : null, + ); const mappingRepository = { - find: async () => (mapping.isDeleted ? [] : [mapping]), - findOne: async ({ where }) => (where.id === mapping.id ? mapping : null), + find: async ({ where } = {} as any) => { + if (where?.groupId === mapping.groupId) return [mapping]; + return mapping.isDeleted ? [] : [mapping]; + }, + findOne: mappingFindOne, save: mappingSave, } as unknown as Repository; + const groupRepository = { + find: async () => (group.isDeleted ? [] : [group]), + findOne: async ({ where }) => (where.id === group.id ? group : null), + save: jest.fn(async (value) => Object.assign(group, value)), + } as unknown as Repository; const historyFindOne = jest.fn(async ({ order, where }) => { if (where.eventId) { return histories.find((item) => item.eventId === where.eventId) || null; @@ -145,6 +194,7 @@ function createHarness(): MqttHarness { getRepository: (entity) => { if (entity === NetworkAgentState) return stateRepository; if (entity === NetworkPortForward) return mappingRepository; + if (entity === NetworkPortForwardGroup) return groupRepository; if (entity === NetworkEndpointHistory) return historyRepository; throw new Error('unexpected repository'); }, @@ -235,9 +285,11 @@ function createHarness(): MqttHarness { client, clientOptions: () => options, deliveryCoordinator, + group, histories, historyFindOne, historySave, + mappingFindOne, mappingSave, mapping, operations, @@ -247,12 +299,370 @@ function createHarness(): MqttHarness { service, stagedEvents, state, + stateFindOne, stateSave, stager, transactionCalls: () => transactionCallCount, }; } +function createV2Harness(): V2MqttHarness { + const operations: string[] = []; + const issuedAt = new KtDateTime('2099-07-27T00:00:00.000Z'); + const state = Object.assign(new NetworkAgentState(), { + agentId: 'nas-main', + appliedRevision: '0', + appliedSchemaVersion: 1, + desiredIssuedAt: issuedAt, + desiredRevision: '7', + desiredSchemaVersion: 2, + maxSupportedSchemaVersion: 2, + online: true, + publishedRevision: '7', + publishedSchemaVersion: 2, + targetIpv4: '192.168.31.224', + tcpNatmapCapable: true, + }); + const group = Object.assign(new NetworkPortForwardGroup(), { + externalPort: 8213, + id: '10', + internalPort: 8213, + isDeleted: false, + name: '双协议规则', + protocolMode: 'tcp_udp', + targetIpv4: '192.168.31.224', + }); + const channelBase = { + currentObservedAt: null, + currentPublicIpv4: null, + currentPublicPort: null, + currentValidatedAt: null, + currentValidUntil: null, + desiredIssuedAt: issuedAt, + desiredPresence: 'present' as const, + desiredRevision: '7', + externalPort: 8213, + groupId: group.id, + internalPort: 8213, + isDeleted: false, + lastObservedAt: null, + lastObservedIpv4: null, + lastObservedPort: null, + lastObservedValidatedAt: null, + name: group.name, + reportedRevision: '0', + syncStatus: 'pending' as const, + targetIpv4: '192.168.31.224', + }; + const tcp = Object.assign(new NetworkPortForward(), channelBase, { + activeGroupProtocolKey: '10:tcp', + activeKey: 'tcp:8213', + id: '101', + keeperDesiredEnabled: false, + keeperStatus: 'disabled', + natmapDesiredEnabled: true, + natmapStatus: 'starting', + protocol: 'tcp', + }); + const udp = Object.assign(new NetworkPortForward(), channelBase, { + activeGroupProtocolKey: '10:udp', + activeKey: 'udp:8213', + id: '102', + keeperDesiredEnabled: true, + keeperStatus: 'starting', + natmapDesiredEnabled: false, + natmapStatus: 'disabled', + protocol: 'udp', + }); + const channels = [tcp, udp]; + const groups = [group]; + const histories: NetworkEndpointHistory[] = []; + const stagedEvents: SystemMessageEventInput[] = []; + const mappingSave = jest.fn(async (value) => value); + const groupSave = jest.fn(async (value) => value); + const historySave = jest.fn(async (value: NetworkEndpointHistory) => { + value.id ||= String(histories.length + 1); + histories.push(value); + return value; + }); + const stateSave = jest.fn(async (value) => value); + const matchesWhere = ( + value: Record, + where: Record = {}, + ) => + Object.entries(where).every(([key, expected]) => value[key] === expected); + const mappingFindOne = jest.fn( + async ({ where }) => + channels.find((channel) => + matchesWhere(channel as unknown as Record, where), + ) || null, + ); + const mappingRepository = { + find: async ({ where } = {} as any) => + channels.filter((channel) => + matchesWhere(channel as unknown as Record, where), + ), + findOne: mappingFindOne, + save: mappingSave, + } as unknown as Repository; + const groupRepository = { + find: async ({ where } = {} as any) => + groups.filter((item) => + matchesWhere(item as unknown as Record, where), + ), + findOne: async ({ where }) => + groups.find((item) => + matchesWhere(item as unknown as Record, where), + ) || null, + save: groupSave, + } as unknown as Repository; + const historyRepository = { + create: (input) => Object.assign(new NetworkEndpointHistory(), input), + find: async ({ order, take, where }) => { + const matches = histories.filter((history) => + matchesWhere(history as unknown as Record, where), + ); + const sorted = [...matches].sort((left, right) => { + for (const [key, direction] of Object.entries(order || {})) { + const leftValue = left[key as keyof NetworkEndpointHistory]; + const rightValue = right[key as keyof NetworkEndpointHistory]; + const compare = + leftValue instanceof Date && rightValue instanceof Date + ? leftValue.getTime() - rightValue.getTime() + : String(leftValue).localeCompare(String(rightValue)); + if (compare !== 0) return direction === 'DESC' ? -compare : compare; + } + return 0; + }); + return take ? sorted.slice(0, take) : sorted; + }, + findOne: async ({ order, where }) => { + const matches = histories.filter((history) => + matchesWhere(history as unknown as Record, where), + ); + if (!order) return matches[0] || null; + return ( + [...matches].sort((left, right) => { + for (const [key, direction] of Object.entries(order)) { + const leftValue = left[key as keyof NetworkEndpointHistory]; + const rightValue = right[key as keyof NetworkEndpointHistory]; + const compare = + leftValue instanceof Date && rightValue instanceof Date + ? leftValue.getTime() - rightValue.getTime() + : String(leftValue).localeCompare(String(rightValue)); + if (compare !== 0) return direction === 'DESC' ? -compare : compare; + } + return 0; + })[0] || null + ); + }, + save: historySave, + } as unknown as Repository; + const stateFindOne = jest.fn(async () => state); + const stateRepository = { + findOne: stateFindOne, + save: stateSave, + } as unknown as Repository; + const manager = { + getRepository: (entity) => { + if (entity === NetworkAgentState) return stateRepository; + if (entity === NetworkPortForward) return mappingRepository; + if (entity === NetworkPortForwardGroup) return groupRepository; + if (entity === NetworkEndpointHistory) return historyRepository; + throw new Error('unexpected v2 repository'); + }, + } as unknown as EntityManager; + const stager = { + stage: jest.fn(async (_manager, input) => { + operations.push('stager:accepted'); + stagedEvents.push(input); + return 'accepted' as const; + }), + } as jest.Mocked; + const dataSource = { + getRepository: manager.getRepository.bind(manager), + transaction: async (work) => { + operations.push('transaction:start'); + const stateBefore = Object.assign(new NetworkAgentState(), state); + const channelsBefore = channels.map((channel) => + Object.assign(new NetworkPortForward(), channel), + ); + const groupsBefore = groups.map((item) => + Object.assign(new NetworkPortForwardGroup(), item), + ); + const historiesBefore = [...histories]; + const stagedEventsBefore = [...stagedEvents]; + try { + const result = await work(manager); + operations.push('transaction:commit'); + return result; + } catch (error) { + Object.assign(state, stateBefore); + for (const snapshot of channelsBefore) { + const channel = channels.find((item) => item.id === snapshot.id); + if (channel) Object.assign(channel, snapshot); + } + for (const snapshot of groupsBefore) { + const item = groups.find((candidate) => candidate.id === snapshot.id); + if (item) Object.assign(item, snapshot); + } + histories.splice(0, histories.length, ...historiesBefore); + stagedEvents.splice(0, stagedEvents.length, ...stagedEventsBefore); + operations.push('transaction:rollback'); + throw error; + } + }, + } as unknown as DataSource; + const publishCommitted = jest.fn(() => operations.push('sse')); + const requestDdnsReconcile = jest.fn(() => operations.push('ddns:wake')); + const deliveryCoordinator = { + requestDrain: jest.fn(() => operations.push('delivery:wake')), + }; + const service = new NetworkAgentMqttService( + { + get: (key: string) => + ({ + NETWORK_AGENT_ID: 'nas-main', + NETWORK_AGENT_MQTT_RETRY_MS: '60000', + })[key], + } as ConfigService, + dataSource, + { publishCommitted } as never, + stager, + deliveryCoordinator as never, + undefined, + { requestReconcile: requestDdnsReconcile } as never, + ); + return { + channels, + deliveryCoordinator, + groups, + histories, + historySave, + mappingFindOne, + mappingSave, + operations, + publishCommitted, + requestDdnsReconcile, + service, + stagedEvents, + stager, + state, + stateFindOne, + }; +} + +function v2Endpoint( + mechanism: 'tcp_natmap' | 'udp_stun', + overrides: Record = {}, +) { + return { + mechanism, + observedAt: '2099-07-27T00:00:00.000Z', + publicIpv4: mechanism === 'tcp_natmap' ? '8.8.8.8' : '8.8.4.4', + publicPort: mechanism === 'tcp_natmap' ? 45101 : 45102, + validatedAt: '2099-07-27T00:00:05.000Z', + validUntil: '2099-07-27T00:00:45.000Z', + ...overrides, + }; +} + +function v2Reported( + harness: V2MqttHarness, + channelOverrides: Record> = {}, + snapshotOverrides: Record = {}, +): Buffer { + const activeChannels = harness.channels.filter( + (channel) => !channel.isDeleted, + ); + const desired = buildDesiredSnapshotV2(harness.state, activeChannels); + const desiredById = new Map( + desired.channels.map((channel) => [channel.channelId, channel]), + ); + const channels = activeChannels.map((channel) => { + const desiredChannel = desiredById.get(channel.id); + if (!desiredChannel) throw new Error('missing desired channel'); + const present = channel.desiredPresence === 'present'; + if (channel.protocol === 'tcp') { + const endpoint = v2Endpoint('tcp_natmap'); + return { + appliedDesiredDigest: desiredChannel.channelDesiredDigest, + appliedDesiredRevision: desiredChannel.channelDesiredRevision, + ...(present + ? { + candidateEndpoint: endpoint, + currentEndpoint: endpoint, + lastObservedEndpoint: endpoint, + } + : {}), + channelId: channel.id, + desiredPresence: channel.desiredPresence, + dnatPresent: present, + groupId: channel.groupId, + ...(present ? { instanceGeneration: 'generation-1' } : {}), + natmapDesiredEnabled: channel.natmapDesiredEnabled, + natmapStatus: present ? 'active' : 'disabled', + protocol: 'tcp', + routerPresent: present, + syncStatus: 'synced', + ...channelOverrides[channel.id], + }; + } + const endpoint = v2Endpoint('udp_stun'); + return { + appliedDesiredDigest: desiredChannel.channelDesiredDigest, + appliedDesiredRevision: desiredChannel.channelDesiredRevision, + channelId: channel.id, + ...(present + ? { currentEndpoint: endpoint, lastObservedEndpoint: endpoint } + : {}), + desiredPresence: channel.desiredPresence, + groupId: channel.groupId, + keeperDesiredEnabled: channel.keeperDesiredEnabled, + keeperStatus: present ? 'active' : 'disabled', + protocol: 'udp', + routePresent: present, + routerPresent: present, + syncStatus: 'synced', + ...channelOverrides[channel.id], + }; + }); + return Buffer.from( + JSON.stringify({ + agentId: 'nas-main', + channels, + reportedAt: '2099-07-27T00:00:10.000Z', + schemaVersion: 2, + snapshotDigest: desired.snapshotDigest, + snapshotRevision: desired.snapshotRevision, + ...snapshotOverrides, + }), + ); +} + +function v2EndpointEvent(overrides: Record = {}): Buffer { + return Buffer.from( + JSON.stringify({ + agentId: 'nas-main', + channelId: '102', + endpoint: v2Endpoint('udp_stun', { + publicPort: 45103, + validatedAt: '2099-07-27T00:00:15.000Z', + validUntil: '2099-07-27T00:00:55.000Z', + }), + eventId: 'v2-endpoint-event-2', + groupId: '10', + mechanism: 'udp_stun', + occurredAt: '2099-07-27T00:00:16.000Z', + protocol: 'udp', + revision: 7, + schemaVersion: 2, + type: 'changed', + ...overrides, + }), + ); +} + function createConcurrentEndpointHarness(): ConcurrentEndpointHarness { const state = Object.assign(new NetworkAgentState(), { agentId: 'nas-main', @@ -449,6 +859,29 @@ function endpointHistory( }); } +function v2EndpointHistory( + overrides: Partial = {}, +): NetworkEndpointHistory { + const endpoint = v2Endpoint('udp_stun'); + return Object.assign(new NetworkEndpointHistory(), { + endpointValidatedAt: new KtDateTime('2099-07-27T00:00:05.000Z'), + endpointValidUntil: new KtDateTime('2099-07-27T00:00:45.000Z'), + endpointIdentity: endpointLeaseIdentityV2(endpoint), + eventId: 'v2-endpoint-event-1', + eventType: 'published', + firstObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + id: '1', + lastObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + mappingId: '102', + mechanism: 'udp_stun', + occurredAt: new KtDateTime('2099-07-27T00:00:01.000Z'), + publicIpv4: '8.8.4.4', + publicPort: 45102, + sourceRevision: '7', + ...overrides, + }); +} + async function flushPromises(): Promise { await new Promise((resolve) => setImmediate(resolve)); } @@ -643,6 +1076,8 @@ describe('NetworkAgentMqttService', () => { 'kt/network/v1/agents/nas-main/events': { qos: 1 }, 'kt/network/v1/agents/nas-main/reported': { qos: 1 }, 'kt/network/v1/agents/nas-main/status': { qos: 1 }, + 'kt/network/v2/agents/nas-main/events': { qos: 1 }, + 'kt/network/v2/agents/nas-main/reported': { qos: 1 }, 'kt/network/v2/agents/nas-main/status': { qos: 1 }, }; harness.service.onModuleInit(); @@ -1022,6 +1457,8 @@ describe('NetworkAgentMqttService', () => { ); expect(harness.mapping.isDeleted).toBe(true); expect(harness.mapping.activeKey).toBeNull(); + expect(harness.mapping.activeGroupProtocolKey).toBeNull(); + expect(harness.group.isDeleted).toBe(true); expect(harness.state.desiredRevision).toBe('9'); await expect( @@ -1508,6 +1945,961 @@ describe('NetworkAgentMqttService', () => { expect(harness.publishCommitted).toHaveBeenCalledWith('status'); }); + it('ignores late v1 reported state and events after v2 becomes the desired owner', async () => { + const harness = createHarness(); + harness.state.desiredSchemaVersion = 2; + + await harness.service.consumeMessage( + 'kt/network/v1/agents/nas-main/reported', + reported(harness, 7), + ); + await harness.service.consumeMessage( + 'kt/network/v1/agents/nas-main/events', + endpointEvent(), + ); + + expect(harness.mapping.currentPublicIpv4).toBeNull(); + expect(harness.histories).toHaveLength(0); + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + }); + + it('uses the durable v2 applied schema watermark to reject late v1 writes', async () => { + const harness = createHarness(); + harness.state.desiredSchemaVersion = 1; + harness.state.appliedSchemaVersion = 2; + + await harness.service.consumeMessage( + 'kt/network/v1/agents/nas-main/reported', + reported(harness, 7), + ); + await harness.service.consumeMessage( + 'kt/network/v1/agents/nas-main/events', + endpointEvent(), + ); + + expect(harness.mapping.currentPublicIpv4).toBeNull(); + expect(harness.histories).toHaveLength(0); + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + }); + + it('locks Agent ownership before the v1 event channel row', async () => { + const harness = createHarness(); + + await harness.service.consumeMessage( + 'kt/network/v1/agents/nas-main/events', + endpointEvent(), + ); + + expect(harness.stateFindOne).toHaveBeenCalledWith( + expect.objectContaining({ lock: { mode: 'pessimistic_write' } }), + ); + expect(harness.mappingFindOne).toHaveBeenCalledWith( + expect.objectContaining({ lock: { mode: 'pessimistic_write' } }), + ); + expect(harness.stateFindOne.mock.invocationCallOrder[0]).toBeLessThan( + harness.mappingFindOne.mock.invocationCallOrder[0], + ); + }); + + describe('MQTT v2 reported endpoint lifecycle', () => { + it('locks Agent ownership and every v2 reported channel in a stable order', async () => { + const harness = createV2Harness(); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness), + ); + + expect(harness.stateFindOne).toHaveBeenCalledWith( + expect.objectContaining({ lock: { mode: 'pessimistic_write' } }), + ); + expect(harness.mappingFindOne).toHaveBeenCalledTimes(2); + for (const call of harness.mappingFindOne.mock.calls) { + expect(call[0]).toEqual( + expect.objectContaining({ lock: { mode: 'pessimistic_write' } }), + ); + } + expect(harness.stateFindOne.mock.invocationCallOrder[0]).toBeLessThan( + harness.mappingFindOne.mock.invocationCallOrder[0], + ); + expect( + harness.mappingFindOne.mock.calls.map(([options]) => options.where.id), + ).toEqual(['101', '102']); + }); + + it('persists an early TCP candidate without publishing it before the static path converges', async () => { + const harness = createV2Harness(); + const tcp = harness.channels[0]; + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, { + [tcp.id]: { + currentEndpoint: undefined, + dnatPresent: false, + routerPresent: false, + syncStatus: 'syncing', + }, + '102': { currentEndpoint: undefined }, + }), + ); + + expect(tcp).toMatchObject({ + candidatePublicIpv4: '8.8.8.8', + candidatePublicPort: 45101, + currentPublicIpv4: null, + currentPublicPort: null, + lastObservedIpv4: '8.8.8.8', + lastObservedPort: 45101, + natmapStatus: 'active', + syncStatus: 'syncing', + }); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + }); + + it.each([ + ['DNAT', { dnatPresent: false }], + ['Router', { routerPresent: false }], + ['sync', { syncStatus: 'failed' }], + ['generation', { instanceGeneration: undefined }], + [ + 'tuple', + { + candidateEndpoint: v2Endpoint('tcp_natmap', { + publicPort: 45111, + }), + }, + ], + ])( + 'withdraws an existing TCP current endpoint when the %s publication gate fails', + async (_gate, gateOverride) => { + const harness = createV2Harness(); + const tcp = harness.channels[0]; + Object.assign(tcp, { + currentObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + currentPublicIpv4: '1.1.1.1', + currentPublicPort: 45000, + currentValidatedAt: new KtDateTime('2099-07-27T00:00:01.000Z'), + currentValidUntil: new KtDateTime('2099-07-27T00:00:40.000Z'), + }); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, { + [tcp.id]: { + ...gateOverride, + }, + }), + ); + + expect(tcp.currentPublicIpv4).toBeNull(); + expect(tcp.currentPublicPort).toBeNull(); + expect(tcp.candidatePublicIpv4).toBe('8.8.8.8'); + expect(tcp.natmapStatus).toBe('active'); + expect(harness.requestDdnsReconcile).toHaveBeenCalledTimes(1); + }, + ); + + it('withdraws a stale lease while retaining the latest candidate observation', async () => { + const harness = createV2Harness(); + const tcp = harness.channels[0]; + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness), + ); + harness.publishCommitted.mockClear(); + harness.requestDdnsReconcile.mockClear(); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, {}, { reportedAt: '2099-07-27T00:01:00.000Z' }), + ); + + expect(tcp.currentPublicIpv4).toBeNull(); + expect(tcp.currentPublicPort).toBeNull(); + expect(tcp.candidatePublicIpv4).toBe('8.8.8.8'); + expect(tcp.lastObservedIpv4).toBe('8.8.8.8'); + expect(tcp.lastPublishedPublicIpv4).toBe('8.8.8.8'); + expect(tcp.lastPublishedPublicPort).toBe(45101); + expect(harness.publishCommitted).toHaveBeenCalledTimes(1); + expect(harness.requestDdnsReconcile).toHaveBeenCalledTimes(1); + }); + + it('isolates a TCP channel digest conflict and still applies a valid UDP sibling', async () => { + const harness = createV2Harness(); + const [tcp, udp] = harness.channels; + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, { + [tcp.id]: { appliedDesiredDigest: 'f'.repeat(64) }, + }), + ); + + expect(tcp).toMatchObject({ + currentPublicIpv4: null, + lastErrorCode: 'desired_digest_conflict', + syncStatus: 'conflict', + }); + expect(udp).toMatchObject({ + currentPublicIpv4: '8.8.4.4', + currentPublicPort: 45102, + keeperStatus: 'active', + syncStatus: 'synced', + }); + expect(harness.state.appliedRevision).toBe('7'); + expect(harness.state.appliedSchemaVersion).toBe(2); + expect(harness.publishCommitted).toHaveBeenCalledTimes(1); + expect(harness.operations.indexOf('transaction:commit')).toBeLessThan( + harness.operations.indexOf('sse'), + ); + expect(harness.operations.indexOf('transaction:commit')).toBeLessThan( + harness.operations.indexOf('ddns:wake'), + ); + }); + + it('persists static, Keeper, and NATMap errors in independent fields', async () => { + const harness = createV2Harness(); + const [tcp, udp] = harness.channels; + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, { + [tcp.id]: { + errorCode: 'tcp_static_failed', + errorMessage: 'TCP static path failed', + natmapErrorCode: 'natmap_callback_stale', + natmapErrorMessage: 'NATMap callback is stale', + natmapStatus: 'failed', + syncStatus: 'failed', + }, + [udp.id]: { + errorCode: 'udp_static_failed', + errorMessage: 'UDP static path failed', + keeperErrorCode: 'stun_probe_failed', + keeperErrorMessage: 'STUN probe failed', + keeperStatus: 'failed', + syncStatus: 'failed', + }, + }), + ); + + expect(tcp).toMatchObject({ + lastErrorCode: 'tcp_static_failed', + lastErrorMessage: 'TCP static path failed', + natmapLastErrorCode: 'natmap_callback_stale', + natmapLastErrorMessage: 'NATMap callback is stale', + }); + expect(udp).toMatchObject({ + keeperLastErrorCode: 'stun_probe_failed', + keeperLastErrorMessage: 'STUN probe failed', + lastErrorCode: 'udp_static_failed', + lastErrorMessage: 'UDP static path failed', + }); + }); + + it('rejects a wrong outer snapshot digest without mutating either channel', async () => { + const harness = createV2Harness(); + + await expect( + harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, {}, { snapshotDigest: 'f'.repeat(64) }), + ), + ).rejects.toThrow('snapshot digest'); + + expect(harness.channels[0].currentPublicIpv4).toBeNull(); + expect(harness.channels[1].currentPublicIpv4).toBeNull(); + expect(harness.state.appliedRevision).toBe('0'); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + }); + + it('does not let an older validation replace a newer current tuple', async () => { + const harness = createV2Harness(); + const tcp = harness.channels[0]; + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness), + ); + harness.publishCommitted.mockClear(); + harness.requestDdnsReconcile.mockClear(); + const olderEvidence = v2Endpoint('tcp_natmap', { + observedAt: '2099-07-26T23:59:59.000Z', + publicIpv4: '1.1.1.1', + publicPort: 45111, + validatedAt: '2099-07-27T00:00:04.000Z', + validUntil: '2099-07-27T00:01:00.000Z', + }); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + [tcp.id]: { + candidateEndpoint: olderEvidence, + currentEndpoint: olderEvidence, + lastObservedEndpoint: olderEvidence, + }, + }, + { reportedAt: '2099-07-27T00:00:20.000Z' }, + ), + ); + + expect(tcp.currentPublicIpv4).toBe('8.8.8.8'); + expect(tcp.currentPublicPort).toBe(45101); + expect(tcp.currentValidatedAt?.toISOString()).toBe( + '2099-07-27T00:00:05.000Z', + ); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + }); + + it('ignores an older same-revision report instead of regressing candidate and runtime state', async () => { + const harness = createV2Harness(); + const tcp = harness.channels[0]; + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, {}, { reportedAt: '2099-07-27T00:00:30.000Z' }), + ); + harness.publishCommitted.mockClear(); + harness.requestDdnsReconcile.mockClear(); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + [tcp.id]: { + candidateEndpoint: undefined, + currentEndpoint: undefined, + errorCode: 'old_failure', + errorMessage: 'stale report', + lastObservedEndpoint: undefined, + natmapStatus: 'failed', + syncStatus: 'failed', + }, + }, + { reportedAt: '2099-07-27T00:00:20.000Z' }, + ), + ); + + expect(tcp).toMatchObject({ + candidatePublicIpv4: '8.8.8.8', + candidatePublicPort: 45101, + currentPublicIpv4: '8.8.8.8', + currentPublicPort: 45101, + lastErrorCode: null, + natmapStatus: 'active', + syncStatus: 'synced', + }); + expect((tcp as any).lastReportedAt?.toISOString()).toBe( + '2099-07-27T00:00:30.000Z', + ); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + }); + + it('uses the raw RFC3339Nano waterline for same-millisecond reports', async () => { + const harness = createV2Harness(); + const tcp = harness.channels[0]; + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, {}, { reportedAt: '2099-07-27T00:00:30.000900Z' }), + ); + harness.publishCommitted.mockClear(); + harness.requestDdnsReconcile.mockClear(); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + [tcp.id]: { + candidateEndpoint: undefined, + currentEndpoint: undefined, + errorCode: 'old_failure', + errorMessage: 'stale report', + lastObservedEndpoint: undefined, + natmapStatus: 'failed', + syncStatus: 'failed', + }, + }, + { reportedAt: '2099-07-27T00:00:30.000100Z' }, + ), + ); + + expect(tcp).toMatchObject({ + currentPublicIpv4: '8.8.8.8', + currentPublicPort: 45101, + lastErrorCode: null, + lastReportedAtWire: '2099-07-27T00:00:30.000900Z', + natmapStatus: 'active', + syncStatus: 'synced', + }); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + }); + + it('does not apply an old revision report waterline to a newer snapshot revision', async () => { + const harness = createV2Harness(); + const tcp = harness.channels[0]; + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, {}, { reportedAt: '2099-07-27T00:00:30.000Z' }), + ); + harness.state.desiredRevision = '8'; + harness.state.desiredIssuedAt = new KtDateTime( + '2099-07-27T00:00:31.000Z', + ); + for (const channel of harness.channels) { + channel.desiredRevision = '8'; + channel.desiredIssuedAt = harness.state.desiredIssuedAt; + } + const nextEndpoint = v2Endpoint('tcp_natmap', { + publicPort: 45111, + validatedAt: '2099-07-27T00:00:06.000Z', + validUntil: '2099-07-27T00:01:00.000Z', + }); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + [tcp.id]: { + candidateEndpoint: nextEndpoint, + currentEndpoint: nextEndpoint, + lastObservedEndpoint: nextEndpoint, + }, + }, + { reportedAt: '2099-07-27T00:00:10.000Z' }, + ), + ); + + expect(harness.state.appliedRevision).toBe('8'); + expect(tcp).toMatchObject({ + currentPublicPort: 45111, + lastReportedAtWire: '2099-07-27T00:00:10.000Z', + reportedRevision: '8', + syncStatus: 'synced', + }); + }); + + it('soft-deletes a logical group only after every protocol channel confirms absence', async () => { + const harness = createV2Harness(); + const [tcp, udp] = harness.channels; + Object.assign(tcp, { + desiredPresence: 'absent', + natmapDesiredEnabled: false, + }); + Object.assign(udp, { + desiredPresence: 'absent', + keeperDesiredEnabled: false, + }); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness, { + [udp.id]: { + routePresent: true, + routerPresent: true, + syncStatus: 'deleting', + }, + }), + ); + + expect(tcp.isDeleted).toBe(true); + expect(udp.isDeleted).toBe(false); + expect(harness.groups[0].isDeleted).toBe(false); + expect(harness.state.desiredRevision).toBe('8'); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness), + ); + + expect(udp.isDeleted).toBe(true); + expect(harness.groups[0].isDeleted).toBe(true); + expect(harness.state.desiredRevision).toBe('9'); + }); + + it('rolls back v2 report state and emits no post-commit effects when persistence fails', async () => { + const harness = createV2Harness(); + harness.mappingSave.mockRejectedValueOnce( + new Error('channel persistence failed'), + ); + + await expect( + harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness), + ), + ).rejects.toThrow('channel persistence failed'); + + expect(harness.channels[0].currentPublicIpv4).toBeNull(); + expect(harness.histories).toHaveLength(0); + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + expect(harness.operations).toContain('transaction:rollback'); + }); + + it('renews validation for the same tuple without history, DDNS, or SSE churn', async () => { + const harness = createV2Harness(); + const tcp = harness.channels[0]; + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported(harness), + ); + const firstObservedAt = tcp.currentObservedAt?.toISOString(); + harness.publishCommitted.mockClear(); + harness.requestDdnsReconcile.mockClear(); + const renewed = v2Endpoint('tcp_natmap', { + observedAt: '2099-07-27T00:00:20.000Z', + validatedAt: '2099-07-27T00:00:20.000Z', + validUntil: '2099-07-27T00:01:20.000Z', + }); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + [tcp.id]: { + candidateEndpoint: renewed, + currentEndpoint: renewed, + lastObservedEndpoint: renewed, + }, + }, + { reportedAt: '2099-07-27T00:00:25.000Z' }, + ), + ); + + expect(tcp.currentObservedAt?.toISOString()).toBe(firstObservedAt); + expect(tcp.currentValidatedAt?.toISOString()).toBe( + '2099-07-27T00:00:20.000Z', + ); + expect(tcp.currentValidUntil?.toISOString()).toBe( + '2099-07-27T00:01:20.000Z', + ); + expect(harness.histories).toHaveLength(0); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + }); + }); + + describe('MQTT v2 endpoint events', () => { + it('locks Agent ownership before the v2 event channel row', async () => { + const harness = createV2Harness(); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/events', + v2EndpointEvent(), + ); + + expect(harness.stateFindOne).toHaveBeenCalledWith( + expect.objectContaining({ lock: { mode: 'pessimistic_write' } }), + ); + expect(harness.mappingFindOne).toHaveBeenCalledWith( + expect.objectContaining({ lock: { mode: 'pessimistic_write' } }), + ); + expect(harness.stateFindOne.mock.invocationCallOrder[0]).toBeLessThan( + harness.mappingFindOne.mock.invocationCallOrder[0], + ); + }); + + it('defers a UDP port-change Outbox until the matching report commits', async () => { + const harness = createV2Harness(); + harness.histories.push(v2EndpointHistory()); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/events', + v2EndpointEvent(), + ); + + expect(harness.histories).toHaveLength(2); + expect(harness.histories[1]).toMatchObject({ + endpointValidatedAt: new KtDateTime('2099-07-27T00:00:15.000Z'), + endpointValidUntil: new KtDateTime('2099-07-27T00:00:55.000Z'), + sourceRevision: '7', + }); + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + + const matchingEndpoint = v2Endpoint('udp_stun', { + publicPort: 45103, + validatedAt: '2099-07-27T00:00:15.000Z', + validUntil: '2099-07-27T00:00:55.000Z', + }); + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + '102': { + currentEndpoint: matchingEndpoint, + lastObservedEndpoint: matchingEndpoint, + }, + }, + { reportedAt: '2099-07-27T00:00:20.000Z' }, + ), + ); + + expect(harness.stagedEvents).toHaveLength(1); + expect(harness.deliveryCoordinator.requestDrain).toHaveBeenCalledTimes(1); + }); + + it('does not stage an old-revision report-first event even when the tuple matches', async () => { + const harness = createV2Harness(); + harness.histories.push(v2EndpointHistory()); + const matchingEndpoint = v2Endpoint('udp_stun', { + publicPort: 45103, + validatedAt: '2099-07-27T00:00:15.000Z', + validUntil: '2099-07-27T00:00:55.000Z', + }); + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + '102': { + currentEndpoint: matchingEndpoint, + lastObservedEndpoint: matchingEndpoint, + }, + }, + { reportedAt: '2099-07-27T00:00:20.000Z' }, + ), + ); + harness.state.desiredRevision = '8'; + harness.state.desiredIssuedAt = new KtDateTime( + '2099-07-27T00:00:21.000Z', + ); + for (const channel of harness.channels) { + channel.desiredRevision = '8'; + channel.desiredIssuedAt = harness.state.desiredIssuedAt; + } + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + '102': { + currentEndpoint: matchingEndpoint, + lastObservedEndpoint: matchingEndpoint, + }, + }, + { reportedAt: '2099-07-27T00:00:25.000Z' }, + ), + ); + harness.stagedEvents.splice(0); + harness.deliveryCoordinator.requestDrain.mockClear(); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/events', + v2EndpointEvent({ revision: 7 }), + ); + + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + }); + + it('does not stage an old event-first history against a newer revision with the same tuple', async () => { + const harness = createV2Harness(); + harness.histories.push(v2EndpointHistory()); + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/events', + v2EndpointEvent(), + ); + harness.state.desiredRevision = '8'; + harness.state.desiredIssuedAt = new KtDateTime( + '2099-07-27T00:00:17.000Z', + ); + for (const channel of harness.channels) { + channel.desiredRevision = '8'; + channel.desiredIssuedAt = harness.state.desiredIssuedAt; + } + const matchingEndpoint = v2Endpoint('udp_stun', { + publicPort: 45103, + validatedAt: '2099-07-27T00:00:15.000Z', + validUntil: '2099-07-27T00:00:55.000Z', + }); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + '102': { + currentEndpoint: matchingEndpoint, + lastObservedEndpoint: matchingEndpoint, + }, + }, + { reportedAt: '2099-07-27T00:00:20.000Z' }, + ), + ); + + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + }); + + it('does not stage a report-first event whose lease differs from current', async () => { + const harness = createV2Harness(); + harness.histories.push(v2EndpointHistory()); + const currentEndpoint = v2Endpoint('udp_stun', { + publicPort: 45103, + validatedAt: '2099-07-27T00:00:18.000Z', + validUntil: '2099-07-27T00:00:58.000Z', + }); + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + '102': { + currentEndpoint, + lastObservedEndpoint: currentEndpoint, + }, + }, + { reportedAt: '2099-07-27T00:00:20.000Z' }, + ), + ); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/events', + v2EndpointEvent(), + ); + + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + }); + + it('does not correlate leases that differ only below millisecond precision', async () => { + const harness = createV2Harness(); + const endpoint = v2Endpoint('udp_stun', { + observedAt: '2099-07-27T00:00:00.000100Z', + publicPort: 45103, + validatedAt: '2099-07-27T00:00:15.000100Z', + validUntil: '2099-07-27T00:00:55.000100Z', + }); + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + '102': { + currentEndpoint: endpoint, + lastObservedEndpoint: endpoint, + }, + }, + { reportedAt: '2099-07-27T00:00:20.000100Z' }, + ), + ); + harness.stagedEvents.splice(0); + harness.deliveryCoordinator.requestDrain.mockClear(); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/events', + v2EndpointEvent({ + endpoint: { + ...endpoint, + validatedAt: '2099-07-27T00:00:15.000900Z', + }, + }), + ); + + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + }); + + it('rolls back matching-report current and Outbox without erasing the previously committed event history', async () => { + const harness = createV2Harness(); + harness.histories.push( + Object.assign(new NetworkEndpointHistory(), { + eventId: 'v2-endpoint-event-1', + eventType: 'published', + firstObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + id: '1', + lastObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + mappingId: '102', + mechanism: 'udp_stun', + occurredAt: new KtDateTime('2099-07-27T00:00:01.000Z'), + publicIpv4: '8.8.4.4', + publicPort: 45102, + }), + ); + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/events', + v2EndpointEvent(), + ); + harness.publishCommitted.mockClear(); + harness.requestDdnsReconcile.mockClear(); + harness.deliveryCoordinator.requestDrain.mockClear(); + harness.operations.splice(0); + harness.mappingSave + .mockImplementationOnce(async (value) => value) + .mockRejectedValueOnce(new Error('UDP channel persistence failed')); + const matchingEndpoint = v2Endpoint('udp_stun', { + publicPort: 45103, + validatedAt: '2099-07-27T00:00:15.000Z', + validUntil: '2099-07-27T00:00:55.000Z', + }); + + await expect( + harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + '102': { + currentEndpoint: matchingEndpoint, + lastObservedEndpoint: matchingEndpoint, + }, + }, + { reportedAt: '2099-07-27T00:00:20.000Z' }, + ), + ), + ).rejects.toThrow('UDP channel persistence failed'); + + expect(harness.channels[1].currentPublicIpv4).toBeNull(); + expect(harness.histories).toHaveLength(2); + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + expect(harness.operations).toContain('transaction:rollback'); + expect(harness.operations).not.toContain('transaction:commit'); + }); + + it('stages a report-first UDP event only after it matches the committed current tuple', async () => { + const harness = createV2Harness(); + harness.histories.push( + Object.assign(new NetworkEndpointHistory(), { + eventId: 'v2-endpoint-event-1', + eventType: 'published', + firstObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + id: '1', + lastObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + mappingId: '102', + mechanism: 'udp_stun', + occurredAt: new KtDateTime('2099-07-27T00:00:01.000Z'), + publicIpv4: '8.8.4.4', + publicPort: 45102, + }), + ); + const matchingEndpoint = v2Endpoint('udp_stun', { + publicPort: 45103, + validatedAt: '2099-07-27T00:00:15.000Z', + validUntil: '2099-07-27T00:00:55.000Z', + }); + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/reported', + v2Reported( + harness, + { + '102': { + currentEndpoint: matchingEndpoint, + lastObservedEndpoint: matchingEndpoint, + }, + }, + { reportedAt: '2099-07-27T00:00:20.000Z' }, + ), + ); + expect(harness.stagedEvents).toHaveLength(0); + harness.deliveryCoordinator.requestDrain.mockClear(); + + await harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/events', + v2EndpointEvent(), + ); + + expect(harness.stagedEvents).toHaveLength(1); + expect(harness.deliveryCoordinator.requestDrain).toHaveBeenCalledTimes(1); + }); + + it('records TCP NATMap history without using the UDP STUN message source', async () => { + const harness = createV2Harness(); + + 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.histories).toHaveLength(1); + expect(harness.histories[0]).toMatchObject({ + mappingId: '101', + mechanism: 'tcp_natmap', + }); + 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', { + publicPort: 45103, + validatedAt: '2099-07-27T00:00:15.000Z', + validUntil: '2099-07-27T00:00:55.000Z', + }); + Object.assign(harness.channels[1], { + currentEndpointIdentity: endpointLeaseIdentityV2(currentEndpoint), + currentObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + currentPublicIpv4: '8.8.4.4', + currentPublicPort: 45103, + currentValidatedAt: new KtDateTime('2099-07-27T00:00:15.000Z'), + currentValidatedAtWire: '2099-07-27T00:00:15.000Z', + currentValidUntil: new KtDateTime('2099-07-27T00:00:55.000Z'), + reportedRevision: '7', + }); + harness.histories.push( + Object.assign(new NetworkEndpointHistory(), { + eventId: 'v2-endpoint-event-1', + eventType: 'published', + firstObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + id: '1', + lastObservedAt: new KtDateTime('2099-07-27T00:00:00.000Z'), + mappingId: '102', + mechanism: 'udp_stun', + occurredAt: new KtDateTime('2099-07-27T00:00:01.000Z'), + publicIpv4: '8.8.4.4', + publicPort: 45102, + }), + ); + harness.stager.stage.mockImplementationOnce(async (_manager, input) => { + harness.stagedEvents.push(input); + throw new Error('v2 outbox unavailable'); + }); + + await expect( + harness.service.consumeMessage( + 'kt/network/v2/agents/nas-main/events', + v2EndpointEvent(), + ), + ).rejects.toThrow('v2 outbox unavailable'); + + expect(harness.histories).toHaveLength(1); + expect(harness.stagedEvents).toHaveLength(0); + expect(harness.publishCommitted).not.toHaveBeenCalled(); + expect(harness.requestDdnsReconcile).not.toHaveBeenCalled(); + expect(harness.deliveryCoordinator.requestDrain).not.toHaveBeenCalled(); + expect(harness.operations).toContain('transaction:rollback'); + }); + }); + it('subscribes to v2 status, persists capability before desired schema activation, and keeps v2 status authoritative', async () => { const harness = createHarness(); (harness.service as any).configService.get = (key: string) => @@ -1521,6 +2913,8 @@ describe('NetworkAgentMqttService', () => { harness.client.emit('connect'); expect(harness.client.subscribe).toHaveBeenCalledWith( expect.objectContaining({ + 'kt/network/v2/agents/nas-main/events': { qos: 1 }, + 'kt/network/v2/agents/nas-main/reported': { qos: 1 }, 'kt/network/v2/agents/nas-main/status': { qos: 1 }, }), expect.any(Function), @@ -1610,43 +3004,15 @@ describe('NetworkAgentMqttService', () => { expect(harness.state.publishedSchemaVersion).toBe(2); }); - it('allows explicit v2 downgrade only after every release, snapshot, group, and TCP tombstone gate passes', async () => { + it('never performs an API-only downgrade after the Agent may have latched v2 ownership', async () => { const accepted = createDowngradeHarness(); - await expect(accepted.service.requestV2Downgrade()).resolves.toBe(true); - expect(accepted.state.desiredSchemaVersion).toBe(1); - expect(accepted.groupFind).toHaveBeenCalledWith( - expect.objectContaining({ lock: { mode: 'pessimistic_read' } }), - ); - expect(accepted.channelFind).toHaveBeenCalledWith( - expect.objectContaining({ lock: { mode: 'pessimistic_read' } }), - ); + accepted.state.appliedSchemaVersion = 2; - const releaseOn = createDowngradeHarness('on'); - await expect(releaseOn.service.requestV2Downgrade()).resolves.toBe(false); - const unpublished = createDowngradeHarness(); - unpublished.state.appliedRevision = '6'; - await expect(unpublished.service.requestV2Downgrade()).resolves.toBe(false); - const mixedGroup = createDowngradeHarness(); - mixedGroup.groups[0].protocolMode = 'tcp_udp'; - await expect(mixedGroup.service.requestV2Downgrade()).resolves.toBe(false); - const tcpChannel = createDowngradeHarness(); - tcpChannel.tcpChannels.push( - Object.assign(new NetworkPortForward(), { - desiredPresence: 'present', - isDeleted: false, - protocol: 'tcp', - }), - ); - await expect(tcpChannel.service.requestV2Downgrade()).resolves.toBe(false); - const tcpTombstone = createDowngradeHarness(); - tcpTombstone.tcpChannels.push( - Object.assign(new NetworkPortForward(), { - desiredPresence: 'absent', - isDeleted: false, - protocol: 'tcp', - }), - ); - await expect(tcpTombstone.service.requestV2Downgrade()).resolves.toBe(false); + await expect(accepted.service.requestV2Downgrade()).resolves.toBe(false); + + expect(accepted.state.desiredSchemaVersion).toBe(2); + expect(accepted.groupFind).not.toHaveBeenCalled(); + expect(accepted.channelFind).not.toHaveBeenCalled(); }); }); @@ -1668,6 +3034,7 @@ function createDowngradeHarness(mode = 'off') { const state = Object.assign(new NetworkAgentState(), { agentId: 'nas-main', appliedRevision: '7', + appliedSchemaVersion: 2, desiredRevision: '7', desiredSchemaVersion: 2, publishedRevision: '7', @@ -1679,9 +3046,24 @@ function createDowngradeHarness(mode = 'off') { protocolMode: 'udp', }), ]; - const tcpChannels: NetworkPortForward[] = []; + const channels = [ + Object.assign(new NetworkPortForward(), { + desiredPresence: 'present', + desiredRevision: '7', + isDeleted: false, + protocol: 'udp', + reportedRevision: '7', + syncStatus: 'synced', + }), + ]; const groupFind = jest.fn(async () => groups); - const channelFind = jest.fn(async () => tcpChannels); + const channelFind = jest.fn(async ({ where } = {} as any) => + channels.filter((channel) => + Object.entries(where || {}).every( + ([key, value]) => channel[key as keyof NetworkPortForward] === value, + ), + ), + ); const stateRepository = { findOne: async () => state, save: jest.fn(async (value) => Object.assign(state, value)), @@ -1702,10 +3084,12 @@ function createDowngradeHarness(mode = 'off') { NETWORK_TCP_NATMAP_RELEASE_MODE: mode, })[key], } as ConfigService, - { transaction: async (work) => await work(manager) } as unknown as DataSource, + { + transaction: async (work) => await work(manager), + } as unknown as DataSource, { publishCommitted: jest.fn() } as never, { stage: jest.fn() } as never, { requestDrain: jest.fn() } as never, ); - return { channelFind, groupFind, groups, service, state, tcpChannels }; + return { channelFind, channels, groupFind, groups, service, state }; } diff --git a/test/admin/network-management/network-agent-v2.types.spec.ts b/test/admin/network-management/network-agent-v2.types.spec.ts index cddffc0..5439d79 100644 --- a/test/admin/network-management/network-agent-v2.types.spec.ts +++ b/test/admin/network-management/network-agent-v2.types.spec.ts @@ -5,6 +5,8 @@ import { buildDesiredSnapshotV2, canonicalDesiredChannelDigestV2, canonicalDesiredSnapshotDigestV2, + compareNetworkV2Timestamps, + endpointLeaseIdentityV2, parseDesiredSnapshotV2, parseEndpointEventV2, parseReportedSnapshotV2, @@ -36,6 +38,39 @@ const eventValue = () => ({ }); describe('network agent MQTT v2 contract', () => { + it('compares RFC3339Nano instants without millisecond truncation', () => { + expect( + compareNetworkV2Timestamps( + '2026-07-27T00:00:00.000900Z', + '2026-07-27T00:00:00.000100Z', + ), + ).toBeGreaterThan(0); + expect( + compareNetworkV2Timestamps( + '2026-07-27T08:00:00.123456789+08:00', + '2026-07-27T00:00:00.123456789Z', + ), + ).toBe(0); + }); + + it('keeps nanosecond-distinct endpoint leases as distinct identities', () => { + const endpoint = { + mechanism: 'udp_stun' as const, + observedAt: '2026-07-27T00:00:00.000100Z', + publicIpv4: '8.8.8.8', + publicPort: 8213, + validatedAt: '2026-07-27T00:00:01.000100Z', + validUntil: '2026-07-27T00:01:00.000100Z', + }; + + expect(endpointLeaseIdentityV2(endpoint)).not.toBe( + endpointLeaseIdentityV2({ + ...endpoint, + validatedAt: '2026-07-27T00:00:01.000900Z', + }), + ); + }); + it('parses the shared desired fixture and validates both literal digests', () => { const desired = parseDesiredSnapshotV2( rawFixture('network-v2-desired.json'), @@ -55,8 +90,14 @@ describe('network agent MQTT v2 contract', () => { ['unknown field', (value: any) => (value.extra = true)], ['schema/body mismatch', (value: any) => (value.schemaVersion = 1)], ['numeric ID drift', (value: any) => (value.channels[0].channelId = 2)], - ['UDP NATMap pair', (value: any) => (value.channels[0].natmapDesiredEnabled = true)], - ['TCP Keeper pair', (value: any) => (value.channels[1].keeperDesiredEnabled = true)], + [ + 'UDP NATMap pair', + (value: any) => (value.channels[0].natmapDesiredEnabled = true), + ], + [ + 'TCP Keeper pair', + (value: any) => (value.channels[1].keeperDesiredEnabled = true), + ], ])('rejects %s before v2 messages reach transport', (_, patch) => { const value = fixture('network-v2-desired.json') as any; patch(value); @@ -79,31 +120,45 @@ describe('network agent MQTT v2 contract', () => { }); it('accepts v2 status and matching endpoint events before transport routing', () => { - expect( - parseStatusSnapshotV2(JSON.stringify(statusValue())), - ).toMatchObject({ online: true }); - expect( - parseEndpointEventV2(JSON.stringify(eventValue())), - ).toMatchObject({ type: 'withdrawn' }); + expect(parseStatusSnapshotV2(JSON.stringify(statusValue()))).toMatchObject({ + online: true, + }); + expect(parseEndpointEventV2(JSON.stringify(eventValue()))).toMatchObject({ + type: 'withdrawn', + }); }); it.each([ - ['desired', parseDesiredSnapshotV2, () => rawFixture('network-v2-desired.json')], - ['reported', parseReportedSnapshotV2, () => rawFixture('network-v2-reported.json')], + [ + 'desired', + parseDesiredSnapshotV2, + () => rawFixture('network-v2-desired.json'), + ], + [ + 'reported', + parseReportedSnapshotV2, + () => rawFixture('network-v2-reported.json'), + ], ['status', parseStatusSnapshotV2, () => JSON.stringify(statusValue())], ['event', parseEndpointEventV2, () => JSON.stringify(eventValue())], - ])('enforces raw size and trailing JSON for %s', (_, parser, validPayload) => { - expect(parser(validPayload())).toBeDefined(); - expect(() => parser(`${validPayload()}{}`)).toThrow(); - expect(() => - parser(Buffer.alloc(NETWORK_AGENT_V2_MAX_MESSAGE_BYTES + 1)), - ).toThrow(); - }); + ])( + 'enforces raw size and trailing JSON for %s', + (_, parser, validPayload) => { + expect(parser(validPayload())).toBeDefined(); + expect(() => parser(`${validPayload()}{}`)).toThrow(); + expect(() => + parser(Buffer.alloc(NETWORK_AGENT_V2_MAX_MESSAGE_BYTES + 1)), + ).toThrow(); + }, + ); it('rejects invalid RFC3339 calendar dates and explicit null optionals', () => { expect(() => parseStatusSnapshotV2( - JSON.stringify({ ...statusValue(), observedAt: '2026-02-29T00:00:00Z' }), + JSON.stringify({ + ...statusValue(), + observedAt: '2026-02-29T00:00:00Z', + }), ), ).toThrow(); expect(() => @@ -112,9 +167,7 @@ describe('network agent MQTT v2 contract', () => { ), ).toThrow(); expect(() => - parseEndpointEventV2( - JSON.stringify({ ...eventValue(), endpoint: null }), - ), + parseEndpointEventV2(JSON.stringify({ ...eventValue(), endpoint: null })), ).toThrow(); }); @@ -163,12 +216,15 @@ describe('network agent MQTT v2 contract', () => { const desiredWithName = (name: string) => { const value = fixture('network-v2-desired.json'); value.channels[0].name = name; - value.channels[0].channelDesiredDigest = - canonicalDesiredChannelDigestV2(value.channels[0]); + value.channels[0].channelDesiredDigest = canonicalDesiredChannelDigestV2( + value.channels[0], + ); value.snapshotDigest = canonicalDesiredSnapshotDigestV2(value); return JSON.stringify(value); }; - expect(parseDesiredSnapshotV2(desiredWithName('界'.repeat(42)))).toBeDefined(); + expect( + parseDesiredSnapshotV2(desiredWithName('界'.repeat(42))), + ).toBeDefined(); expect(() => parseDesiredSnapshotV2(desiredWithName('界'.repeat(43))), ).toThrow(); @@ -216,9 +272,14 @@ describe('network agent MQTT v2 contract', () => { const desired = buildDesiredSnapshotV2(state, channels); expect(desired).toMatchObject({ schemaVersion: 2, snapshotRevision: 7 }); expect(desired.channels[0].channelDesiredDigest).toMatch(/^[0-9a-f]{64}$/); - expect(desired.snapshotDigest).toBe(canonicalDesiredSnapshotDigestV2(desired)); + expect(desired.snapshotDigest).toBe( + canonicalDesiredSnapshotDigestV2(desired), + ); expect(() => - buildDesiredSnapshotV2({ ...state, desiredRevision: '9007199254740992' }, channels), + buildDesiredSnapshotV2( + { ...state, desiredRevision: '9007199254740992' }, + channels, + ), ).toThrow(); }); @@ -241,8 +302,14 @@ describe('network agent MQTT v2 contract', () => { protocol: 'tcp' as const, }); - const desired = buildDesiredSnapshotV2(state, [channel('20'), channel('10')]); - expect(desired.channels.map((item) => item.channelId)).toEqual(['10', '20']); + const desired = buildDesiredSnapshotV2(state, [ + channel('20'), + channel('10'), + ]); + expect(desired.channels.map((item) => item.channelId)).toEqual([ + '10', + '20', + ]); expect(parseDesiredSnapshotV2(JSON.stringify(desired))).toEqual(desired); }); @@ -265,7 +332,9 @@ describe('network agent MQTT v2 contract', () => { protocol: 'tcp' as const, }; - expect(() => buildDesiredSnapshotV2(state, [channel, { ...channel }])).toThrow(); + expect(() => + buildDesiredSnapshotV2(state, [channel, { ...channel }]), + ).toThrow(); expect(() => buildDesiredSnapshotV2(state, [{ ...channel, externalPort: 0 }]), ).toThrow(); diff --git a/test/admin/network-management/network-management.entity.spec.ts b/test/admin/network-management/network-management.entity.spec.ts index 89f8e44..48a704a 100644 --- a/test/admin/network-management/network-management.entity.spec.ts +++ b/test/admin/network-management/network-management.entity.spec.ts @@ -50,7 +50,10 @@ describe('network management persistence module', () => { (column) => column.target === NetworkEndpointHistory, ); const featureProviders = ( - Reflect.getMetadata(MODULE_METADATA.IMPORTS, AdminPlatformConfigModule) as { + Reflect.getMetadata( + MODULE_METADATA.IMPORTS, + AdminPlatformConfigModule, + ) as { module?: unknown; providers?: { provide?: unknown }[]; }[] @@ -67,46 +70,130 @@ describe('network management persistence module', () => { ]), ); expect( - groupColumns.find((column) => column.propertyName === 'createTime') - ?.mode, + groupColumns.find((column) => column.propertyName === 'createTime')?.mode, ).toBe('createDate'); expect( - groupColumns.find((column) => column.propertyName === 'updateTime') - ?.mode, + groupColumns.find((column) => column.propertyName === 'updateTime')?.mode, ).toBe('updateDate'); expect(channelColumns.map((column) => column.options.name)).toEqual( expect.arrayContaining([ 'active_group_protocol_key', 'candidate_observed_at', 'candidate_validated_at', + 'candidate_validated_at_wire', + 'current_endpoint_identity', 'current_validated_at', + 'current_validated_at_wire', 'group_id', 'last_observed_validated_at', + 'last_observed_validated_at_wire', 'last_published_at', + 'last_reported_at', + 'last_reported_at_wire', 'natmap_desired_enabled', ]), ); expect( - channelColumns.find((column) => column.propertyName === 'groupId')?.options - .type, + channelColumns.find((column) => column.propertyName === 'groupId') + ?.options.type, ).toBe('bigint'); expect( channelColumns.find( (column) => column.propertyName === 'candidateObservedAt', )?.options.type, ).toBe('datetime'); + expect( + channelColumns.find((column) => column.propertyName === 'lastReportedAt') + ?.options, + ).toEqual( + expect.objectContaining({ + name: 'last_reported_at', + nullable: true, + precision: 6, + type: 'datetime', + }), + ); + expect( + channelColumns.find( + (column) => column.propertyName === 'lastReportedAtWire', + )?.options, + ).toEqual( + expect.objectContaining({ + length: 64, + name: 'last_reported_at_wire', + nullable: true, + }), + ); expect(agentColumns.map((column) => column.options.name)).toEqual( expect.arrayContaining([ + 'applied_schema_version', 'desired_schema_version', 'published_schema_version', 'max_supported_schema_version', 'tcp_natmap_capable', ]), ); + expect( + agentColumns.find((column) => column.propertyName === 'version')?.options + .length, + ).toBe(128); + for (const propertyName of [ + 'lastMqttErrorMessage', + 'lastReconcileErrorMessage', + ]) { + expect( + agentColumns.find((column) => column.propertyName === propertyName) + ?.options.length, + ).toBe(512); + } expect( historyColumns.find((column) => column.propertyName === 'mechanism') ?.options.name, ).toBe('mechanism'); + expect( + historyColumns.find((column) => column.propertyName === 'sourceRevision') + ?.options, + ).toEqual( + expect.objectContaining({ + name: 'source_revision', + nullable: true, + type: 'bigint', + }), + ); + expect( + historyColumns.find( + (column) => column.propertyName === 'endpointIdentity', + )?.options, + ).toEqual( + expect.objectContaining({ + length: 64, + name: 'endpoint_identity', + nullable: true, + }), + ); + expect( + Reflect.getMetadata( + 'design:type', + NetworkEndpointHistory.prototype, + 'sourceRevision', + ), + ).toBe(String); + for (const [propertyName, columnName] of [ + ['endpointValidatedAt', 'endpoint_validated_at'], + ['endpointValidUntil', 'endpoint_valid_until'], + ]) { + expect( + historyColumns.find((column) => column.propertyName === propertyName) + ?.options, + ).toEqual( + expect.objectContaining({ + name: columnName, + nullable: true, + precision: 6, + type: 'datetime', + }), + ); + } expect(featureProviders.map((provider) => provider.provide)).toContain( getRepositoryToken(NetworkPortForwardGroup), ); @@ -119,11 +206,10 @@ describe('network management persistence module', () => { ), ).toBe(true); expect( - storage.relations.filter( - (relation) => - [NetworkPortForward, NetworkPortForwardGroup].includes( - relation.target as never, - ), + storage.relations.filter((relation) => + [NetworkPortForward, NetworkPortForwardGroup].includes( + relation.target as never, + ), ), ).toHaveLength(0); }); diff --git a/test/admin/network-management/network-tcp-natmap-v2-sql.spec.ts b/test/admin/network-management/network-tcp-natmap-v2-sql.spec.ts index fa4a921..5de151f 100644 --- a/test/admin/network-management/network-tcp-natmap-v2-sql.spec.ts +++ b/test/admin/network-management/network-tcp-natmap-v2-sql.spec.ts @@ -68,10 +68,7 @@ type FixtureRow = Record; function parseFixtureRows(sql: string): Record { const rows: Record = {}; - const normalized = sql - .replace(/--.*$/gm, '') - .replace(/`/g, '') - .trim(); + const normalized = sql.replace(/--.*$/gm, '').replace(/`/g, '').trim(); const inserts = normalized.matchAll( /insert into ([a-z0-9_]+)\s*\(([\s\S]*?)\)\s*values\s*\(([\s\S]*?)\);/gi, ); @@ -152,11 +149,17 @@ function expectFinalSchema(sql: string): void { 'candidate_public_port int null', 'candidate_observed_at datetime(6) null', 'candidate_validated_at datetime(6) null', + 'candidate_validated_at_wire varchar(64) null', + 'current_endpoint_identity char(64) null', 'current_validated_at datetime(6) null', + 'current_validated_at_wire varchar(64) null', 'last_observed_validated_at datetime(6) null', + 'last_observed_validated_at_wire varchar(64) null', 'last_published_public_ipv4 varchar(15) null', 'last_published_public_port int null', 'last_published_at datetime(6) null', + 'last_reported_at datetime(6) null', + 'last_reported_at_wire varchar(64) null', ]) { expect(channel).toContain(column); } @@ -171,11 +174,25 @@ function expectFinalSchema(sql: string): void { 'desired_schema_version int unsigned not null default 1', 'published_schema_version int unsigned not null default 1', 'max_supported_schema_version int unsigned not null default 1', + 'applied_schema_version int unsigned not null default 1', 'tcp_natmap_capable tinyint(1) not null default 0', + 'version varchar(128) null', + 'last_mqtt_error_message varchar(512) null', + 'last_reconcile_error_message varchar(512) null', ]) { expect(agent).toContain(column); } - expect(history).toContain("mechanism varchar(16) not null default 'udp_stun'"); + expect(history).toContain( + "mechanism varchar(16) not null default 'udp_stun'", + ); + expect(history).toContain('endpoint_identity char(64) null'); + for (const column of [ + 'source_revision bigint null', + 'endpoint_validated_at datetime(6) null', + 'endpoint_valid_until datetime(6) null', + ]) { + expect(history).toContain(column); + } } describe('TCP NATMap v2 schema SQL', () => { @@ -199,13 +216,28 @@ describe('TCP NATMap v2 schema SQL', () => { it('uses an additive idempotent migration with ordered group backfill', () => { const migration = readNormalizedSql('sql/network-tcp-natmap-v2.sql'); - expect(migration).toContain('create table if not exists network_port_forward_group'); + expect(migration).toContain( + 'create table if not exists network_port_forward_group', + ); expect(migration).toContain('information_schema.columns'); expect(migration).toContain('information_schema.statistics'); expect(migration).toContain('insert into network_port_forward_group'); - expect(migration).toContain('select channel.id, channel.name, channel.remark'); - expect(migration).toContain("'udp'"); - expect(migration).toContain('update network_port_forward channel set group_id = channel.id'); + expect(migration).toContain( + 'select channel.id, channel.name, channel.remark', + ); + expect(migration).toContain( + 'channel.internal_port, channel.protocol, channel.target_ipv4', + ); + expect(migration).toContain( + 'update network_port_forward channel set group_id = channel.id', + ); + expect(migration).toContain('modify column version varchar(128) null'); + expect(migration).toContain( + 'modify column last_mqtt_error_message varchar(512) null', + ); + expect(migration).toContain( + 'modify column last_reconcile_error_message varchar(512) null', + ); expect(migration).toContain( "active_group_protocol_key = case when channel.is_deleted = 0 then concat(channel.group_id, ':', channel.protocol) else null end", ); @@ -221,15 +253,26 @@ describe('TCP NATMap v2 schema SQL', () => { 'candidate_public_port', 'candidate_observed_at', 'candidate_validated_at', + 'candidate_validated_at_wire', + 'current_endpoint_identity', 'current_validated_at', + 'current_validated_at_wire', 'last_observed_validated_at', + 'last_observed_validated_at_wire', 'last_published_public_ipv4', 'last_published_public_port', 'last_published_at', + 'last_reported_at', + 'last_reported_at_wire', 'desired_schema_version', 'published_schema_version', 'max_supported_schema_version', + 'applied_schema_version', 'tcp_natmap_capable', + 'source_revision', + 'endpoint_validated_at', + 'endpoint_valid_until', + 'endpoint_identity', ]) { expect(migration).toContain(`column_name = '${columnName}'`); } @@ -271,9 +314,31 @@ describe('TCP NATMap v2 schema SQL', () => { expect(verify).toContain('from network_port_forward_group'); expect(verify).toContain("column_name = 'group_id'"); - expect(verify).toContain('uk_network_port_forward_active_group_protocol_key'); + expect(verify).toContain( + 'uk_network_port_forward_active_group_protocol_key', + ); expect(verify).toContain('idx_network_port_forward_group'); - expect(verify).not.toMatch(/\b(insert|update|delete|alter|drop|truncate)\b/); + expect(verify).toContain("column_name = 'last_reported_at'"); + expect(verify).toContain("column_name = 'last_reported_at_wire'"); + expect(verify).toContain("column_name = 'applied_schema_version'"); + expect(verify).toContain("column_name = 'version'"); + expect(verify).toContain("column_name = 'last_mqtt_error_message'"); + expect(verify).toContain("column_name = 'last_reconcile_error_message'"); + expect(verify).toContain("column_name = 'source_revision'"); + expect(verify).toContain("column_name = 'endpoint_validated_at'"); + expect(verify).toContain("column_name = 'endpoint_valid_until'"); + expect(verify).toContain("column_name = 'endpoint_identity'"); + expect(verify).not.toMatch( + /\b(insert|update|delete|alter|drop|truncate)\b/, + ); + }); + + it('backfills legacy groups only for channels whose group ID is still null', () => { + const migration = readNormalizedSql('sql/network-tcp-natmap-v2.sql'); + + expect(migration).toMatch( + /from network_port_forward channel left join network_port_forward_group grouped on grouped\.id = channel\.id where channel\.group_id is null and grouped\.id is null/, + ); }); it('preserves the sanitized existing 8213 UDP channel, history, and DDNS binding', () => { @@ -294,13 +359,16 @@ describe('TCP NATMap v2 schema SQL', () => { }; const afterHistory: FixtureRow = { ...beforeHistory, + endpoint_validated_at: null, + endpoint_valid_until: null, mechanism: 'udp_stun', + source_revision: null, }; const afterDdns: FixtureRow = { ...beforeDdns }; - expect(mutationAssignmentTargets(migration, 'network_port_forward')).toEqual( - ['active_group_protocol_key', 'group_id'], - ); + expect( + mutationAssignmentTargets(migration, 'network_port_forward'), + ).toEqual(['active_group_protocol_key', 'group_id']); expect( mutationAssignmentTargets(migration, 'network_endpoint_history'), ).toEqual(['mechanism']); @@ -316,9 +384,17 @@ describe('TCP NATMap v2 schema SQL', () => { 'group_id', ]), ).toEqual(beforeChannel); - expect(withoutProperties(afterHistory, ['mechanism'])).toEqual( - beforeHistory, - ); + expect( + withoutProperties(afterHistory, [ + 'endpoint_validated_at', + 'endpoint_valid_until', + 'mechanism', + 'source_revision', + ]), + ).toEqual(beforeHistory); + expect(afterHistory.source_revision).toBeNull(); + expect(afterHistory.endpoint_validated_at).toBeNull(); + expect(afterHistory.endpoint_valid_until).toBeNull(); expect(afterDdns).toEqual(beforeDdns); expect(afterChannel.id).toBe('2041600000000008213'); expect(afterChannel.active_key).toBe('udp:8213');