feat: 接入TCP NATMap上报与端点状态

This commit is contained in:
sunlei 2026-07-27 14:09:45 +08:00
parent 8ce72c1269
commit cd157e773f
16 changed files with 3779 additions and 317 deletions

60
API.md
View File

@ -111,17 +111,37 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
| `DELETE` | `/system/network/ddns/:id` | `super` | 删除本地绑定,不删除云端 DNS 记录 | | `DELETE` | `/system/network/ddns/:id` | `super` | 删除本地绑定,不删除云端 DNS 记录 |
| `POST` | `/system/network/ddns/:id/retry` | `super` | 手动重试一条已启用 DDNS 绑定 | | `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 展示与诊断。 端点历史行使用 `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-v1desired 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 Outboxhistory 耐久保存来源 revision 和由 mechanism、tuple、observed/validated/valid-until 原始值生成的完整 lease identitymatching reported 只有在 revision、组/协议/mechanism、tuple 和未被 JS `Date` 截断的 lease 身份完全相同时,才在 current 同一事务中以原 `eventId` stage 现有 STUN 事件,提交后 wake。report-first 使用同一精确关联门禁。TCP 在本阶段只写历史,不复用 UDP STUN 消息源。组删除只有所有通道分别确认 exact absent 后才完成先完成的通道单独软删除并清空活动键仍有通道的组继续保留。v1 单 UDP 组继续使用 helper/router/route/Keeper/current 的原有 exact-absence 门禁。
Admin 首次进入网络管理页通过 HTTP 读取快照,随后使用 `/system/network/events/stream` 接收 `network-state-changed`。API 只在 `reported`、`status`、`events` 对应事务提交且语义状态实际变化后发出事件MQTT QoS 1 重投、仅推进 `lastHeartbeatAt` 的状态心跳,以及相同 tuple 只推进 `validatedAt/validUntil` 的续租继续持久化,但不触发 history、SSE、DDNS 或消息 Outbox。公网 IP/端口、candidate/last-observed tuple、Keeper/NATMap/同步/错误/删除状态或 Agent 在线会话变化仍发布事件。一个入站消息不论改变多少通道都最多产生一次提交后 SSE。SSE 心跳只维持连接并复用最近一次真实状态事件 ID尚无状态事件时显式发送空 ID浏览器重连通过 `Last-Event-ID``lastEventId` 重放有限窗口,游标失效时收到一次 `snapshot-required` 并重新读取 HTTP 快照。前端不直接订阅 MQTT也不使用定时轮询。
TCP 通道使用独立 NATMap 开关UDP 通道使用独立 Keeper 开关TCP 不提供 STUNUDP 只有 `externalPort === internalPort` 时可启用 Keeper。`NETWORK_TCP_NATMAP_RELEASE_MODE=off|draining|canary|on` 控制 TCP 变更范围canary 端口来自 `NETWORK_TCP_NATMAP_CANARY_PORTS`。当前端点只有在 `currentValidUntil` 未过期时才返回为可用值;租约过期不会删除 candidate、last-observed、last-published 或 `network_endpoint_history`。API 不直接访问小米路由器、不修改 NAS 路由真实小米规则、FriendlyWrt DNAT/NATMap、raw UDP 与回程规则由固定 Agent/helper/Companion 处理。
自动 DDNS 支持 `A``AAAA`。A 记录绑定 `sourceType=port_forward_ipv4` 与一条合格的同源端口 UDP KeeperAAAA 记录固定使用 `sourceType=agent_ipv6`,不得携带 `portForwardId`。来源暂不可用时记录进入 `waiting_source`,不会向腾讯云写入空地址。协调器只修改腾讯云云解析 DNS 中已存在、已启用、默认线路且唯一的同类型记录,写入时保留 RecordId、线路和 TTL并回读相同 RecordId 验证结果DNS 值不包含端口。删除接口只删除本地自动更新绑定不删除云端记录。Provider 状态接口只返回开关、配置完整性和官方 provider 标识,不返回 SecretId、SecretKey 或 SDK 原始错误。 自动 DDNS 支持 `A``AAAA`。A 记录绑定 `sourceType=port_forward_ipv4` 与一条合格的同源端口 UDP KeeperAAAA 记录固定使用 `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` | | 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` | | 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` | | 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 | `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` | | 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_*` | | 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_*` | | NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| Env Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` | | Env Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
| Network | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_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` | | 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` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | | FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。 真实密码、Token、OAuth secret 和生产 env 不提交到 Git。

View File

@ -56,19 +56,19 @@ ci/ Jenkins Agent/Docker 辅助文件
主要配置分组: 主要配置分组:
| 分组 | 变量 | | 分组 | 变量 |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | | 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` | | 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` | | 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` | | 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_*` | | Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | | QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
| Environment Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` | | Environment Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
| Network Management | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_DDNS_DNSPOD_*`、`NETWORK_DDNS_RECONCILE_INTERVAL_MS`、`NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` | | 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` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | | FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。 `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` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。 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 发布 SSEQoS 1 幂等重投、`status` 心跳时间推进和 `reported` 租约时间续期仍写入数据库,但不触发页面刷新。公网 IP/端口、Keeper/同步/错误/删除状态或 Agent 在线会话变化仍发布事件。SSE 心跳复用最近一次真实状态事件游标,尚无状态事件时显式发送空游标,避免 Nest 自动生成的 ID 污染重放位置;有限重放缺口只要求一次 HTTP 快照。TCP 目前仅保存 CRUD 期望,真实路由器写入仍受设备协议证据门禁并回报 `tcp_router_write_gated`;只有外部端口等于内部端口的 UDP 记录允许启停 Keeper 和立即探测。当前公网端点受 `currentValidUntil` 租约约束,过期后列表隐藏当前值但保留最近观测与历史。生产发布同时把完整 `NETWORK_AGENT_*` 连接配置作为 Jenkins 私有 env 和 `/health/runtime` 必需项,任一项缺失时拒绝发布或报告运行态阻断,避免页面可见但 MQTT 控制链路未接线。 System 网络管理以 MySQL 中的逻辑端口转发组和 TCP/UDP 协议通道为唯一事实源。`super` 可在同一外部端口配置 `TCP`、`UDP` 或 `TCP+UDP`,并分别启停 TCP NATMap 与 UDP KeeperTCP 发布范围由 `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 只提交 historyUDP 端口变更必须等 matching current 在 reported 事务中提交后才写现有 STUN OutboxTCP 在独立消息源上线前只写 `tcp_natmap` history。event、report 与 ownership 切换统一按 Agent state、channel、history 顺序加锁history 耐久保存来源 revision 和完整 lease identity只有 revision、tuple 与未被时间精度截断的 lease 身份全部匹配才允许 stage避免迟到事件误投递。所有 SSE、DDNS reconcile、投递 wake 和 desired republish 都在数据库事务成功后最多触发一次;组只有所有协议通道都确认 exact absent 后才软删除。Agent 失联或 MQTT 暂不可用时合法请求仍保存为 pending恢复后按 revision 自动收敛;瞬时数据库错误或 SUBACK 失败依赖持久会话重投,非法负载确认后丢弃。当前公网端点受 `currentValidUntil` 约束,过期后列表隐藏 current但保留候选、最近观测、发布基线与历史。
同一模块提供腾讯云云解析 DNS 的双栈自动 DDNS。A 记录只从合格 UDP Keeper 的有效公网 IPv4 取值AAAA 记录只从在线 Agent 最近上报的全局 IPv6 取值DNS 值始终不包含端口。协调器只修改已存在、已启用、默认线路且唯一的 A/AAAA 记录,保留 RecordId、线路和 TTL并在写入后回读确认。删除 Admin 绑定只停止本地自动更新,不删除云端 DNS 记录。凭据只从 API 私有运行环境的 `NETWORK_DDNS_DNSPOD_SECRET_ID/SECRET_KEY` 读取,不进入 Admin、数据库、MQTT、Agent、日志或 Git`DNSPOD` 是腾讯云官方 SDK 的技术服务名。 同一模块提供腾讯云云解析 DNS 的双栈自动 DDNS。A 记录只从合格 UDP Keeper 的有效公网 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 的技术服务名。

View File

@ -34,6 +34,8 @@ CREATE TABLE IF NOT EXISTS `network_port_forward` (
`desired_revision` BIGINT NOT NULL DEFAULT 0, `desired_revision` BIGINT NOT NULL DEFAULT 0,
`desired_issued_at` DATETIME(6) NOT NULL, `desired_issued_at` DATETIME(6) NOT NULL,
`reported_revision` BIGINT NOT NULL DEFAULT 0, `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', `sync_status` VARCHAR(16) NOT NULL DEFAULT 'pending',
`keeper_status` VARCHAR(16) NOT NULL DEFAULT 'disabled', `keeper_status` VARCHAR(16) NOT NULL DEFAULT 'disabled',
`natmap_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_public_port` INT NULL,
`current_observed_at` DATETIME(6) NULL, `current_observed_at` DATETIME(6) NULL,
`current_validated_at` DATETIME(6) NULL, `current_validated_at` DATETIME(6) NULL,
`current_validated_at_wire` VARCHAR(64) NULL,
`current_valid_until` DATETIME(6) NULL, `current_valid_until` DATETIME(6) NULL,
`current_endpoint_identity` CHAR(64) NULL,
`last_observed_ipv4` VARCHAR(15) NULL, `last_observed_ipv4` VARCHAR(15) NULL,
`last_observed_port` INT NULL, `last_observed_port` INT NULL,
`last_observed_at` DATETIME(6) NULL, `last_observed_at` DATETIME(6) NULL,
`last_observed_validated_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_ipv4` VARCHAR(15) NULL,
`candidate_public_port` INT NULL, `candidate_public_port` INT NULL,
`candidate_observed_at` DATETIME(6) NULL, `candidate_observed_at` DATETIME(6) NULL,
`candidate_validated_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_ipv4` VARCHAR(15) NULL,
`last_published_public_port` INT NULL, `last_published_public_port` INT NULL,
`last_published_at` DATETIME(6) 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, `max_supported_schema_version` INT UNSIGNED NOT NULL DEFAULT 1,
`tcp_natmap_capable` TINYINT(1) NOT NULL DEFAULT 0, `tcp_natmap_capable` TINYINT(1) NOT NULL DEFAULT 0,
`applied_revision` BIGINT 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, `online` TINYINT(1) NOT NULL DEFAULT 0,
`version` VARCHAR(64) NULL, `version` VARCHAR(128) NULL,
`started_at` DATETIME(6) NULL, `started_at` DATETIME(6) NULL,
`last_heartbeat_at` DATETIME(6) NULL, `last_heartbeat_at` DATETIME(6) NULL,
`current_public_ipv6` VARCHAR(45) NULL, `current_public_ipv6` VARCHAR(45) NULL,
`current_ipv6_observed_at` DATETIME(3) NULL, `current_ipv6_observed_at` DATETIME(3) NULL,
`last_mqtt_error_code` VARCHAR(64) NULL, `last_mqtt_error_code` VARCHAR(64) NULL,
`last_mqtt_error_message` VARCHAR(500) NULL, `last_mqtt_error_message` VARCHAR(512) NULL,
`last_reconcile_error_code` VARCHAR(64) 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), `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), `update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (`agent_id`) PRIMARY KEY (`agent_id`)
@ -164,8 +171,12 @@ CREATE TABLE IF NOT EXISTS `network_endpoint_history` (
`mapping_id` BIGINT NOT NULL, `mapping_id` BIGINT NOT NULL,
`event_type` VARCHAR(16) NOT NULL, `event_type` VARCHAR(16) NOT NULL,
`mechanism` VARCHAR(16) NOT NULL DEFAULT 'udp_stun', `mechanism` VARCHAR(16) NOT NULL DEFAULT 'udp_stun',
`source_revision` BIGINT NULL,
`endpoint_identity` CHAR(64) NULL,
`public_ipv4` VARCHAR(15) NULL, `public_ipv4` VARCHAR(15) NULL,
`public_port` INT NULL, `public_port` INT NULL,
`endpoint_validated_at` DATETIME(6) NULL,
`endpoint_valid_until` DATETIME(6) NULL,
`first_observed_at` DATETIME(6) NOT NULL, `first_observed_at` DATETIME(6) NOT NULL,
`last_observed_at` DATETIME(6) NOT NULL, `last_observed_at` DATETIME(6) NOT NULL,
`occurred_at` DATETIME(6) NOT NULL, `occurred_at` DATETIME(6) NOT NULL,

View File

@ -25,6 +25,52 @@ WHERE TABLE_SCHEMA = DATABASE()
AND COLUMN_TYPE = 'bigint' AND COLUMN_TYPE = 'bigint'
AND IS_NULLABLE = 'NO'; 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 SELECT 'network_port_forward_v2_indexes' AS check_name, INDEX_NAME, NON_UNIQUE
FROM INFORMATION_SCHEMA.STATISTICS FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE() WHERE TABLE_SCHEMA = DATABASE()
@ -41,3 +87,14 @@ WHERE TABLE_SCHEMA = DATABASE()
AND COLUMN_NAME = 'mechanism' AND COLUMN_NAME = 'mechanism'
AND COLUMN_TYPE = 'varchar(16)' AND COLUMN_TYPE = 'varchar(16)'
AND IS_NULLABLE = 'NO'; 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')
);

View File

@ -72,6 +72,34 @@ PREPARE network_port_forward_natmap_status_stmt FROM @network_port_forward_natma
EXECUTE network_port_forward_natmap_status_stmt; EXECUTE network_port_forward_natmap_status_stmt;
DEALLOCATE PREPARE 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 := ( SET @network_tcp_natmap_v2_column_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' 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; EXECUTE network_tcp_natmap_v2_alter_stmt;
DEALLOCATE PREPARE 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 := ( SET @network_tcp_natmap_v2_column_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward' 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; EXECUTE network_tcp_natmap_v2_alter_stmt;
DEALLOCATE PREPARE 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 := ( SET @network_endpoint_history_mechanism_exists := (
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history' 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; EXECUTE network_endpoint_history_mechanism_stmt;
DEALLOCATE PREPARE 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` ( INSERT INTO `network_port_forward_group` (
`id`, `name`, `remark`, `external_port`, `internal_port`, `protocol_mode`, `id`, `name`, `remark`, `external_port`, `internal_port`, `protocol_mode`,
`target_ipv4`, `is_deleted`, `create_time`, `update_time` `target_ipv4`, `is_deleted`, `create_time`, `update_time`
) )
SELECT channel.id, channel.name, channel.remark, channel.external_port, 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 channel.create_time, channel.update_time
FROM `network_port_forward` channel FROM `network_port_forward` channel
LEFT JOIN `network_port_forward_group` grouped ON grouped.id = channel.id 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 UPDATE `network_port_forward` channel
SET `group_id` = channel.id SET `group_id` = channel.id

View File

@ -157,6 +157,8 @@ CREATE TABLE IF NOT EXISTS network_port_forward (
desired_revision BIGINT NOT NULL DEFAULT 0, desired_revision BIGINT NOT NULL DEFAULT 0,
desired_issued_at DATETIME(6) NOT NULL, desired_issued_at DATETIME(6) NOT NULL,
reported_revision BIGINT NOT NULL DEFAULT 0, 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', sync_status VARCHAR(16) NOT NULL DEFAULT 'pending',
keeper_status VARCHAR(16) NOT NULL DEFAULT 'disabled', keeper_status VARCHAR(16) NOT NULL DEFAULT 'disabled',
natmap_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_public_port INT NULL,
current_observed_at DATETIME(6) NULL, current_observed_at DATETIME(6) NULL,
current_validated_at DATETIME(6) NULL, current_validated_at DATETIME(6) NULL,
current_validated_at_wire VARCHAR(64) NULL,
current_valid_until DATETIME(6) NULL, current_valid_until DATETIME(6) NULL,
current_endpoint_identity CHAR(64) NULL,
last_observed_ipv4 VARCHAR(15) NULL, last_observed_ipv4 VARCHAR(15) NULL,
last_observed_port INT NULL, last_observed_port INT NULL,
last_observed_at DATETIME(6) NULL, last_observed_at DATETIME(6) NULL,
last_observed_validated_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_ipv4 VARCHAR(15) NULL,
candidate_public_port INT NULL, candidate_public_port INT NULL,
candidate_observed_at DATETIME(6) NULL, candidate_observed_at DATETIME(6) NULL,
candidate_validated_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_ipv4 VARCHAR(15) NULL,
last_published_public_port INT NULL, last_published_public_port INT NULL,
last_published_at DATETIME(6) 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, max_supported_schema_version INT UNSIGNED NOT NULL DEFAULT 1,
tcp_natmap_capable TINYINT(1) NOT NULL DEFAULT 0, tcp_natmap_capable TINYINT(1) NOT NULL DEFAULT 0,
applied_revision BIGINT 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, online TINYINT(1) NOT NULL DEFAULT 0,
version VARCHAR(64) NULL, version VARCHAR(128) NULL,
started_at DATETIME(6) NULL, started_at DATETIME(6) NULL,
last_heartbeat_at DATETIME(6) NULL, last_heartbeat_at DATETIME(6) NULL,
current_public_ipv6 VARCHAR(45) NULL, current_public_ipv6 VARCHAR(45) NULL,
current_ipv6_observed_at DATETIME(3) NULL, current_ipv6_observed_at DATETIME(3) NULL,
last_mqtt_error_code VARCHAR(64) NULL, last_mqtt_error_code VARCHAR(64) NULL,
last_mqtt_error_message VARCHAR(500) NULL, last_mqtt_error_message VARCHAR(512) NULL,
last_reconcile_error_code VARCHAR(64) 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), 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) update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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, mapping_id BIGINT NOT NULL,
event_type VARCHAR(16) NOT NULL, event_type VARCHAR(16) NOT NULL,
mechanism VARCHAR(16) NOT NULL DEFAULT 'udp_stun', mechanism VARCHAR(16) NOT NULL DEFAULT 'udp_stun',
source_revision BIGINT NULL,
endpoint_identity CHAR(64) NULL,
public_ipv4 VARCHAR(15) NULL, public_ipv4 VARCHAR(15) NULL,
public_port INT NULL, public_port INT NULL,
endpoint_validated_at DATETIME(6) NULL,
endpoint_valid_until DATETIME(6) NULL,
first_observed_at DATETIME(6) NOT NULL, first_observed_at DATETIME(6) NOT NULL,
last_observed_at DATETIME(6) NOT NULL, last_observed_at DATETIME(6) NOT NULL,
occurred_at DATETIME(6) NOT NULL, occurred_at DATETIME(6) NOT NULL,

View File

@ -53,10 +53,18 @@ export class NetworkAgentState {
@Column({ default: '0', name: 'applied_revision', type: 'bigint' }) @Column({ default: '0', name: 'applied_revision', type: 'bigint' })
appliedRevision: string; appliedRevision: string;
@Column({
default: 1,
name: 'applied_schema_version',
type: 'int',
unsigned: true,
})
appliedSchemaVersion: number;
@Column({ default: false, type: 'boolean' }) @Column({ default: false, type: 'boolean' })
online: boolean; online: boolean;
@Column({ length: 64, nullable: true }) @Column({ length: 128, nullable: true })
version?: string | null; version?: string | null;
@KtDateTimeColumn({ name: 'started_at', nullable: true, type: 'datetime' }) @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 }) @Column({ length: 64, name: 'last_mqtt_error_code', nullable: true })
lastMqttErrorCode?: string | null; 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; lastMqttErrorMessage?: string | null;
@Column({ length: 64, name: 'last_reconcile_error_code', nullable: true }) @Column({ length: 64, name: 'last_reconcile_error_code', nullable: true })
lastReconcileErrorCode?: string | null; 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; lastReconcileErrorMessage?: string | null;
@KtCreateDateColumn({ name: 'create_time' }) @KtCreateDateColumn({ name: 'create_time' })

View File

@ -3,6 +3,7 @@ import { isIP } from 'node:net';
import { TextDecoder } from 'node:util'; import { TextDecoder } from 'node:util';
import type { NetworkAgentState } from './network-agent-state.entity'; import type { NetworkAgentState } from './network-agent-state.entity';
import type { NetworkPortForward } from './network-management.entity'; import type { NetworkPortForward } from './network-management.entity';
import type { EndpointMechanism } from './network-management.types';
export const NETWORK_AGENT_V2_SCHEMA_VERSION = 2 as const; export const NETWORK_AGENT_V2_SCHEMA_VERSION = 2 as const;
export const NETWORK_AGENT_V2_MAX_CHANNELS = 64; export const NETWORK_AGENT_V2_MAX_CHANNELS = 64;
@ -30,7 +31,7 @@ export type NetworkV2NatmapStatus =
| 'stale' | 'stale'
| 'starting' | 'starting'
| 'stopping'; | 'stopping';
export type NetworkV2EndpointMechanism = 'tcp_natmap' | 'udp_stun'; export type NetworkV2EndpointMechanism = EndpointMechanism;
export type NetworkV2EndpointEventType = export type NetworkV2EndpointEventType =
| 'changed' | 'changed'
| 'published' | 'published'
@ -173,9 +174,36 @@ export class NetworkV2MessageValidationError extends Error {}
const ID_PATTERN = /^\d{1,32}$/; const ID_PATTERN = /^\d{1,32}$/;
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/; const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
const ERROR_CODE_PATTERN = /^[a-z0-9_]{1,64}$/; 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; 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( export function canonicalDesiredChannelDigestV2(
channel: NetworkDesiredChannelV2, channel: NetworkDesiredChannelV2,
): string { ): string {
@ -183,7 +211,10 @@ export function canonicalDesiredChannelDigestV2(
} }
export function canonicalDesiredSnapshotDigestV2( export function canonicalDesiredSnapshotDigestV2(
snapshot: Pick<NetworkDesiredSnapshotV2, 'agentId' | 'channels' | 'schemaVersion'>, snapshot: Pick<
NetworkDesiredSnapshotV2,
'agentId' | 'channels' | 'schemaVersion'
>,
): string { ): string {
return digest({ return digest({
schemaVersion: snapshot.schemaVersion, schemaVersion: snapshot.schemaVersion,
@ -195,42 +226,48 @@ export function canonicalDesiredSnapshotDigestV2(
} }
export function buildDesiredSnapshotV2( export function buildDesiredSnapshotV2(
state: Pick<NetworkAgentState, 'agentId' | 'desiredIssuedAt' | 'desiredRevision'>, state: Pick<
NetworkAgentState,
'agentId' | 'desiredIssuedAt' | 'desiredRevision'
>,
channels: NetworkDesiredChannelSourceV2[], channels: NetworkDesiredChannelSourceV2[],
): NetworkDesiredSnapshotV2 { ): NetworkDesiredSnapshotV2 {
if (channels.length > NETWORK_AGENT_V2_MAX_CHANNELS) invalid('desired.channels'); if (channels.length > NETWORK_AGENT_V2_MAX_CHANNELS)
const desiredChannels = channels.map((channel) => { invalid('desired.channels');
const base = { const desiredChannels = channels
channelDesiredDigest: '', .map((channel) => {
channelDesiredRevision: safeRevisionFromString( const base = {
channel.desiredRevision, channelDesiredDigest: '',
'channelDesiredRevision', channelDesiredRevision: safeRevisionFromString(
), channel.desiredRevision,
channelId: String(channel.id), 'channelDesiredRevision',
desiredPresence: channel.desiredPresence, ),
externalPort: channel.externalPort, channelId: String(channel.id),
groupId: String(channel.groupId), desiredPresence: channel.desiredPresence,
internalPort: channel.internalPort, externalPort: channel.externalPort,
name: channel.name, groupId: String(channel.groupId),
}; internalPort: channel.internalPort,
const desired: NetworkDesiredChannelV2 = name: channel.name,
channel.protocol === 'tcp' };
? { const desired: NetworkDesiredChannelV2 =
...base, channel.protocol === 'tcp'
natmapDesiredEnabled: channel.natmapDesiredEnabled, ? {
protocol: 'tcp', ...base,
} natmapDesiredEnabled: channel.natmapDesiredEnabled,
: { protocol: 'tcp',
...base, }
keeperDesiredEnabled: channel.keeperDesiredEnabled, : {
...(channel.probeRequestId ...base,
? { probeRequestId: channel.probeRequestId } keeperDesiredEnabled: channel.keeperDesiredEnabled,
: {}), ...(channel.probeRequestId
protocol: 'udp', ? { probeRequestId: channel.probeRequestId }
}; : {}),
desired.channelDesiredDigest = canonicalDesiredChannelDigestV2(desired); protocol: 'udp',
return desired; };
}).sort(compareDesiredChannelsV2); desired.channelDesiredDigest = canonicalDesiredChannelDigestV2(desired);
return desired;
})
.sort(compareDesiredChannelsV2);
if ( if (
new Set(desiredChannels.map((channel) => channel.channelId)).size !== new Set(desiredChannels.map((channel) => channel.channelId)).size !==
desiredChannels.length desiredChannels.length
@ -261,23 +298,42 @@ export function parseDesiredSnapshotV2(
function validateDesiredSnapshotV2(value: unknown): NetworkDesiredSnapshotV2 { function validateDesiredSnapshotV2(value: unknown): NetworkDesiredSnapshotV2 {
const record = exactRecord( const record = exactRecord(
value, value,
['agentId', 'channels', 'issuedAt', 'schemaVersion', 'snapshotDigest', 'snapshotRevision'], [
'agentId',
'channels',
'issuedAt',
'schemaVersion',
'snapshotDigest',
'snapshotRevision',
],
[], [],
'desired', 'desired',
); );
assertSchema(record.schemaVersion); 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); 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 = { const snapshot: NetworkDesiredSnapshotV2 = {
agentId: boundedString(record.agentId, 'agentId', 64), agentId: boundedString(record.agentId, 'agentId', 64),
channels, channels,
issuedAt: isoString(record.issuedAt, 'issuedAt'), issuedAt: isoString(record.issuedAt, 'issuedAt'),
schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION, schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION,
snapshotDigest: digestValue(record.snapshotDigest, 'snapshotDigest'), 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; return snapshot;
} }
@ -288,18 +344,41 @@ export function parseReportedSnapshotV2(
} }
function validateReportedSnapshotV2(value: unknown): NetworkReportedSnapshotV2 { 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); 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); 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 { return {
agentId: boundedString(record.agentId, 'agentId', 64), agentId: boundedString(record.agentId, 'agentId', 64),
channels, channels,
reportedAt: isoString(record.reportedAt, 'reportedAt'), reportedAt: isoString(record.reportedAt, 'reportedAt'),
schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION, schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION,
snapshotDigest: digestValue(record.snapshotDigest, 'snapshotDigest'), 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 { 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); 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 { return {
agentId: boundedString(record.agentId, 'agentId', 64), agentId: boundedString(record.agentId, 'agentId', 64),
...(record.errorCode === undefined ? {} : { errorCode: errorCode(record.errorCode, 'errorCode') }), ...(record.errorCode === undefined
...(record.errorMessage === undefined ? {} : { errorMessage: boundedString(record.errorMessage, 'errorMessage', 512) }), ? {}
: { errorCode: errorCode(record.errorCode, 'errorCode') }),
...(record.errorMessage === undefined
? {}
: {
errorMessage: boundedString(record.errorMessage, 'errorMessage', 512),
}),
observedAt: isoString(record.observedAt, 'observedAt'), observedAt: isoString(record.observedAt, 'observedAt'),
online: booleanValue(record.online, 'online'), 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, 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], supportedSchemaVersions: [1, 2],
tcpNatmapCapable: booleanValue(record.tcpNatmapCapable, 'tcpNatmapCapable'), 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 { 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); assertSchema(record.schemaVersion);
const protocol = enumValue(record.protocol, ['tcp', 'udp'] as const, 'protocol'); const protocol = enumValue(
const mechanism = enumValue(record.mechanism, ['tcp_natmap', 'udp_stun'] as const, 'mechanism'); record.protocol,
const type = enumValue(record.type, ['changed', 'published', 'restored', 'withdrawn'] as const, 'type'); ['tcp', 'udp'] as const,
if ((protocol === 'tcp') !== (mechanism === 'tcp_natmap')) invalid('event mechanism'); 'protocol',
const endpoint = record.endpoint === undefined ? undefined : parseEndpointV2(record.endpoint, mechanism); );
if ((type === 'withdrawn') !== (endpoint === undefined)) invalid('event endpoint'); 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 { return {
agentId: boundedString(record.agentId, 'agentId', 64), agentId: boundedString(record.agentId, 'agentId', 64),
channelId: identifier(record.channelId, 'channelId'), channelId: identifier(record.channelId, 'channelId'),
@ -352,7 +494,9 @@ function validateEndpointEventV2(value: unknown): NetworkEndpointEventV2 {
mechanism, mechanism,
occurredAt: isoString(record.occurredAt, 'occurredAt'), occurredAt: isoString(record.occurredAt, 'occurredAt'),
protocol, protocol,
...(record.reason === undefined ? {} : { reason: boundedString(record.reason, 'reason', 128) }), ...(record.reason === undefined
? {}
: { reason: boundedString(record.reason, 'reason', 128) }),
revision: positiveRevision(record.revision, 'revision'), revision: positiveRevision(record.revision, 'revision'),
schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION, schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION,
type, type,
@ -361,78 +505,547 @@ function validateEndpointEventV2(value: unknown): NetworkEndpointEventV2 {
function parseDesiredChannelV2(value: unknown): NetworkDesiredChannelV2 { function parseDesiredChannelV2(value: unknown): NetworkDesiredChannelV2 {
const protocol = protocolOf(value, 'desired channel'); const protocol = protocolOf(value, 'desired channel');
const required = ['channelDesiredDigest', 'channelDesiredRevision', 'channelId', 'desiredPresence', 'externalPort', 'groupId', 'internalPort', 'name', 'protocol']; const required = [
const record = exactRecord(value, protocol === 'tcp' ? [...required, 'natmapDesiredEnabled'] : [...required, 'keeperDesiredEnabled'], protocol === 'udp' ? ['probeRequestId'] : [], 'desired channel'); '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 base = desiredBase(record);
const channel = protocol === 'tcp' const channel =
? { ...base, natmapDesiredEnabled: booleanValue(record.natmapDesiredEnabled, 'natmapDesiredEnabled'), protocol } protocol === 'tcp'
: { ...base, keeperDesiredEnabled: booleanValue(record.keeperDesiredEnabled, 'keeperDesiredEnabled'), ...(record.probeRequestId === undefined ? {} : { probeRequestId: requestId(record.probeRequestId, 'probeRequestId') }), protocol }; ? {
if (canonicalDesiredChannelDigestV2(channel) !== channel.channelDesiredDigest) invalid('channelDesiredDigest'); ...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; return channel;
} }
function parseReportedChannelV2(value: unknown): NetworkReportedChannelV2 { function parseReportedChannelV2(value: unknown): NetworkReportedChannelV2 {
const protocol = protocolOf(value, 'reported channel'); const protocol = protocolOf(value, 'reported channel');
const common = ['appliedDesiredDigest', 'appliedDesiredRevision', 'channelId', 'desiredPresence', 'groupId', 'protocol', 'routerPresent', 'syncStatus']; const common = [
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'); '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 = { const base: ReportedChannelBaseV2 = {
appliedDesiredDigest: digestValue(record.appliedDesiredDigest, 'appliedDesiredDigest'), appliedDesiredDigest: digestValue(
appliedDesiredRevision: positiveRevision(record.appliedDesiredRevision, 'appliedDesiredRevision'), record.appliedDesiredDigest,
'appliedDesiredDigest',
),
appliedDesiredRevision: positiveRevision(
record.appliedDesiredRevision,
'appliedDesiredRevision',
),
channelId: identifier(record.channelId, 'channelId'), channelId: identifier(record.channelId, 'channelId'),
desiredPresence: enumValue(record.desiredPresence, ['absent', 'present'] as const, 'desiredPresence'), desiredPresence: enumValue(
...(record.errorCode === undefined ? {} : { errorCode: errorCode(record.errorCode, 'errorCode') }), record.desiredPresence,
...(record.errorMessage === undefined ? {} : { errorMessage: boundedString(record.errorMessage, 'errorMessage', 512) }), ['absent', 'present'] as const,
groupId: identifier(record.groupId, 'groupId'), protocol, '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'), 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<string, unknown>): DesiredChannelBaseV2 { function desiredBase(record: Record<string, unknown>): 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 { function parseEndpointV2(
const record = exactRecord(value, ['mechanism', 'observedAt', 'publicIpv4', 'publicPort', 'validatedAt', 'validUntil'], [], 'endpoint'); value: unknown,
const mechanism = enumValue(record.mechanism, ['tcp_natmap', 'udp_stun'] as const, 'mechanism'); 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'); if (mechanism !== expectedMechanism) invalid('endpoint mechanism');
const observedAt = isoString(record.observedAt, 'observedAt'); const observedAt = isoString(record.observedAt, 'observedAt');
const validatedAt = isoString(record.validatedAt, 'validatedAt'); const validatedAt = isoString(record.validatedAt, 'validatedAt');
const validUntil = isoString(record.validUntil, 'validUntil'); const validUntil = isoString(record.validUntil, 'validUntil');
if (Date.parse(validUntil) <= Date.parse(observedAt)) invalid('endpoint validity'); if (compareNetworkV2Timestamps(validUntil, observedAt) <= 0)
return { mechanism, observedAt, publicIpv4: publicIpv4(record.publicIpv4), publicPort: port(record.publicPort, 'publicPort'), validatedAt, validUntil }; invalid('endpoint validity');
return {
mechanism,
observedAt,
publicIpv4: publicIpv4(record.publicIpv4),
publicPort: port(record.publicPort, 'publicPort'),
validatedAt,
validUntil,
};
} }
function canonicalDesiredChannelV2(channel: NetworkDesiredChannelV2): object { 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 }; if (channel.protocol === 'tcp')
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 }; 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 compareDesiredChannelsV2(
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'); } } left: NetworkDesiredChannelV2,
function optionalEndpoint(value: unknown, mechanism: NetworkV2EndpointMechanism): NetworkEndpointLeaseV2 | undefined { return value === undefined ? undefined : parseEndpointV2(value, mechanism); } right: NetworkDesiredChannelV2,
function optionalBounded(value: unknown, name: string, max: number): string | undefined { return value === undefined ? undefined : boundedString(value, name, max); } ): number {
function optionalErrorCode(value: unknown, name: string): string | undefined { return value === undefined ? undefined : errorCode(value, name); } return (
function protocolOf(value: unknown, name: string): NetworkV2Protocol { if (typeof value !== 'object' || value === null || Array.isArray(value)) invalid(name); return enumValue((value as Record<string, unknown>).protocol, ['tcp', 'udp'] as const, 'protocol'); } (left.channelId < right.channelId
function exactRecord(value: unknown, required: string[], optional: string[], name: string, allowPartial = false): Record<string, unknown> { if (typeof value !== 'object' || value === null || Array.isArray(value)) invalid(name); const record = value as Record<string, unknown>; 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; } ? -1
function assertSchema(value: unknown): void { if (value !== NETWORK_AGENT_V2_SCHEMA_VERSION) invalid('schemaVersion'); } : left.channelId > right.channelId
function identifier(value: unknown, name: string): string { const result = stringValue(value, name, 32); if (!ID_PATTERN.test(result)) invalid(name); return result; } ? 1
function requestId(value: unknown, name: string): string { const result = stringValue(value, name, 128); if (!REQUEST_ID_PATTERN.test(result)) invalid(name); return result; } : 0) ||
function digestValue(value: unknown, name: string): string { const result = stringValue(value, name, 64); if (!/^[0-9a-f]{64}$/.test(result)) invalid(name); return result; } (left.protocol < right.protocol
function errorCode(value: unknown, name: string): string { const result = stringValue(value, name, 64); if (!ERROR_CODE_PATTERN.test(result)) invalid(name); return result; } ? -1
function boundedString(value: unknown, name: string, max: number): string { const result = stringValue(value, name, max); if (result.length === 0) invalid(name); return result; } : left.protocol > right.protocol
function stringValue(value: unknown, name: string, max: number): string { if (typeof value !== 'string' || Buffer.byteLength(value, 'utf8') > max) invalid(name); return value; } ? 1
function positiveRevision(value: unknown, name: string): number { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) invalid(name); return value; } : 0) ||
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); } left.externalPort - right.externalPort
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 parseWirePayloadV2(payload: NetworkV2WirePayload): unknown {
function enumValue<T extends string>(value: unknown, allowed: readonly T[], name: string): T { if (typeof value !== 'string' || !allowed.includes(value as T)) invalid(name); return value as T; } let text: string;
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; } if (typeof payload === 'string') {
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; } if (Buffer.byteLength(payload, 'utf8') > NETWORK_AGENT_V2_MAX_MESSAGE_BYTES)
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; } invalid('message size');
function digest(value: unknown): string { return createHash('sha256').update(goJsonStringify(value)).digest('hex'); } text = payload;
function goJsonStringify(value: unknown): string { return JSON.stringify(value).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026').replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029'); } } else if (Buffer.isBuffer(payload)) {
function invalid(name: string): never { throw new NetworkV2MessageValidationError(`invalid network v2 ${name}`); } 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<string, unknown>).protocol,
['tcp', 'udp'] as const,
'protocol',
);
}
function exactRecord(
value: unknown,
required: string[],
optional: string[],
name: string,
allowPartial = false,
): Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value))
invalid(name);
const record = value as Record<string, unknown>;
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<T extends string>(
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, '\\u003c')
.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}`);
}

View File

@ -5,7 +5,10 @@ import {
KtDateTime, KtDateTime,
KtDateTimeColumn, KtDateTimeColumn,
} from '@/common'; } from '@/common';
import type { EndpointEventType } from './network-management.types'; import type {
EndpointEventType,
EndpointMechanism,
} from './network-management.types';
@Entity('network_endpoint_history') @Entity('network_endpoint_history')
@Index('uk_network_endpoint_history_event_id', ['eventId'], { unique: true }) @Index('uk_network_endpoint_history_event_id', ['eventId'], { unique: true })
@ -24,7 +27,18 @@ export class NetworkEndpointHistory {
eventType: EndpointEventType; eventType: EndpointEventType;
@Column({ default: 'udp_stun', length: 16, name: 'mechanism' }) @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 }) @Column({ length: 15, name: 'public_ipv4', nullable: true })
publicIpv4?: string | null; publicIpv4?: string | null;
@ -32,6 +46,22 @@ export class NetworkEndpointHistory {
@Column({ name: 'public_port', nullable: true, type: 'int' }) @Column({ name: 'public_port', nullable: true, type: 'int' })
publicPort?: number | null; 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' }) @KtDateTimeColumn({ name: 'first_observed_at', type: 'datetime' })
firstObservedAt: KtDateTime; firstObservedAt: KtDateTime;

View File

@ -15,9 +15,11 @@ import type {
@Entity('network_port_forward') @Entity('network_port_forward')
@Index('uk_network_port_forward_active_key', ['activeKey'], { unique: true }) @Index('uk_network_port_forward_active_key', ['activeKey'], { unique: true })
@Index('uk_network_port_forward_active_group_protocol_key', [ @Index(
'activeGroupProtocolKey', 'uk_network_port_forward_active_group_protocol_key',
], { unique: true }) ['activeGroupProtocolKey'],
{ unique: true },
)
@Index('idx_network_port_forward_group', ['groupId', 'isDeleted', 'protocol']) @Index('idx_network_port_forward_group', ['groupId', 'isDeleted', 'protocol'])
export class NetworkPortForward { export class NetworkPortForward {
@PrimaryColumn({ type: 'bigint' }) @PrimaryColumn({ type: 'bigint' })
@ -83,6 +85,17 @@ export class NetworkPortForward {
@Column({ default: '0', name: 'reported_revision', type: 'bigint' }) @Column({ default: '0', name: 'reported_revision', type: 'bigint' })
reportedRevision: string; 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' }) @Column({ default: 'pending', length: 16, name: 'sync_status' })
syncStatus: PortForwardSyncStatus; syncStatus: PortForwardSyncStatus;
@ -113,6 +126,13 @@ export class NetworkPortForward {
}) })
currentValidatedAt?: KtDateTime | null; currentValidatedAt?: KtDateTime | null;
@Column({
length: 64,
name: 'current_validated_at_wire',
nullable: true,
})
currentValidatedAtWire?: string | null;
@KtDateTimeColumn({ @KtDateTimeColumn({
name: 'current_valid_until', name: 'current_valid_until',
nullable: true, nullable: true,
@ -120,6 +140,14 @@ export class NetworkPortForward {
}) })
currentValidUntil?: KtDateTime | null; 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 }) @Column({ length: 15, name: 'last_observed_ipv4', nullable: true })
lastObservedIpv4?: string | null; lastObservedIpv4?: string | null;
@ -141,6 +169,13 @@ export class NetworkPortForward {
}) })
lastObservedValidatedAt?: KtDateTime | null; 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 }) @Column({ length: 15, name: 'candidate_public_ipv4', nullable: true })
candidatePublicIpv4?: string | null; candidatePublicIpv4?: string | null;
@ -163,6 +198,13 @@ export class NetworkPortForward {
}) })
candidateValidatedAt?: KtDateTime | null; 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 }) @Column({ length: 15, name: 'last_published_public_ipv4', nullable: true })
lastPublishedPublicIpv4?: string | null; lastPublishedPublicIpv4?: string | null;

View File

@ -30,6 +30,7 @@ export type EndpointEventType =
| 'published' | 'published'
| 'restored' | 'restored'
| 'withdrawn'; | 'withdrawn';
export type EndpointMechanism = 'tcp_natmap' | 'udp_stun';
export type NetworkStateChangeSource = export type NetworkStateChangeSource =
| 'ddns' | 'ddns'
| 'events' | 'events'

View File

@ -5,6 +5,8 @@ import {
buildDesiredSnapshotV2, buildDesiredSnapshotV2,
canonicalDesiredChannelDigestV2, canonicalDesiredChannelDigestV2,
canonicalDesiredSnapshotDigestV2, canonicalDesiredSnapshotDigestV2,
compareNetworkV2Timestamps,
endpointLeaseIdentityV2,
parseDesiredSnapshotV2, parseDesiredSnapshotV2,
parseEndpointEventV2, parseEndpointEventV2,
parseReportedSnapshotV2, parseReportedSnapshotV2,
@ -36,6 +38,39 @@ const eventValue = () => ({
}); });
describe('network agent MQTT v2 contract', () => { 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', () => { it('parses the shared desired fixture and validates both literal digests', () => {
const desired = parseDesiredSnapshotV2( const desired = parseDesiredSnapshotV2(
rawFixture('network-v2-desired.json'), rawFixture('network-v2-desired.json'),
@ -55,8 +90,14 @@ describe('network agent MQTT v2 contract', () => {
['unknown field', (value: any) => (value.extra = true)], ['unknown field', (value: any) => (value.extra = true)],
['schema/body mismatch', (value: any) => (value.schemaVersion = 1)], ['schema/body mismatch', (value: any) => (value.schemaVersion = 1)],
['numeric ID drift', (value: any) => (value.channels[0].channelId = 2)], ['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) => { ])('rejects %s before v2 messages reach transport', (_, patch) => {
const value = fixture('network-v2-desired.json') as any; const value = fixture('network-v2-desired.json') as any;
patch(value); patch(value);
@ -79,31 +120,45 @@ describe('network agent MQTT v2 contract', () => {
}); });
it('accepts v2 status and matching endpoint events before transport routing', () => { it('accepts v2 status and matching endpoint events before transport routing', () => {
expect( expect(parseStatusSnapshotV2(JSON.stringify(statusValue()))).toMatchObject({
parseStatusSnapshotV2(JSON.stringify(statusValue())), online: true,
).toMatchObject({ online: true }); });
expect( expect(parseEndpointEventV2(JSON.stringify(eventValue()))).toMatchObject({
parseEndpointEventV2(JSON.stringify(eventValue())), type: 'withdrawn',
).toMatchObject({ type: 'withdrawn' }); });
}); });
it.each([ 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())], ['status', parseStatusSnapshotV2, () => JSON.stringify(statusValue())],
['event', parseEndpointEventV2, () => JSON.stringify(eventValue())], ['event', parseEndpointEventV2, () => JSON.stringify(eventValue())],
])('enforces raw size and trailing JSON for %s', (_, parser, validPayload) => { ])(
expect(parser(validPayload())).toBeDefined(); 'enforces raw size and trailing JSON for %s',
expect(() => parser(`${validPayload()}{}`)).toThrow(); (_, parser, validPayload) => {
expect(() => expect(parser(validPayload())).toBeDefined();
parser(Buffer.alloc(NETWORK_AGENT_V2_MAX_MESSAGE_BYTES + 1)), expect(() => parser(`${validPayload()}{}`)).toThrow();
).toThrow(); expect(() =>
}); parser(Buffer.alloc(NETWORK_AGENT_V2_MAX_MESSAGE_BYTES + 1)),
).toThrow();
},
);
it('rejects invalid RFC3339 calendar dates and explicit null optionals', () => { it('rejects invalid RFC3339 calendar dates and explicit null optionals', () => {
expect(() => expect(() =>
parseStatusSnapshotV2( parseStatusSnapshotV2(
JSON.stringify({ ...statusValue(), observedAt: '2026-02-29T00:00:00Z' }), JSON.stringify({
...statusValue(),
observedAt: '2026-02-29T00:00:00Z',
}),
), ),
).toThrow(); ).toThrow();
expect(() => expect(() =>
@ -112,9 +167,7 @@ describe('network agent MQTT v2 contract', () => {
), ),
).toThrow(); ).toThrow();
expect(() => expect(() =>
parseEndpointEventV2( parseEndpointEventV2(JSON.stringify({ ...eventValue(), endpoint: null })),
JSON.stringify({ ...eventValue(), endpoint: null }),
),
).toThrow(); ).toThrow();
}); });
@ -163,12 +216,15 @@ describe('network agent MQTT v2 contract', () => {
const desiredWithName = (name: string) => { const desiredWithName = (name: string) => {
const value = fixture('network-v2-desired.json'); const value = fixture('network-v2-desired.json');
value.channels[0].name = name; value.channels[0].name = name;
value.channels[0].channelDesiredDigest = value.channels[0].channelDesiredDigest = canonicalDesiredChannelDigestV2(
canonicalDesiredChannelDigestV2(value.channels[0]); value.channels[0],
);
value.snapshotDigest = canonicalDesiredSnapshotDigestV2(value); value.snapshotDigest = canonicalDesiredSnapshotDigestV2(value);
return JSON.stringify(value); return JSON.stringify(value);
}; };
expect(parseDesiredSnapshotV2(desiredWithName('界'.repeat(42)))).toBeDefined(); expect(
parseDesiredSnapshotV2(desiredWithName('界'.repeat(42))),
).toBeDefined();
expect(() => expect(() =>
parseDesiredSnapshotV2(desiredWithName('界'.repeat(43))), parseDesiredSnapshotV2(desiredWithName('界'.repeat(43))),
).toThrow(); ).toThrow();
@ -216,9 +272,14 @@ describe('network agent MQTT v2 contract', () => {
const desired = buildDesiredSnapshotV2(state, channels); const desired = buildDesiredSnapshotV2(state, channels);
expect(desired).toMatchObject({ schemaVersion: 2, snapshotRevision: 7 }); expect(desired).toMatchObject({ schemaVersion: 2, snapshotRevision: 7 });
expect(desired.channels[0].channelDesiredDigest).toMatch(/^[0-9a-f]{64}$/); expect(desired.channels[0].channelDesiredDigest).toMatch(/^[0-9a-f]{64}$/);
expect(desired.snapshotDigest).toBe(canonicalDesiredSnapshotDigestV2(desired)); expect(desired.snapshotDigest).toBe(
canonicalDesiredSnapshotDigestV2(desired),
);
expect(() => expect(() =>
buildDesiredSnapshotV2({ ...state, desiredRevision: '9007199254740992' }, channels), buildDesiredSnapshotV2(
{ ...state, desiredRevision: '9007199254740992' },
channels,
),
).toThrow(); ).toThrow();
}); });
@ -241,8 +302,14 @@ describe('network agent MQTT v2 contract', () => {
protocol: 'tcp' as const, protocol: 'tcp' as const,
}); });
const desired = buildDesiredSnapshotV2(state, [channel('20'), channel('10')]); const desired = buildDesiredSnapshotV2(state, [
expect(desired.channels.map((item) => item.channelId)).toEqual(['10', '20']); channel('20'),
channel('10'),
]);
expect(desired.channels.map((item) => item.channelId)).toEqual([
'10',
'20',
]);
expect(parseDesiredSnapshotV2(JSON.stringify(desired))).toEqual(desired); expect(parseDesiredSnapshotV2(JSON.stringify(desired))).toEqual(desired);
}); });
@ -265,7 +332,9 @@ describe('network agent MQTT v2 contract', () => {
protocol: 'tcp' as const, protocol: 'tcp' as const,
}; };
expect(() => buildDesiredSnapshotV2(state, [channel, { ...channel }])).toThrow(); expect(() =>
buildDesiredSnapshotV2(state, [channel, { ...channel }]),
).toThrow();
expect(() => expect(() =>
buildDesiredSnapshotV2(state, [{ ...channel, externalPort: 0 }]), buildDesiredSnapshotV2(state, [{ ...channel, externalPort: 0 }]),
).toThrow(); ).toThrow();

View File

@ -50,7 +50,10 @@ describe('network management persistence module', () => {
(column) => column.target === NetworkEndpointHistory, (column) => column.target === NetworkEndpointHistory,
); );
const featureProviders = ( const featureProviders = (
Reflect.getMetadata(MODULE_METADATA.IMPORTS, AdminPlatformConfigModule) as { Reflect.getMetadata(
MODULE_METADATA.IMPORTS,
AdminPlatformConfigModule,
) as {
module?: unknown; module?: unknown;
providers?: { provide?: unknown }[]; providers?: { provide?: unknown }[];
}[] }[]
@ -67,46 +70,130 @@ describe('network management persistence module', () => {
]), ]),
); );
expect( expect(
groupColumns.find((column) => column.propertyName === 'createTime') groupColumns.find((column) => column.propertyName === 'createTime')?.mode,
?.mode,
).toBe('createDate'); ).toBe('createDate');
expect( expect(
groupColumns.find((column) => column.propertyName === 'updateTime') groupColumns.find((column) => column.propertyName === 'updateTime')?.mode,
?.mode,
).toBe('updateDate'); ).toBe('updateDate');
expect(channelColumns.map((column) => column.options.name)).toEqual( expect(channelColumns.map((column) => column.options.name)).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'active_group_protocol_key', 'active_group_protocol_key',
'candidate_observed_at', 'candidate_observed_at',
'candidate_validated_at', 'candidate_validated_at',
'candidate_validated_at_wire',
'current_endpoint_identity',
'current_validated_at', 'current_validated_at',
'current_validated_at_wire',
'group_id', 'group_id',
'last_observed_validated_at', 'last_observed_validated_at',
'last_observed_validated_at_wire',
'last_published_at', 'last_published_at',
'last_reported_at',
'last_reported_at_wire',
'natmap_desired_enabled', 'natmap_desired_enabled',
]), ]),
); );
expect( expect(
channelColumns.find((column) => column.propertyName === 'groupId')?.options channelColumns.find((column) => column.propertyName === 'groupId')
.type, ?.options.type,
).toBe('bigint'); ).toBe('bigint');
expect( expect(
channelColumns.find( channelColumns.find(
(column) => column.propertyName === 'candidateObservedAt', (column) => column.propertyName === 'candidateObservedAt',
)?.options.type, )?.options.type,
).toBe('datetime'); ).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(agentColumns.map((column) => column.options.name)).toEqual(
expect.arrayContaining([ expect.arrayContaining([
'applied_schema_version',
'desired_schema_version', 'desired_schema_version',
'published_schema_version', 'published_schema_version',
'max_supported_schema_version', 'max_supported_schema_version',
'tcp_natmap_capable', '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( expect(
historyColumns.find((column) => column.propertyName === 'mechanism') historyColumns.find((column) => column.propertyName === 'mechanism')
?.options.name, ?.options.name,
).toBe('mechanism'); ).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( expect(featureProviders.map((provider) => provider.provide)).toContain(
getRepositoryToken(NetworkPortForwardGroup), getRepositoryToken(NetworkPortForwardGroup),
); );
@ -119,11 +206,10 @@ describe('network management persistence module', () => {
), ),
).toBe(true); ).toBe(true);
expect( expect(
storage.relations.filter( storage.relations.filter((relation) =>
(relation) => [NetworkPortForward, NetworkPortForwardGroup].includes(
[NetworkPortForward, NetworkPortForwardGroup].includes( relation.target as never,
relation.target as never, ),
),
), ),
).toHaveLength(0); ).toHaveLength(0);
}); });

View File

@ -68,10 +68,7 @@ type FixtureRow = Record<string, null | string>;
function parseFixtureRows(sql: string): Record<string, FixtureRow> { function parseFixtureRows(sql: string): Record<string, FixtureRow> {
const rows: Record<string, FixtureRow> = {}; const rows: Record<string, FixtureRow> = {};
const normalized = sql const normalized = sql.replace(/--.*$/gm, '').replace(/`/g, '').trim();
.replace(/--.*$/gm, '')
.replace(/`/g, '')
.trim();
const inserts = normalized.matchAll( const inserts = normalized.matchAll(
/insert into ([a-z0-9_]+)\s*\(([\s\S]*?)\)\s*values\s*\(([\s\S]*?)\);/gi, /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_public_port int null',
'candidate_observed_at datetime(6) null', 'candidate_observed_at datetime(6) null',
'candidate_validated_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 datetime(6) null',
'current_validated_at_wire varchar(64) null',
'last_observed_validated_at datetime(6) 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_ipv4 varchar(15) null',
'last_published_public_port int null', 'last_published_public_port int null',
'last_published_at datetime(6) null', 'last_published_at datetime(6) null',
'last_reported_at datetime(6) null',
'last_reported_at_wire varchar(64) null',
]) { ]) {
expect(channel).toContain(column); expect(channel).toContain(column);
} }
@ -171,11 +174,25 @@ function expectFinalSchema(sql: string): void {
'desired_schema_version int unsigned not null default 1', 'desired_schema_version int unsigned not null default 1',
'published_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', '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', '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(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', () => { 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', () => { it('uses an additive idempotent migration with ordered group backfill', () => {
const migration = readNormalizedSql('sql/network-tcp-natmap-v2.sql'); 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.columns');
expect(migration).toContain('information_schema.statistics'); expect(migration).toContain('information_schema.statistics');
expect(migration).toContain('insert into network_port_forward_group'); expect(migration).toContain('insert into network_port_forward_group');
expect(migration).toContain('select channel.id, channel.name, channel.remark'); expect(migration).toContain(
expect(migration).toContain("'udp'"); 'select channel.id, channel.name, channel.remark',
expect(migration).toContain('update network_port_forward channel set group_id = channel.id'); );
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( expect(migration).toContain(
"active_group_protocol_key = case when channel.is_deleted = 0 then concat(channel.group_id, ':', channel.protocol) else null end", "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_public_port',
'candidate_observed_at', 'candidate_observed_at',
'candidate_validated_at', 'candidate_validated_at',
'candidate_validated_at_wire',
'current_endpoint_identity',
'current_validated_at', 'current_validated_at',
'current_validated_at_wire',
'last_observed_validated_at', 'last_observed_validated_at',
'last_observed_validated_at_wire',
'last_published_public_ipv4', 'last_published_public_ipv4',
'last_published_public_port', 'last_published_public_port',
'last_published_at', 'last_published_at',
'last_reported_at',
'last_reported_at_wire',
'desired_schema_version', 'desired_schema_version',
'published_schema_version', 'published_schema_version',
'max_supported_schema_version', 'max_supported_schema_version',
'applied_schema_version',
'tcp_natmap_capable', 'tcp_natmap_capable',
'source_revision',
'endpoint_validated_at',
'endpoint_valid_until',
'endpoint_identity',
]) { ]) {
expect(migration).toContain(`column_name = '${columnName}'`); 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('from network_port_forward_group');
expect(verify).toContain("column_name = 'group_id'"); 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).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', () => { 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 = { const afterHistory: FixtureRow = {
...beforeHistory, ...beforeHistory,
endpoint_validated_at: null,
endpoint_valid_until: null,
mechanism: 'udp_stun', mechanism: 'udp_stun',
source_revision: null,
}; };
const afterDdns: FixtureRow = { ...beforeDdns }; const afterDdns: FixtureRow = { ...beforeDdns };
expect(mutationAssignmentTargets(migration, 'network_port_forward')).toEqual( expect(
['active_group_protocol_key', 'group_id'], mutationAssignmentTargets(migration, 'network_port_forward'),
); ).toEqual(['active_group_protocol_key', 'group_id']);
expect( expect(
mutationAssignmentTargets(migration, 'network_endpoint_history'), mutationAssignmentTargets(migration, 'network_endpoint_history'),
).toEqual(['mechanism']); ).toEqual(['mechanism']);
@ -316,9 +384,17 @@ describe('TCP NATMap v2 schema SQL', () => {
'group_id', 'group_id',
]), ]),
).toEqual(beforeChannel); ).toEqual(beforeChannel);
expect(withoutProperties(afterHistory, ['mechanism'])).toEqual( expect(
beforeHistory, 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(afterDdns).toEqual(beforeDdns);
expect(afterChannel.id).toBe('2041600000000008213'); expect(afterChannel.id).toBe('2041600000000008213');
expect(afterChannel.active_key).toBe('udp:8213'); expect(afterChannel.active_key).toBe('udp:8213');