feat: 接入TCP NATMap上报与端点状态
This commit is contained in:
parent
8ce72c1269
commit
cd157e773f
34
API.md
34
API.md
@ -111,17 +111,37 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
||||
| `DELETE` | `/system/network/ddns/:id` | `super` | 删除本地绑定,不删除云端 DNS 记录 |
|
||||
| `POST` | `/system/network/ddns/:id/retry` | `super` | 手动重试一条已启用 DDNS 绑定 |
|
||||
|
||||
新增和修改请求只接受名称、备注、`tcp|udp`、外部端口和内部端口;目标 NAS IPv4 固定来自 `NETWORK_AGENT_TARGET_IPV4`,请求体中的未知字段会返回 400。Snowflake ID 与 revision 在 HTTP JSON 中保留为字符串。所有动态响应设置 `Cache-Control: no-store`。
|
||||
逻辑组接口用于当前 Admin;同一逻辑组可包含相同端口的 TCP、UDP 或双协议通道:
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
| -------- | --------------------------------------------------------------------------------- | ------- | ----------------------------------- |
|
||||
| `GET` | `/system/network/port-forward-group/list` | `super` | 分页查询逻辑组和两个协议通道 |
|
||||
| `POST` | `/system/network/port-forward-group` | `super` | 新增 `tcp / udp / tcp_udp` 逻辑组 |
|
||||
| `PUT` | `/system/network/port-forward-group/:groupId` | `super` | 原子修改组字段和协议模式 |
|
||||
| `DELETE` | `/system/network/port-forward-group/:groupId` | `super` | 为组内全部通道写入 absent tombstone |
|
||||
| `POST` | `/system/network/port-forward-group/:groupId/channels/:protocol/retry` | `super` | 重试指定协议通道 |
|
||||
| `GET` | `/system/network/port-forward-group/:groupId/channels/:protocol/endpoint-history` | `super` | 查询指定协议和 mechanism 的端点历史 |
|
||||
| `POST` | `/system/network/port-forward-group/:groupId/channels/tcp/natmap/enable` | `super` | 启用 TCP NATMap |
|
||||
| `POST` | `/system/network/port-forward-group/:groupId/channels/tcp/natmap/disable` | `super` | 停用 TCP NATMap |
|
||||
| `POST` | `/system/network/port-forward-group/:groupId/channels/udp/keeper/enable` | `super` | 启用 UDP Keeper |
|
||||
| `POST` | `/system/network/port-forward-group/:groupId/channels/udp/keeper/disable` | `super` | 停用 UDP Keeper |
|
||||
| `POST` | `/system/network/port-forward-group/:groupId/channels/udp/keeper/probe` | `super` | 立即请求一次 UDP 探测 |
|
||||
|
||||
旧 `/port-forward` 路由保留平面通道兼容语义;多通道组必须使用逻辑组接口。新增和修改请求只接受各 DTO 白名单字段,目标 NAS IPv4 固定来自 `NETWORK_AGENT_TARGET_IPV4`,未知字段返回 400。Snowflake ID 与 revision 在 HTTP JSON 中保留为字符串。所有动态响应设置 `Cache-Control: no-store`。
|
||||
|
||||
端点历史行使用 `portForwardId` 关联端口转发,撤下原因返回为 `withdrawalReason`。Agent 状态同时返回统一的 `lastErrorCode/lastErrorMessage`(reconciliation 优先于 MQTT)和细分错误字段,便于 Admin 展示与诊断。
|
||||
|
||||
API 数据库是唯一事实源。每次合法期望变更在同一事务中锁定 `network_agent_state`、全局 revision 只增加一次并保存稳定 `desiredIssuedAt`;事务提交后再发布 `kt/network/v1/agents/{agentId}/desired` 的 QoS 1 retained 完整快照。PUBACK 只推进 `publishedRevision`,Agent 的完整 `reported` 才推进 `appliedRevision` 和逐条实际状态。MQTT 或 Agent 离线不回滚已接受的期望状态。
|
||||
API 数据库是唯一事实源。每次合法期望变更在同一事务中锁定 `network_agent_state`、全局 revision 只增加一次并保存稳定 `desiredIssuedAt`;事务提交后再向当前 owner 的 `kt/network/v1|v2/agents/{agentId}/desired` 发布 QoS 1 retained 完整快照。PUBACK 只推进 `publishedRevision`,Agent 的完整 `reported` 才推进 `appliedRevision` 和逐通道实际状态。Agent 声明 v2 capability 且 release policy 允许后,API 清除旧 retained owner、切换到 v2,并忽略此后迟到的 v1 reported/events。MQTT 或 Agent 离线不回滚已接受的期望状态。
|
||||
|
||||
Wire contract 采用 `kt-network-agent/internal/contract` schema-v1:desired mapping 使用 `state=present|absent`;reported 使用 `appliedRevision`、`desiredDigest`、helper 状态和逐条 router/route/Keeper 证据;endpoint event 使用唯一 `eventId`。API 不发布数据库备注,也不在 MQTT、HTTP 或数据库中接收/保存路由器密码和 token。事件按 `eventId` 幂等追加;删除只有在 Agent 明确回报 absent、synced、router/route 均不存在、Keeper 期望关闭且实际 disabled、current endpoint 为空,并且 helper 已确认且 `helperAppliedRevision === appliedRevision` 后才完成,随后生成新 revision 移除 tombstone。
|
||||
Wire contract 同时保留 schema-v1 兼容路径和严格 schema-v2。v2 外层使用 snapshot revision/digest,逐通道使用独立 desired revision/digest;错误的外层 identity 整包拒绝,单通道 identity/digest 冲突只隔离该通道,不阻断合法 sibling。合法外层 snapshot 完成一次协调处理后推进 applied revision 与独立 applied schema 水位;该值不代表全部通道成功。通道保存原始 RFC3339Nano report 水位,阻止同 revision、同毫秒内乱序的旧 report 回退运行态与错误。Agent 首次接受 v2 后会耐久锁存 owner;在实现 Agent 可确认的显式降级握手前,API-only downgrade 固定返回拒绝,v1 reported/events 也不能越过已持久化的 v2 applied schema 水位。
|
||||
|
||||
Admin 首次进入网络管理页通过 HTTP 读取快照,随后使用 `/system/network/events/stream` 接收 `network-state-changed`。API 只在 `reported`、`status`、`events` 对应事务提交且语义状态实际变化后发出事件;MQTT QoS 1 重投、仅推进 `lastHeartbeatAt` 的状态心跳以及仅推进 `currentObservedAt/currentValidUntil/lastObservedAt` 的租约续期继续持久化,但不触发刷新。公网 IP/端口、Keeper/同步/错误/删除状态或 Agent 在线会话变化仍发布事件。SSE 心跳只维持连接并复用最近一次真实状态事件 ID,尚无状态事件时显式发送空 ID,避免 Nest 自动生成游标;浏览器重连通过 `Last-Event-ID` 或 `lastEventId` 重放有限窗口,游标失效时收到一次 `snapshot-required` 并重新读取 HTTP 快照。前端不直接订阅 MQTT,也不使用定时轮询。
|
||||
TCP current 必须同时满足 present、NATMap intent、synced、Router、DNAT、NATMap active、非空 generation、candidate/current/last-observed 同 tuple,以及接收时仍新鲜且未倒退的 lease;否则只保留候选/最近观测并撤下 current。candidate、current、last-observed、last-published、静态错误、Keeper 错误和 NATMap 错误分别持久化。事件按 `eventId` 幂等追加并记录 `udp_stun|tcp_natmap` mechanism。
|
||||
|
||||
TCP 记录支持 API CRUD,但不提供 STUN/Keeper;当前已验证的 Agent 切片尚未启用 TCP 路由器写入,会明确回报 `tcp_router_write_gated`,不能把 pending/failed TCP 记录描述为已生效转发。UDP 只有 `externalPort === internalPort` 时可启用 Keeper。当前端点只有在 `currentValidUntil` 未过期时才返回为可用值;租约过期不会删除最近观测或 `network_endpoint_history`。API 不直接访问小米路由器、不修改 NAS 路由;真实路由器、raw UDP 与回程规则只由固定 NAS Agent/helper 处理。
|
||||
v2 endpoint event 与 matching reported 是两条独立 MQTT 消息。event、report 和 ownership 切换统一按 Agent state、channel、history 顺序加锁。正常 event-first 路径先提交 history,不提前创建会被 5 秒扫描器消费的 UDP Outbox;history 耐久保存来源 revision 和由 mechanism、tuple、observed/validated/valid-until 原始值生成的完整 lease identity,matching reported 只有在 revision、组/协议/mechanism、tuple 和未被 JS `Date` 截断的 lease 身份完全相同时,才在 current 同一事务中以原 `eventId` stage 现有 STUN 事件,提交后 wake。report-first 使用同一精确关联门禁。TCP 在本阶段只写历史,不复用 UDP STUN 消息源。组删除只有所有通道分别确认 exact absent 后才完成;先完成的通道单独软删除并清空活动键,仍有通道的组继续保留。v1 单 UDP 组继续使用 helper/router/route/Keeper/current 的原有 exact-absence 门禁。
|
||||
|
||||
Admin 首次进入网络管理页通过 HTTP 读取快照,随后使用 `/system/network/events/stream` 接收 `network-state-changed`。API 只在 `reported`、`status`、`events` 对应事务提交且语义状态实际变化后发出事件;MQTT QoS 1 重投、仅推进 `lastHeartbeatAt` 的状态心跳,以及相同 tuple 只推进 `validatedAt/validUntil` 的续租继续持久化,但不触发 history、SSE、DDNS 或消息 Outbox。公网 IP/端口、candidate/last-observed tuple、Keeper/NATMap/同步/错误/删除状态或 Agent 在线会话变化仍发布事件。一个入站消息不论改变多少通道都最多产生一次提交后 SSE。SSE 心跳只维持连接并复用最近一次真实状态事件 ID,尚无状态事件时显式发送空 ID;浏览器重连通过 `Last-Event-ID` 或 `lastEventId` 重放有限窗口,游标失效时收到一次 `snapshot-required` 并重新读取 HTTP 快照。前端不直接订阅 MQTT,也不使用定时轮询。
|
||||
|
||||
TCP 通道使用独立 NATMap 开关,UDP 通道使用独立 Keeper 开关;TCP 不提供 STUN,UDP 只有 `externalPort === internalPort` 时可启用 Keeper。`NETWORK_TCP_NATMAP_RELEASE_MODE=off|draining|canary|on` 控制 TCP 变更范围,canary 端口来自 `NETWORK_TCP_NATMAP_CANARY_PORTS`。当前端点只有在 `currentValidUntil` 未过期时才返回为可用值;租约过期不会删除 candidate、last-observed、last-published 或 `network_endpoint_history`。API 不直接访问小米路由器、不修改 NAS 路由;真实小米规则、FriendlyWrt DNAT/NATMap、raw UDP 与回程规则由固定 Agent/helper/Companion 处理。
|
||||
|
||||
自动 DDNS 支持 `A` 和 `AAAA`。A 记录绑定 `sourceType=port_forward_ipv4` 与一条合格的同源端口 UDP Keeper;AAAA 记录固定使用 `sourceType=agent_ipv6`,不得携带 `portForwardId`。来源暂不可用时记录进入 `waiting_source`,不会向腾讯云写入空地址。协调器只修改腾讯云云解析 DNS 中已存在、已启用、默认线路且唯一的同类型记录,写入时保留 RecordId、线路和 TTL,并回读相同 RecordId 验证结果;DNS 值不包含端口。删除接口只删除本地自动更新绑定,不删除云端记录。Provider 状态接口只返回开关、配置完整性和官方 provider 标识,不返回 SecretId、SecretKey 或 SDK 原始错误。
|
||||
|
||||
@ -130,7 +150,7 @@ Agent 状态响应额外包含可选的 `currentPublicIpv6/currentIpv6ObservedAt
|
||||
## 环境变量分组
|
||||
|
||||
| 分组 | 关键变量 |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
|
||||
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` |
|
||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
||||
@ -140,7 +160,7 @@ Agent 状态响应额外包含可选的 `currentPublicIpv6/currentIpv6ObservedAt
|
||||
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
|
||||
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
||||
| Env Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
|
||||
| Network | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_DDNS_DNSPOD_ENABLED`、`NETWORK_DDNS_DNSPOD_SECRET_ID`、`NETWORK_DDNS_DNSPOD_SECRET_KEY`、`NETWORK_DDNS_RECONCILE_INTERVAL_MS`、`NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` |
|
||||
| Network | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`NETWORK_TCP_NATMAP_RELEASE_MODE`、`NETWORK_TCP_NATMAP_CANARY_PORTS`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_DDNS_DNSPOD_ENABLED`、`NETWORK_DDNS_DNSPOD_SECRET_ID`、`NETWORK_DDNS_DNSPOD_SECRET_KEY`、`NETWORK_DDNS_RECONCILE_INTERVAL_MS`、`NETWORK_DDNS_AGENT_IPV6_MAX_AGE_MS` |
|
||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
||||
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
|
||||
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||
|
||||
10
README.md
10
README.md
@ -57,7 +57,7 @@ ci/ Jenkins Agent/Docker 辅助文件
|
||||
主要配置分组:
|
||||
|
||||
| 分组 | 变量 |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
|
||||
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET`、`BLOG_LIVE2D_ALLOWED_ORIGINS`、`BLOG_LIVE2D_BUCKET`、`BLOG_LIVE2D_ROOT_PREFIX`、`BLOG_LIVE2D_PREFIX` |
|
||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
||||
@ -65,7 +65,7 @@ ci/ Jenkins Agent/Docker 辅助文件
|
||||
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
|
||||
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_PLUGIN_QUEUE_REDIS_*`、`QQBOT_PLUGIN_TASK_QUEUE_REDIS_*`、`QQBOT_PLUGIN_QUEUE_WAIT_TIMEOUT_MS`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
|
||||
| Environment Dashboard | `ENV_DASHBOARD_CACHE_TTL_MS`、`ENV_DASHBOARD_SIGNAL_TIMEOUT_MS`、`ENV_DASHBOARD_EVENT_BUS`、`ENV_DASHBOARD_MQTT_*`、`ENV_DASHBOARD_SSE_*`、`ENV_DASHBOARD_JENKINS_*`、`ENV_DASHBOARD_K8S_*`、`ENV_DASHBOARD_TENCENT_*`、`ENV_DASHBOARD_CADDY_*`、`ENV_DASHBOARD_R4SE_*` |
|
||||
| Network Management | `NETWORK_AGENT_ID`、`NETWORK_AGENT_TARGET_IPV4`、`NETWORK_AGENT_MQTT_URL`、`NETWORK_AGENT_MQTT_CLIENT_ID`、`NETWORK_AGENT_MQTT_USERNAME`、`NETWORK_AGENT_MQTT_PASSWORD`、`NETWORK_AGENT_MQTT_RETRY_MS`、`NETWORK_MANAGEMENT_SSE_HEARTBEAT_MS`、`NETWORK_MANAGEMENT_SSE_REPLAY_LIMIT`、`NETWORK_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` |
|
||||
| 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` |
|
||||
@ -80,7 +80,11 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq
|
||||
|
||||
Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin;前端不直连 MQTT,也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。
|
||||
|
||||
System 网络管理以 MySQL 中的 TCP/UDP 端口转发期望状态为唯一事实源。`super` 通过统一 CRUD 和 UDP Keeper 动作修改期望状态;API 在事务内单调提升 revision,提交后使用固定 `kt/network/v1/agents/{agentId}` MQTT topic、QoS 1 retained 完整快照通知 NAS `kt-network-agent`,自身不登录路由器、不接收路由器密码,也不执行 raw socket。Agent 失联或 MQTT 暂不可用时合法请求仍保存为 pending,恢复后按 revision 自动收敛;消费端发生瞬时数据库错误或 SUBACK 失败时主动重连并依赖 broker 重投,非法负载则确认后丢弃,避免 poison message 阻塞。API 仅在入站 MQTT 事务提交且语义状态实际变化后通过 `/system/network/events/stream` 向 Admin 发布 SSE;QoS 1 幂等重投、`status` 心跳时间推进和 `reported` 租约时间续期仍写入数据库,但不触发页面刷新。公网 IP/端口、Keeper/同步/错误/删除状态或 Agent 在线会话变化仍发布事件。SSE 心跳复用最近一次真实状态事件游标,尚无状态事件时显式发送空游标,避免 Nest 自动生成的 ID 污染重放位置;有限重放缺口只要求一次 HTTP 快照。TCP 目前仅保存 CRUD 期望,真实路由器写入仍受设备协议证据门禁并回报 `tcp_router_write_gated`;只有外部端口等于内部端口的 UDP 记录允许启停 Keeper 和立即探测。当前公网端点受 `currentValidUntil` 租约约束,过期后列表隐藏当前值但保留最近观测与历史。生产发布同时把完整 `NETWORK_AGENT_*` 连接配置作为 Jenkins 私有 env 和 `/health/runtime` 必需项,任一项缺失时拒绝发布或报告运行态阻断,避免页面可见但 MQTT 控制链路未接线。
|
||||
System 网络管理以 MySQL 中的逻辑端口转发组和 TCP/UDP 协议通道为唯一事实源。`super` 可在同一外部端口配置 `TCP`、`UDP` 或 `TCP+UDP`,并分别启停 TCP NATMap 与 UDP Keeper;TCP 发布范围由 `NETWORK_TCP_NATMAP_RELEASE_MODE` 和 canary 端口控制。API 在事务内单调提升 revision,提交后按当前 schema owner 使用 `kt/network/v1|v2/agents/{agentId}`、QoS 1 retained 完整快照通知 NAS `kt-network-agent`,自身不登录路由器、不接收路由器密码,也不执行 raw socket。Agent 声明 v2 capability 且发布门禁允许后,v2 成为唯一 desired/reported/events 写入者,迟到的 v1 reported/events 不再覆盖状态。
|
||||
|
||||
MQTT v2 分别保存 candidate、current、last-observed、last-published、通道 report 原始 RFC3339Nano 水位,以及静态路径、Keeper 和 NATMap 错误。TCP current 只有在 desired present、NATMap intent、sync、Router、DNAT、NATMap 进程、generation、同 tuple 证据和新鲜 lease 全部通过时才发布;相同 tuple 续租只推进 validation/lease,不新增历史、不触发 DDNS、Outbox 或 Admin 刷新。同 revision 的旧 report 不得覆盖较新的通道状态;合法外层 snapshot 完成协调尝试后推进 applied revision 与独立 applied schema 水位,单通道 conflict 只隔离该通道。Agent 接受 v2 后会耐久锁存唯一 owner,在未来实现 Agent 可确认的降级握手前,API 单边降级固定拒绝。
|
||||
|
||||
v2 endpoint event 通常先于 matching reported 到达,因此 event-first 只提交 history;UDP 端口变更必须等 matching current 在 reported 事务中提交后才写现有 STUN Outbox,TCP 在独立消息源上线前只写 `tcp_natmap` history。event、report 与 ownership 切换统一按 Agent state、channel、history 顺序加锁;history 耐久保存来源 revision 和完整 lease identity,只有 revision、tuple 与未被时间精度截断的 lease 身份全部匹配才允许 stage,避免迟到事件误投递。所有 SSE、DDNS reconcile、投递 wake 和 desired republish 都在数据库事务成功后最多触发一次;组只有所有协议通道都确认 exact absent 后才软删除。Agent 失联或 MQTT 暂不可用时合法请求仍保存为 pending,恢复后按 revision 自动收敛;瞬时数据库错误或 SUBACK 失败依赖持久会话重投,非法负载确认后丢弃。当前公网端点受 `currentValidUntil` 约束,过期后列表隐藏 current,但保留候选、最近观测、发布基线与历史。
|
||||
|
||||
同一模块提供腾讯云云解析 DNS 的双栈自动 DDNS。A 记录只从合格 UDP Keeper 的有效公网 IPv4 取值,AAAA 记录只从在线 Agent 最近上报的全局 IPv6 取值;DNS 值始终不包含端口。协调器只修改已存在、已启用、默认线路且唯一的 A/AAAA 记录,保留 RecordId、线路和 TTL,并在写入后回读确认。删除 Admin 绑定只停止本地自动更新,不删除云端 DNS 记录。凭据只从 API 私有运行环境的 `NETWORK_DDNS_DNSPOD_SECRET_ID/SECRET_KEY` 读取,不进入 Admin、数据库、MQTT、Agent、日志或 Git;`DNSPOD` 是腾讯云官方 SDK 的技术服务名。
|
||||
|
||||
|
||||
@ -34,6 +34,8 @@ CREATE TABLE IF NOT EXISTS `network_port_forward` (
|
||||
`desired_revision` BIGINT NOT NULL DEFAULT 0,
|
||||
`desired_issued_at` DATETIME(6) NOT NULL,
|
||||
`reported_revision` BIGINT NOT NULL DEFAULT 0,
|
||||
`last_reported_at` DATETIME(6) NULL,
|
||||
`last_reported_at_wire` VARCHAR(64) NULL,
|
||||
`sync_status` VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
`keeper_status` VARCHAR(16) NOT NULL DEFAULT 'disabled',
|
||||
`natmap_status` VARCHAR(16) NOT NULL DEFAULT 'disabled',
|
||||
@ -41,15 +43,19 @@ CREATE TABLE IF NOT EXISTS `network_port_forward` (
|
||||
`current_public_port` INT NULL,
|
||||
`current_observed_at` DATETIME(6) NULL,
|
||||
`current_validated_at` DATETIME(6) NULL,
|
||||
`current_validated_at_wire` VARCHAR(64) NULL,
|
||||
`current_valid_until` DATETIME(6) NULL,
|
||||
`current_endpoint_identity` CHAR(64) NULL,
|
||||
`last_observed_ipv4` VARCHAR(15) NULL,
|
||||
`last_observed_port` INT NULL,
|
||||
`last_observed_at` DATETIME(6) NULL,
|
||||
`last_observed_validated_at` DATETIME(6) NULL,
|
||||
`last_observed_validated_at_wire` VARCHAR(64) NULL,
|
||||
`candidate_public_ipv4` VARCHAR(15) NULL,
|
||||
`candidate_public_port` INT NULL,
|
||||
`candidate_observed_at` DATETIME(6) NULL,
|
||||
`candidate_validated_at` DATETIME(6) NULL,
|
||||
`candidate_validated_at_wire` VARCHAR(64) NULL,
|
||||
`last_published_public_ipv4` VARCHAR(15) NULL,
|
||||
`last_published_public_port` INT NULL,
|
||||
`last_published_at` DATETIME(6) NULL,
|
||||
@ -111,16 +117,17 @@ CREATE TABLE IF NOT EXISTS `network_agent_state` (
|
||||
`max_supported_schema_version` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`tcp_natmap_capable` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`applied_revision` BIGINT NOT NULL DEFAULT 0,
|
||||
`applied_schema_version` INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
`online` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
`version` VARCHAR(64) NULL,
|
||||
`version` VARCHAR(128) NULL,
|
||||
`started_at` DATETIME(6) NULL,
|
||||
`last_heartbeat_at` DATETIME(6) NULL,
|
||||
`current_public_ipv6` VARCHAR(45) NULL,
|
||||
`current_ipv6_observed_at` DATETIME(3) NULL,
|
||||
`last_mqtt_error_code` VARCHAR(64) NULL,
|
||||
`last_mqtt_error_message` VARCHAR(500) NULL,
|
||||
`last_mqtt_error_message` VARCHAR(512) NULL,
|
||||
`last_reconcile_error_code` VARCHAR(64) NULL,
|
||||
`last_reconcile_error_message` VARCHAR(500) NULL,
|
||||
`last_reconcile_error_message` VARCHAR(512) NULL,
|
||||
`create_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
`update_time` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||
PRIMARY KEY (`agent_id`)
|
||||
@ -164,8 +171,12 @@ CREATE TABLE IF NOT EXISTS `network_endpoint_history` (
|
||||
`mapping_id` BIGINT NOT NULL,
|
||||
`event_type` VARCHAR(16) NOT NULL,
|
||||
`mechanism` VARCHAR(16) NOT NULL DEFAULT 'udp_stun',
|
||||
`source_revision` BIGINT NULL,
|
||||
`endpoint_identity` CHAR(64) NULL,
|
||||
`public_ipv4` VARCHAR(15) NULL,
|
||||
`public_port` INT NULL,
|
||||
`endpoint_validated_at` DATETIME(6) NULL,
|
||||
`endpoint_valid_until` DATETIME(6) NULL,
|
||||
`first_observed_at` DATETIME(6) NOT NULL,
|
||||
`last_observed_at` DATETIME(6) NOT NULL,
|
||||
`occurred_at` DATETIME(6) NOT NULL,
|
||||
|
||||
@ -25,6 +25,52 @@ WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_TYPE = 'bigint'
|
||||
AND IS_NULLABLE = 'NO';
|
||||
|
||||
SELECT 'network_port_forward_last_reported_at' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'network_port_forward'
|
||||
AND COLUMN_NAME = 'last_reported_at'
|
||||
AND COLUMN_TYPE = 'datetime(6)'
|
||||
AND IS_NULLABLE = 'YES';
|
||||
|
||||
SELECT 'network_port_forward_last_reported_at_wire' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'network_port_forward'
|
||||
AND COLUMN_NAME = 'last_reported_at_wire'
|
||||
AND COLUMN_TYPE = 'varchar(64)'
|
||||
AND IS_NULLABLE = 'YES';
|
||||
|
||||
SELECT 'network_port_forward_wire_identity_columns' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'network_port_forward'
|
||||
AND (
|
||||
(COLUMN_NAME = 'candidate_validated_at_wire' AND COLUMN_TYPE = 'varchar(64)' AND IS_NULLABLE = 'YES')
|
||||
OR (COLUMN_NAME = 'current_validated_at_wire' AND COLUMN_TYPE = 'varchar(64)' AND IS_NULLABLE = 'YES')
|
||||
OR (COLUMN_NAME = 'current_endpoint_identity' AND COLUMN_TYPE = 'char(64)' AND IS_NULLABLE = 'YES')
|
||||
OR (COLUMN_NAME = 'last_observed_validated_at_wire' AND COLUMN_TYPE = 'varchar(64)' AND IS_NULLABLE = 'YES')
|
||||
);
|
||||
|
||||
SELECT 'network_agent_state_applied_schema_version' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'network_agent_state'
|
||||
AND COLUMN_NAME = 'applied_schema_version'
|
||||
AND COLUMN_TYPE = 'int unsigned'
|
||||
AND IS_NULLABLE = 'NO'
|
||||
AND COLUMN_DEFAULT = '1';
|
||||
|
||||
SELECT 'network_agent_state_v2_text_widths' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'network_agent_state'
|
||||
AND (
|
||||
(COLUMN_NAME = 'version' AND COLUMN_TYPE = 'varchar(128)' AND IS_NULLABLE = 'YES')
|
||||
OR (COLUMN_NAME = 'last_mqtt_error_message' AND COLUMN_TYPE = 'varchar(512)' AND IS_NULLABLE = 'YES')
|
||||
OR (COLUMN_NAME = 'last_reconcile_error_message' AND COLUMN_TYPE = 'varchar(512)' AND IS_NULLABLE = 'YES')
|
||||
);
|
||||
|
||||
SELECT 'network_port_forward_v2_indexes' AS check_name, INDEX_NAME, NON_UNIQUE
|
||||
FROM INFORMATION_SCHEMA.STATISTICS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
@ -41,3 +87,14 @@ WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND COLUMN_NAME = 'mechanism'
|
||||
AND COLUMN_TYPE = 'varchar(16)'
|
||||
AND IS_NULLABLE = 'NO';
|
||||
|
||||
SELECT 'network_endpoint_history_correlation_columns' AS check_name, COUNT(*) AS matched_rows
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'network_endpoint_history'
|
||||
AND (
|
||||
(COLUMN_NAME = 'source_revision' AND COLUMN_TYPE = 'bigint' AND IS_NULLABLE = 'YES')
|
||||
OR (COLUMN_NAME = 'endpoint_identity' AND COLUMN_TYPE = 'char(64)' AND IS_NULLABLE = 'YES')
|
||||
OR (COLUMN_NAME = 'endpoint_validated_at' AND COLUMN_TYPE = 'datetime(6)' AND IS_NULLABLE = 'YES')
|
||||
OR (COLUMN_NAME = 'endpoint_valid_until' AND COLUMN_TYPE = 'datetime(6)' AND IS_NULLABLE = 'YES')
|
||||
);
|
||||
|
||||
@ -72,6 +72,34 @@ PREPARE network_port_forward_natmap_status_stmt FROM @network_port_forward_natma
|
||||
EXECUTE network_port_forward_natmap_status_stmt;
|
||||
DEALLOCATE PREPARE network_port_forward_natmap_status_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
|
||||
AND COLUMN_NAME = 'last_reported_at'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_port_forward` ADD COLUMN `last_reported_at` DATETIME(6) NULL AFTER `reported_revision`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
|
||||
AND COLUMN_NAME = 'last_reported_at_wire'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_port_forward` ADD COLUMN `last_reported_at_wire` VARCHAR(64) NULL AFTER `last_reported_at`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
|
||||
@ -212,6 +240,62 @@ PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
|
||||
AND COLUMN_NAME = 'candidate_validated_at_wire'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_port_forward` ADD COLUMN `candidate_validated_at_wire` VARCHAR(64) NULL AFTER `candidate_validated_at`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
|
||||
AND COLUMN_NAME = 'current_validated_at_wire'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_port_forward` ADD COLUMN `current_validated_at_wire` VARCHAR(64) NULL AFTER `current_validated_at`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
|
||||
AND COLUMN_NAME = 'current_endpoint_identity'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_port_forward` ADD COLUMN `current_endpoint_identity` CHAR(64) NULL AFTER `current_valid_until`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
|
||||
AND COLUMN_NAME = 'last_observed_validated_at_wire'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_port_forward` ADD COLUMN `last_observed_validated_at_wire` VARCHAR(64) NULL AFTER `last_observed_validated_at`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_port_forward'
|
||||
@ -310,6 +394,65 @@ PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_agent_state'
|
||||
AND COLUMN_NAME = 'applied_schema_version'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_agent_state` ADD COLUMN `applied_schema_version` INT UNSIGNED NOT NULL DEFAULT 1 AFTER `applied_revision`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_needs_resize := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_agent_state'
|
||||
AND COLUMN_NAME = 'version'
|
||||
AND (COLUMN_TYPE <> 'varchar(128)' OR IS_NULLABLE <> 'YES')
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_needs_resize = 1,
|
||||
'ALTER TABLE `network_agent_state` MODIFY COLUMN `version` VARCHAR(128) NULL',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_needs_resize := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_agent_state'
|
||||
AND COLUMN_NAME = 'last_mqtt_error_message'
|
||||
AND (COLUMN_TYPE <> 'varchar(512)' OR IS_NULLABLE <> 'YES')
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_needs_resize = 1,
|
||||
'ALTER TABLE `network_agent_state` MODIFY COLUMN `last_mqtt_error_message` VARCHAR(512) NULL',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_needs_resize := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_agent_state'
|
||||
AND COLUMN_NAME = 'last_reconcile_error_message'
|
||||
AND (COLUMN_TYPE <> 'varchar(512)' OR IS_NULLABLE <> 'YES')
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_needs_resize = 1,
|
||||
'ALTER TABLE `network_agent_state` MODIFY COLUMN `last_reconcile_error_message` VARCHAR(512) NULL',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_endpoint_history_mechanism_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history'
|
||||
@ -324,16 +467,73 @@ PREPARE network_endpoint_history_mechanism_stmt FROM @network_endpoint_history_m
|
||||
EXECUTE network_endpoint_history_mechanism_stmt;
|
||||
DEALLOCATE PREPARE network_endpoint_history_mechanism_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history'
|
||||
AND COLUMN_NAME = 'source_revision'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_endpoint_history` ADD COLUMN `source_revision` BIGINT NULL AFTER `mechanism`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history'
|
||||
AND COLUMN_NAME = 'endpoint_identity'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_endpoint_history` ADD COLUMN `endpoint_identity` CHAR(64) NULL AFTER `source_revision`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history'
|
||||
AND COLUMN_NAME = 'endpoint_validated_at'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_endpoint_history` ADD COLUMN `endpoint_validated_at` DATETIME(6) NULL AFTER `public_port`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
SET @network_tcp_natmap_v2_column_exists := (
|
||||
SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'network_endpoint_history'
|
||||
AND COLUMN_NAME = 'endpoint_valid_until'
|
||||
);
|
||||
SET @network_tcp_natmap_v2_alter_sql := IF(
|
||||
@network_tcp_natmap_v2_column_exists = 0,
|
||||
'ALTER TABLE `network_endpoint_history` ADD COLUMN `endpoint_valid_until` DATETIME(6) NULL AFTER `endpoint_validated_at`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE network_tcp_natmap_v2_alter_stmt FROM @network_tcp_natmap_v2_alter_sql;
|
||||
EXECUTE network_tcp_natmap_v2_alter_stmt;
|
||||
DEALLOCATE PREPARE network_tcp_natmap_v2_alter_stmt;
|
||||
|
||||
INSERT INTO `network_port_forward_group` (
|
||||
`id`, `name`, `remark`, `external_port`, `internal_port`, `protocol_mode`,
|
||||
`target_ipv4`, `is_deleted`, `create_time`, `update_time`
|
||||
)
|
||||
SELECT channel.id, channel.name, channel.remark, channel.external_port,
|
||||
channel.internal_port, 'udp', channel.target_ipv4, channel.is_deleted,
|
||||
channel.internal_port, channel.protocol, channel.target_ipv4, channel.is_deleted,
|
||||
channel.create_time, channel.update_time
|
||||
FROM `network_port_forward` channel
|
||||
LEFT JOIN `network_port_forward_group` grouped ON grouped.id = channel.id
|
||||
WHERE grouped.id IS NULL;
|
||||
WHERE channel.group_id IS NULL
|
||||
AND grouped.id IS NULL;
|
||||
|
||||
UPDATE `network_port_forward` channel
|
||||
SET `group_id` = channel.id
|
||||
|
||||
@ -157,6 +157,8 @@ CREATE TABLE IF NOT EXISTS network_port_forward (
|
||||
desired_revision BIGINT NOT NULL DEFAULT 0,
|
||||
desired_issued_at DATETIME(6) NOT NULL,
|
||||
reported_revision BIGINT NOT NULL DEFAULT 0,
|
||||
last_reported_at DATETIME(6) NULL,
|
||||
last_reported_at_wire VARCHAR(64) NULL,
|
||||
sync_status VARCHAR(16) NOT NULL DEFAULT 'pending',
|
||||
keeper_status VARCHAR(16) NOT NULL DEFAULT 'disabled',
|
||||
natmap_status VARCHAR(16) NOT NULL DEFAULT 'disabled',
|
||||
@ -164,15 +166,19 @@ CREATE TABLE IF NOT EXISTS network_port_forward (
|
||||
current_public_port INT NULL,
|
||||
current_observed_at DATETIME(6) NULL,
|
||||
current_validated_at DATETIME(6) NULL,
|
||||
current_validated_at_wire VARCHAR(64) NULL,
|
||||
current_valid_until DATETIME(6) NULL,
|
||||
current_endpoint_identity CHAR(64) NULL,
|
||||
last_observed_ipv4 VARCHAR(15) NULL,
|
||||
last_observed_port INT NULL,
|
||||
last_observed_at DATETIME(6) NULL,
|
||||
last_observed_validated_at DATETIME(6) NULL,
|
||||
last_observed_validated_at_wire VARCHAR(64) NULL,
|
||||
candidate_public_ipv4 VARCHAR(15) NULL,
|
||||
candidate_public_port INT NULL,
|
||||
candidate_observed_at DATETIME(6) NULL,
|
||||
candidate_validated_at DATETIME(6) NULL,
|
||||
candidate_validated_at_wire VARCHAR(64) NULL,
|
||||
last_published_public_ipv4 VARCHAR(15) NULL,
|
||||
last_published_public_port INT NULL,
|
||||
last_published_at DATETIME(6) NULL,
|
||||
@ -232,16 +238,17 @@ CREATE TABLE IF NOT EXISTS network_agent_state (
|
||||
max_supported_schema_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
tcp_natmap_capable TINYINT(1) NOT NULL DEFAULT 0,
|
||||
applied_revision BIGINT NOT NULL DEFAULT 0,
|
||||
applied_schema_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||||
online TINYINT(1) NOT NULL DEFAULT 0,
|
||||
version VARCHAR(64) NULL,
|
||||
version VARCHAR(128) NULL,
|
||||
started_at DATETIME(6) NULL,
|
||||
last_heartbeat_at DATETIME(6) NULL,
|
||||
current_public_ipv6 VARCHAR(45) NULL,
|
||||
current_ipv6_observed_at DATETIME(3) NULL,
|
||||
last_mqtt_error_code VARCHAR(64) NULL,
|
||||
last_mqtt_error_message VARCHAR(500) NULL,
|
||||
last_mqtt_error_message VARCHAR(512) NULL,
|
||||
last_reconcile_error_code VARCHAR(64) NULL,
|
||||
last_reconcile_error_message VARCHAR(500) NULL,
|
||||
last_reconcile_error_message VARCHAR(512) NULL,
|
||||
create_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
update_time DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@ -252,8 +259,12 @@ CREATE TABLE IF NOT EXISTS network_endpoint_history (
|
||||
mapping_id BIGINT NOT NULL,
|
||||
event_type VARCHAR(16) NOT NULL,
|
||||
mechanism VARCHAR(16) NOT NULL DEFAULT 'udp_stun',
|
||||
source_revision BIGINT NULL,
|
||||
endpoint_identity CHAR(64) NULL,
|
||||
public_ipv4 VARCHAR(15) NULL,
|
||||
public_port INT NULL,
|
||||
endpoint_validated_at DATETIME(6) NULL,
|
||||
endpoint_valid_until DATETIME(6) NULL,
|
||||
first_observed_at DATETIME(6) NOT NULL,
|
||||
last_observed_at DATETIME(6) NOT NULL,
|
||||
occurred_at DATETIME(6) NOT NULL,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -53,10 +53,18 @@ export class NetworkAgentState {
|
||||
@Column({ default: '0', name: 'applied_revision', type: 'bigint' })
|
||||
appliedRevision: string;
|
||||
|
||||
@Column({
|
||||
default: 1,
|
||||
name: 'applied_schema_version',
|
||||
type: 'int',
|
||||
unsigned: true,
|
||||
})
|
||||
appliedSchemaVersion: number;
|
||||
|
||||
@Column({ default: false, type: 'boolean' })
|
||||
online: boolean;
|
||||
|
||||
@Column({ length: 64, nullable: true })
|
||||
@Column({ length: 128, nullable: true })
|
||||
version?: string | null;
|
||||
|
||||
@KtDateTimeColumn({ name: 'started_at', nullable: true, type: 'datetime' })
|
||||
@ -83,13 +91,13 @@ export class NetworkAgentState {
|
||||
@Column({ length: 64, name: 'last_mqtt_error_code', nullable: true })
|
||||
lastMqttErrorCode?: string | null;
|
||||
|
||||
@Column({ length: 500, name: 'last_mqtt_error_message', nullable: true })
|
||||
@Column({ length: 512, name: 'last_mqtt_error_message', nullable: true })
|
||||
lastMqttErrorMessage?: string | null;
|
||||
|
||||
@Column({ length: 64, name: 'last_reconcile_error_code', nullable: true })
|
||||
lastReconcileErrorCode?: string | null;
|
||||
|
||||
@Column({ length: 500, name: 'last_reconcile_error_message', nullable: true })
|
||||
@Column({ length: 512, name: 'last_reconcile_error_message', nullable: true })
|
||||
lastReconcileErrorMessage?: string | null;
|
||||
|
||||
@KtCreateDateColumn({ name: 'create_time' })
|
||||
|
||||
@ -3,6 +3,7 @@ import { isIP } from 'node:net';
|
||||
import { TextDecoder } from 'node:util';
|
||||
import type { NetworkAgentState } from './network-agent-state.entity';
|
||||
import type { NetworkPortForward } from './network-management.entity';
|
||||
import type { EndpointMechanism } from './network-management.types';
|
||||
|
||||
export const NETWORK_AGENT_V2_SCHEMA_VERSION = 2 as const;
|
||||
export const NETWORK_AGENT_V2_MAX_CHANNELS = 64;
|
||||
@ -30,7 +31,7 @@ export type NetworkV2NatmapStatus =
|
||||
| 'stale'
|
||||
| 'starting'
|
||||
| 'stopping';
|
||||
export type NetworkV2EndpointMechanism = 'tcp_natmap' | 'udp_stun';
|
||||
export type NetworkV2EndpointMechanism = EndpointMechanism;
|
||||
export type NetworkV2EndpointEventType =
|
||||
| 'changed'
|
||||
| 'published'
|
||||
@ -173,9 +174,36 @@ export class NetworkV2MessageValidationError extends Error {}
|
||||
const ID_PATTERN = /^\d{1,32}$/;
|
||||
const REQUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
|
||||
const ERROR_CODE_PATTERN = /^[a-z0-9_]{1,64}$/;
|
||||
const RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/;
|
||||
const RFC3339_PATTERN =
|
||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|([+-])(\d{2}):(\d{2}))$/;
|
||||
type NetworkV2WirePayload = Buffer | string;
|
||||
|
||||
export function compareNetworkV2Timestamps(
|
||||
left: string,
|
||||
right: string,
|
||||
): number {
|
||||
const leftNanoseconds = rfc3339Nanoseconds(left);
|
||||
const rightNanoseconds = rfc3339Nanoseconds(right);
|
||||
return leftNanoseconds < rightNanoseconds
|
||||
? -1
|
||||
: leftNanoseconds > rightNanoseconds
|
||||
? 1
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function endpointLeaseIdentityV2(
|
||||
endpoint: NetworkEndpointLeaseV2,
|
||||
): string {
|
||||
return digest({
|
||||
mechanism: endpoint.mechanism,
|
||||
observedAt: endpoint.observedAt,
|
||||
publicIpv4: endpoint.publicIpv4,
|
||||
publicPort: endpoint.publicPort,
|
||||
validatedAt: endpoint.validatedAt,
|
||||
validUntil: endpoint.validUntil,
|
||||
});
|
||||
}
|
||||
|
||||
export function canonicalDesiredChannelDigestV2(
|
||||
channel: NetworkDesiredChannelV2,
|
||||
): string {
|
||||
@ -183,7 +211,10 @@ export function canonicalDesiredChannelDigestV2(
|
||||
}
|
||||
|
||||
export function canonicalDesiredSnapshotDigestV2(
|
||||
snapshot: Pick<NetworkDesiredSnapshotV2, 'agentId' | 'channels' | 'schemaVersion'>,
|
||||
snapshot: Pick<
|
||||
NetworkDesiredSnapshotV2,
|
||||
'agentId' | 'channels' | 'schemaVersion'
|
||||
>,
|
||||
): string {
|
||||
return digest({
|
||||
schemaVersion: snapshot.schemaVersion,
|
||||
@ -195,11 +226,16 @@ export function canonicalDesiredSnapshotDigestV2(
|
||||
}
|
||||
|
||||
export function buildDesiredSnapshotV2(
|
||||
state: Pick<NetworkAgentState, 'agentId' | 'desiredIssuedAt' | 'desiredRevision'>,
|
||||
state: Pick<
|
||||
NetworkAgentState,
|
||||
'agentId' | 'desiredIssuedAt' | 'desiredRevision'
|
||||
>,
|
||||
channels: NetworkDesiredChannelSourceV2[],
|
||||
): NetworkDesiredSnapshotV2 {
|
||||
if (channels.length > NETWORK_AGENT_V2_MAX_CHANNELS) invalid('desired.channels');
|
||||
const desiredChannels = channels.map((channel) => {
|
||||
if (channels.length > NETWORK_AGENT_V2_MAX_CHANNELS)
|
||||
invalid('desired.channels');
|
||||
const desiredChannels = channels
|
||||
.map((channel) => {
|
||||
const base = {
|
||||
channelDesiredDigest: '',
|
||||
channelDesiredRevision: safeRevisionFromString(
|
||||
@ -230,7 +266,8 @@ export function buildDesiredSnapshotV2(
|
||||
};
|
||||
desired.channelDesiredDigest = canonicalDesiredChannelDigestV2(desired);
|
||||
return desired;
|
||||
}).sort(compareDesiredChannelsV2);
|
||||
})
|
||||
.sort(compareDesiredChannelsV2);
|
||||
if (
|
||||
new Set(desiredChannels.map((channel) => channel.channelId)).size !==
|
||||
desiredChannels.length
|
||||
@ -261,23 +298,42 @@ export function parseDesiredSnapshotV2(
|
||||
function validateDesiredSnapshotV2(value: unknown): NetworkDesiredSnapshotV2 {
|
||||
const record = exactRecord(
|
||||
value,
|
||||
['agentId', 'channels', 'issuedAt', 'schemaVersion', 'snapshotDigest', 'snapshotRevision'],
|
||||
[
|
||||
'agentId',
|
||||
'channels',
|
||||
'issuedAt',
|
||||
'schemaVersion',
|
||||
'snapshotDigest',
|
||||
'snapshotRevision',
|
||||
],
|
||||
[],
|
||||
'desired',
|
||||
);
|
||||
assertSchema(record.schemaVersion);
|
||||
if (!Array.isArray(record.channels) || record.channels.length > NETWORK_AGENT_V2_MAX_CHANNELS) invalid('desired.channels');
|
||||
if (
|
||||
!Array.isArray(record.channels) ||
|
||||
record.channels.length > NETWORK_AGENT_V2_MAX_CHANNELS
|
||||
)
|
||||
invalid('desired.channels');
|
||||
const channels = record.channels.map(parseDesiredChannelV2);
|
||||
if (new Set(channels.map((channel) => channel.channelId)).size !== channels.length) invalid('desired duplicate channel');
|
||||
if (
|
||||
new Set(channels.map((channel) => channel.channelId)).size !==
|
||||
channels.length
|
||||
)
|
||||
invalid('desired duplicate channel');
|
||||
const snapshot: NetworkDesiredSnapshotV2 = {
|
||||
agentId: boundedString(record.agentId, 'agentId', 64),
|
||||
channels,
|
||||
issuedAt: isoString(record.issuedAt, 'issuedAt'),
|
||||
schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION,
|
||||
snapshotDigest: digestValue(record.snapshotDigest, 'snapshotDigest'),
|
||||
snapshotRevision: positiveRevision(record.snapshotRevision, 'snapshotRevision'),
|
||||
snapshotRevision: positiveRevision(
|
||||
record.snapshotRevision,
|
||||
'snapshotRevision',
|
||||
),
|
||||
};
|
||||
if (canonicalDesiredSnapshotDigestV2(snapshot) !== snapshot.snapshotDigest) invalid('snapshotDigest');
|
||||
if (canonicalDesiredSnapshotDigestV2(snapshot) !== snapshot.snapshotDigest)
|
||||
invalid('snapshotDigest');
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
@ -288,18 +344,41 @@ export function parseReportedSnapshotV2(
|
||||
}
|
||||
|
||||
function validateReportedSnapshotV2(value: unknown): NetworkReportedSnapshotV2 {
|
||||
const record = exactRecord(value, ['agentId', 'channels', 'reportedAt', 'schemaVersion', 'snapshotDigest', 'snapshotRevision'], [], 'reported');
|
||||
const record = exactRecord(
|
||||
value,
|
||||
[
|
||||
'agentId',
|
||||
'channels',
|
||||
'reportedAt',
|
||||
'schemaVersion',
|
||||
'snapshotDigest',
|
||||
'snapshotRevision',
|
||||
],
|
||||
[],
|
||||
'reported',
|
||||
);
|
||||
assertSchema(record.schemaVersion);
|
||||
if (!Array.isArray(record.channels) || record.channels.length > NETWORK_AGENT_V2_MAX_CHANNELS) invalid('reported.channels');
|
||||
if (
|
||||
!Array.isArray(record.channels) ||
|
||||
record.channels.length > NETWORK_AGENT_V2_MAX_CHANNELS
|
||||
)
|
||||
invalid('reported.channels');
|
||||
const channels = record.channels.map(parseReportedChannelV2);
|
||||
if (new Set(channels.map((channel) => channel.channelId)).size !== channels.length) invalid('reported duplicate channel');
|
||||
if (
|
||||
new Set(channels.map((channel) => channel.channelId)).size !==
|
||||
channels.length
|
||||
)
|
||||
invalid('reported duplicate channel');
|
||||
return {
|
||||
agentId: boundedString(record.agentId, 'agentId', 64),
|
||||
channels,
|
||||
reportedAt: isoString(record.reportedAt, 'reportedAt'),
|
||||
schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION,
|
||||
snapshotDigest: digestValue(record.snapshotDigest, 'snapshotDigest'),
|
||||
snapshotRevision: positiveRevision(record.snapshotRevision, 'snapshotRevision'),
|
||||
snapshotRevision: positiveRevision(
|
||||
record.snapshotRevision,
|
||||
'snapshotRevision',
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@ -310,21 +389,51 @@ export function parseStatusSnapshotV2(
|
||||
}
|
||||
|
||||
function validateStatusSnapshotV2(value: unknown): NetworkStatusSnapshotV2 {
|
||||
const record = exactRecord(value, ['agentId', 'observedAt', 'online', 'schemaVersion', 'supportedSchemaVersions', 'tcpNatmapCapable'], ['errorCode', 'errorMessage', 'publicIpv6', 'startedAt', 'version'], 'status');
|
||||
const record = exactRecord(
|
||||
value,
|
||||
[
|
||||
'agentId',
|
||||
'observedAt',
|
||||
'online',
|
||||
'schemaVersion',
|
||||
'supportedSchemaVersions',
|
||||
'tcpNatmapCapable',
|
||||
],
|
||||
['errorCode', 'errorMessage', 'publicIpv6', 'startedAt', 'version'],
|
||||
'status',
|
||||
);
|
||||
assertSchema(record.schemaVersion);
|
||||
if (!Array.isArray(record.supportedSchemaVersions) || record.supportedSchemaVersions.length !== 2 || record.supportedSchemaVersions[0] !== 1 || record.supportedSchemaVersions[1] !== 2) invalid('supportedSchemaVersions');
|
||||
if (
|
||||
!Array.isArray(record.supportedSchemaVersions) ||
|
||||
record.supportedSchemaVersions.length !== 2 ||
|
||||
record.supportedSchemaVersions[0] !== 1 ||
|
||||
record.supportedSchemaVersions[1] !== 2
|
||||
)
|
||||
invalid('supportedSchemaVersions');
|
||||
return {
|
||||
agentId: boundedString(record.agentId, 'agentId', 64),
|
||||
...(record.errorCode === undefined ? {} : { errorCode: errorCode(record.errorCode, 'errorCode') }),
|
||||
...(record.errorMessage === undefined ? {} : { errorMessage: boundedString(record.errorMessage, 'errorMessage', 512) }),
|
||||
...(record.errorCode === undefined
|
||||
? {}
|
||||
: { errorCode: errorCode(record.errorCode, 'errorCode') }),
|
||||
...(record.errorMessage === undefined
|
||||
? {}
|
||||
: {
|
||||
errorMessage: boundedString(record.errorMessage, 'errorMessage', 512),
|
||||
}),
|
||||
observedAt: isoString(record.observedAt, 'observedAt'),
|
||||
online: booleanValue(record.online, 'online'),
|
||||
...(record.publicIpv6 === undefined ? {} : { publicIpv6: publicIpv6(record.publicIpv6) }),
|
||||
...(record.publicIpv6 === undefined
|
||||
? {}
|
||||
: { publicIpv6: publicIpv6(record.publicIpv6) }),
|
||||
schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION,
|
||||
...(record.startedAt === undefined ? {} : { startedAt: isoString(record.startedAt, 'startedAt') }),
|
||||
...(record.startedAt === undefined
|
||||
? {}
|
||||
: { startedAt: isoString(record.startedAt, 'startedAt') }),
|
||||
supportedSchemaVersions: [1, 2],
|
||||
tcpNatmapCapable: booleanValue(record.tcpNatmapCapable, 'tcpNatmapCapable'),
|
||||
...(record.version === undefined ? {} : { version: boundedString(record.version, 'version', 128) }),
|
||||
...(record.version === undefined
|
||||
? {}
|
||||
: { version: boundedString(record.version, 'version', 128) }),
|
||||
};
|
||||
}
|
||||
|
||||
@ -335,14 +444,47 @@ export function parseEndpointEventV2(
|
||||
}
|
||||
|
||||
function validateEndpointEventV2(value: unknown): NetworkEndpointEventV2 {
|
||||
const record = exactRecord(value, ['agentId', 'channelId', 'eventId', 'groupId', 'mechanism', 'occurredAt', 'protocol', 'revision', 'schemaVersion', 'type'], ['endpoint', 'reason'], 'event');
|
||||
const record = exactRecord(
|
||||
value,
|
||||
[
|
||||
'agentId',
|
||||
'channelId',
|
||||
'eventId',
|
||||
'groupId',
|
||||
'mechanism',
|
||||
'occurredAt',
|
||||
'protocol',
|
||||
'revision',
|
||||
'schemaVersion',
|
||||
'type',
|
||||
],
|
||||
['endpoint', 'reason'],
|
||||
'event',
|
||||
);
|
||||
assertSchema(record.schemaVersion);
|
||||
const protocol = enumValue(record.protocol, ['tcp', 'udp'] as const, 'protocol');
|
||||
const mechanism = enumValue(record.mechanism, ['tcp_natmap', 'udp_stun'] as const, 'mechanism');
|
||||
const type = enumValue(record.type, ['changed', 'published', 'restored', 'withdrawn'] as const, 'type');
|
||||
if ((protocol === 'tcp') !== (mechanism === 'tcp_natmap')) invalid('event mechanism');
|
||||
const endpoint = record.endpoint === undefined ? undefined : parseEndpointV2(record.endpoint, mechanism);
|
||||
if ((type === 'withdrawn') !== (endpoint === undefined)) invalid('event endpoint');
|
||||
const protocol = enumValue(
|
||||
record.protocol,
|
||||
['tcp', 'udp'] as const,
|
||||
'protocol',
|
||||
);
|
||||
const mechanism = enumValue(
|
||||
record.mechanism,
|
||||
['tcp_natmap', 'udp_stun'] as const,
|
||||
'mechanism',
|
||||
);
|
||||
const type = enumValue(
|
||||
record.type,
|
||||
['changed', 'published', 'restored', 'withdrawn'] as const,
|
||||
'type',
|
||||
);
|
||||
if ((protocol === 'tcp') !== (mechanism === 'tcp_natmap'))
|
||||
invalid('event mechanism');
|
||||
const endpoint =
|
||||
record.endpoint === undefined
|
||||
? undefined
|
||||
: parseEndpointV2(record.endpoint, mechanism);
|
||||
if ((type === 'withdrawn') !== (endpoint === undefined))
|
||||
invalid('event endpoint');
|
||||
return {
|
||||
agentId: boundedString(record.agentId, 'agentId', 64),
|
||||
channelId: identifier(record.channelId, 'channelId'),
|
||||
@ -352,7 +494,9 @@ function validateEndpointEventV2(value: unknown): NetworkEndpointEventV2 {
|
||||
mechanism,
|
||||
occurredAt: isoString(record.occurredAt, 'occurredAt'),
|
||||
protocol,
|
||||
...(record.reason === undefined ? {} : { reason: boundedString(record.reason, 'reason', 128) }),
|
||||
...(record.reason === undefined
|
||||
? {}
|
||||
: { reason: boundedString(record.reason, 'reason', 128) }),
|
||||
revision: positiveRevision(record.revision, 'revision'),
|
||||
schemaVersion: NETWORK_AGENT_V2_SCHEMA_VERSION,
|
||||
type,
|
||||
@ -361,78 +505,547 @@ function validateEndpointEventV2(value: unknown): NetworkEndpointEventV2 {
|
||||
|
||||
function parseDesiredChannelV2(value: unknown): NetworkDesiredChannelV2 {
|
||||
const protocol = protocolOf(value, 'desired channel');
|
||||
const required = ['channelDesiredDigest', 'channelDesiredRevision', 'channelId', 'desiredPresence', 'externalPort', 'groupId', 'internalPort', 'name', 'protocol'];
|
||||
const record = exactRecord(value, protocol === 'tcp' ? [...required, 'natmapDesiredEnabled'] : [...required, 'keeperDesiredEnabled'], protocol === 'udp' ? ['probeRequestId'] : [], 'desired channel');
|
||||
const required = [
|
||||
'channelDesiredDigest',
|
||||
'channelDesiredRevision',
|
||||
'channelId',
|
||||
'desiredPresence',
|
||||
'externalPort',
|
||||
'groupId',
|
||||
'internalPort',
|
||||
'name',
|
||||
'protocol',
|
||||
];
|
||||
const record = exactRecord(
|
||||
value,
|
||||
protocol === 'tcp'
|
||||
? [...required, 'natmapDesiredEnabled']
|
||||
: [...required, 'keeperDesiredEnabled'],
|
||||
protocol === 'udp' ? ['probeRequestId'] : [],
|
||||
'desired channel',
|
||||
);
|
||||
const base = desiredBase(record);
|
||||
const channel = protocol === 'tcp'
|
||||
? { ...base, natmapDesiredEnabled: booleanValue(record.natmapDesiredEnabled, 'natmapDesiredEnabled'), protocol }
|
||||
: { ...base, keeperDesiredEnabled: booleanValue(record.keeperDesiredEnabled, 'keeperDesiredEnabled'), ...(record.probeRequestId === undefined ? {} : { probeRequestId: requestId(record.probeRequestId, 'probeRequestId') }), protocol };
|
||||
if (canonicalDesiredChannelDigestV2(channel) !== channel.channelDesiredDigest) invalid('channelDesiredDigest');
|
||||
const channel =
|
||||
protocol === 'tcp'
|
||||
? {
|
||||
...base,
|
||||
natmapDesiredEnabled: booleanValue(
|
||||
record.natmapDesiredEnabled,
|
||||
'natmapDesiredEnabled',
|
||||
),
|
||||
protocol,
|
||||
}
|
||||
: {
|
||||
...base,
|
||||
keeperDesiredEnabled: booleanValue(
|
||||
record.keeperDesiredEnabled,
|
||||
'keeperDesiredEnabled',
|
||||
),
|
||||
...(record.probeRequestId === undefined
|
||||
? {}
|
||||
: {
|
||||
probeRequestId: requestId(
|
||||
record.probeRequestId,
|
||||
'probeRequestId',
|
||||
),
|
||||
}),
|
||||
protocol,
|
||||
};
|
||||
if (canonicalDesiredChannelDigestV2(channel) !== channel.channelDesiredDigest)
|
||||
invalid('channelDesiredDigest');
|
||||
return channel;
|
||||
}
|
||||
|
||||
function parseReportedChannelV2(value: unknown): NetworkReportedChannelV2 {
|
||||
const protocol = protocolOf(value, 'reported channel');
|
||||
const common = ['appliedDesiredDigest', 'appliedDesiredRevision', 'channelId', 'desiredPresence', 'groupId', 'protocol', 'routerPresent', 'syncStatus'];
|
||||
const record = exactRecord(value, protocol === 'tcp' ? [...common, 'dnatPresent', 'natmapDesiredEnabled', 'natmapStatus'] : [...common, 'keeperDesiredEnabled', 'keeperStatus', 'routePresent'], protocol === 'tcp' ? ['candidateEndpoint', 'currentEndpoint', 'errorCode', 'errorMessage', 'instanceGeneration', 'lastObservedEndpoint', 'natmapErrorCode', 'natmapErrorMessage'] : ['currentEndpoint', 'errorCode', 'errorMessage', 'keeperErrorCode', 'keeperErrorMessage', 'lastObservedEndpoint', 'lastProbeRequestId'], 'reported channel');
|
||||
const common = [
|
||||
'appliedDesiredDigest',
|
||||
'appliedDesiredRevision',
|
||||
'channelId',
|
||||
'desiredPresence',
|
||||
'groupId',
|
||||
'protocol',
|
||||
'routerPresent',
|
||||
'syncStatus',
|
||||
];
|
||||
const record = exactRecord(
|
||||
value,
|
||||
protocol === 'tcp'
|
||||
? [...common, 'dnatPresent', 'natmapDesiredEnabled', 'natmapStatus']
|
||||
: [...common, 'keeperDesiredEnabled', 'keeperStatus', 'routePresent'],
|
||||
protocol === 'tcp'
|
||||
? [
|
||||
'candidateEndpoint',
|
||||
'currentEndpoint',
|
||||
'errorCode',
|
||||
'errorMessage',
|
||||
'instanceGeneration',
|
||||
'lastObservedEndpoint',
|
||||
'natmapErrorCode',
|
||||
'natmapErrorMessage',
|
||||
]
|
||||
: [
|
||||
'currentEndpoint',
|
||||
'errorCode',
|
||||
'errorMessage',
|
||||
'keeperErrorCode',
|
||||
'keeperErrorMessage',
|
||||
'lastObservedEndpoint',
|
||||
'lastProbeRequestId',
|
||||
],
|
||||
'reported channel',
|
||||
);
|
||||
const base: ReportedChannelBaseV2 = {
|
||||
appliedDesiredDigest: digestValue(record.appliedDesiredDigest, 'appliedDesiredDigest'),
|
||||
appliedDesiredRevision: positiveRevision(record.appliedDesiredRevision, 'appliedDesiredRevision'),
|
||||
appliedDesiredDigest: digestValue(
|
||||
record.appliedDesiredDigest,
|
||||
'appliedDesiredDigest',
|
||||
),
|
||||
appliedDesiredRevision: positiveRevision(
|
||||
record.appliedDesiredRevision,
|
||||
'appliedDesiredRevision',
|
||||
),
|
||||
channelId: identifier(record.channelId, 'channelId'),
|
||||
desiredPresence: enumValue(record.desiredPresence, ['absent', 'present'] as const, 'desiredPresence'),
|
||||
...(record.errorCode === undefined ? {} : { errorCode: errorCode(record.errorCode, 'errorCode') }),
|
||||
...(record.errorMessage === undefined ? {} : { errorMessage: boundedString(record.errorMessage, 'errorMessage', 512) }),
|
||||
groupId: identifier(record.groupId, 'groupId'), protocol,
|
||||
desiredPresence: enumValue(
|
||||
record.desiredPresence,
|
||||
['absent', 'present'] as const,
|
||||
'desiredPresence',
|
||||
),
|
||||
...(record.errorCode === undefined
|
||||
? {}
|
||||
: { errorCode: errorCode(record.errorCode, 'errorCode') }),
|
||||
...(record.errorMessage === undefined
|
||||
? {}
|
||||
: {
|
||||
errorMessage: boundedString(record.errorMessage, 'errorMessage', 512),
|
||||
}),
|
||||
groupId: identifier(record.groupId, 'groupId'),
|
||||
protocol,
|
||||
routerPresent: booleanValue(record.routerPresent, 'routerPresent'),
|
||||
syncStatus: enumValue(record.syncStatus, ['conflict', 'deleting', 'failed', 'pending', 'synced', 'syncing'] as const, 'syncStatus'),
|
||||
syncStatus: enumValue(
|
||||
record.syncStatus,
|
||||
[
|
||||
'conflict',
|
||||
'deleting',
|
||||
'failed',
|
||||
'pending',
|
||||
'synced',
|
||||
'syncing',
|
||||
] as const,
|
||||
'syncStatus',
|
||||
),
|
||||
};
|
||||
if (protocol === 'tcp')
|
||||
return {
|
||||
...base,
|
||||
candidateEndpoint: optionalEndpoint(
|
||||
record.candidateEndpoint,
|
||||
'tcp_natmap',
|
||||
),
|
||||
currentEndpoint: optionalEndpoint(record.currentEndpoint, 'tcp_natmap'),
|
||||
dnatPresent: booleanValue(record.dnatPresent, 'dnatPresent'),
|
||||
instanceGeneration: optionalBounded(
|
||||
record.instanceGeneration,
|
||||
'instanceGeneration',
|
||||
128,
|
||||
),
|
||||
lastObservedEndpoint: optionalEndpoint(
|
||||
record.lastObservedEndpoint,
|
||||
'tcp_natmap',
|
||||
),
|
||||
natmapDesiredEnabled: booleanValue(
|
||||
record.natmapDesiredEnabled,
|
||||
'natmapDesiredEnabled',
|
||||
),
|
||||
natmapErrorCode: optionalErrorCode(
|
||||
record.natmapErrorCode,
|
||||
'natmapErrorCode',
|
||||
),
|
||||
natmapErrorMessage: optionalBounded(
|
||||
record.natmapErrorMessage,
|
||||
'natmapErrorMessage',
|
||||
512,
|
||||
),
|
||||
natmapStatus: enumValue(
|
||||
record.natmapStatus,
|
||||
[
|
||||
'active',
|
||||
'disabled',
|
||||
'failed',
|
||||
'stale',
|
||||
'starting',
|
||||
'stopping',
|
||||
] as const,
|
||||
'natmapStatus',
|
||||
),
|
||||
protocol,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
currentEndpoint: optionalEndpoint(record.currentEndpoint, 'udp_stun'),
|
||||
keeperDesiredEnabled: booleanValue(
|
||||
record.keeperDesiredEnabled,
|
||||
'keeperDesiredEnabled',
|
||||
),
|
||||
keeperErrorCode: optionalErrorCode(
|
||||
record.keeperErrorCode,
|
||||
'keeperErrorCode',
|
||||
),
|
||||
keeperErrorMessage: optionalBounded(
|
||||
record.keeperErrorMessage,
|
||||
'keeperErrorMessage',
|
||||
512,
|
||||
),
|
||||
keeperStatus: enumValue(
|
||||
record.keeperStatus,
|
||||
['active', 'disabled', 'failed', 'stale', 'starting'] as const,
|
||||
'keeperStatus',
|
||||
),
|
||||
lastObservedEndpoint: optionalEndpoint(
|
||||
record.lastObservedEndpoint,
|
||||
'udp_stun',
|
||||
),
|
||||
lastProbeRequestId:
|
||||
record.lastProbeRequestId === undefined
|
||||
? undefined
|
||||
: requestId(record.lastProbeRequestId, 'lastProbeRequestId'),
|
||||
protocol,
|
||||
routePresent: booleanValue(record.routePresent, 'routePresent'),
|
||||
};
|
||||
if (protocol === 'tcp') return { ...base, candidateEndpoint: optionalEndpoint(record.candidateEndpoint, 'tcp_natmap'), currentEndpoint: optionalEndpoint(record.currentEndpoint, 'tcp_natmap'), dnatPresent: booleanValue(record.dnatPresent, 'dnatPresent'), instanceGeneration: optionalBounded(record.instanceGeneration, 'instanceGeneration', 128), lastObservedEndpoint: optionalEndpoint(record.lastObservedEndpoint, 'tcp_natmap'), natmapDesiredEnabled: booleanValue(record.natmapDesiredEnabled, 'natmapDesiredEnabled'), natmapErrorCode: optionalErrorCode(record.natmapErrorCode, 'natmapErrorCode'), natmapErrorMessage: optionalBounded(record.natmapErrorMessage, 'natmapErrorMessage', 512), natmapStatus: enumValue(record.natmapStatus, ['active', 'disabled', 'failed', 'stale', 'starting', 'stopping'] as const, 'natmapStatus'), protocol };
|
||||
return { ...base, currentEndpoint: optionalEndpoint(record.currentEndpoint, 'udp_stun'), keeperDesiredEnabled: booleanValue(record.keeperDesiredEnabled, 'keeperDesiredEnabled'), keeperErrorCode: optionalErrorCode(record.keeperErrorCode, 'keeperErrorCode'), keeperErrorMessage: optionalBounded(record.keeperErrorMessage, 'keeperErrorMessage', 512), keeperStatus: enumValue(record.keeperStatus, ['active', 'disabled', 'failed', 'stale', 'starting'] as const, 'keeperStatus'), lastObservedEndpoint: optionalEndpoint(record.lastObservedEndpoint, 'udp_stun'), lastProbeRequestId: record.lastProbeRequestId === undefined ? undefined : requestId(record.lastProbeRequestId, 'lastProbeRequestId'), protocol, routePresent: booleanValue(record.routePresent, 'routePresent') };
|
||||
}
|
||||
|
||||
function desiredBase(record: Record<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 {
|
||||
const record = exactRecord(value, ['mechanism', 'observedAt', 'publicIpv4', 'publicPort', 'validatedAt', 'validUntil'], [], 'endpoint');
|
||||
const mechanism = enumValue(record.mechanism, ['tcp_natmap', 'udp_stun'] as const, 'mechanism');
|
||||
function parseEndpointV2(
|
||||
value: unknown,
|
||||
expectedMechanism: NetworkV2EndpointMechanism,
|
||||
): NetworkEndpointLeaseV2 {
|
||||
const record = exactRecord(
|
||||
value,
|
||||
[
|
||||
'mechanism',
|
||||
'observedAt',
|
||||
'publicIpv4',
|
||||
'publicPort',
|
||||
'validatedAt',
|
||||
'validUntil',
|
||||
],
|
||||
[],
|
||||
'endpoint',
|
||||
);
|
||||
const mechanism = enumValue(
|
||||
record.mechanism,
|
||||
['tcp_natmap', 'udp_stun'] as const,
|
||||
'mechanism',
|
||||
);
|
||||
if (mechanism !== expectedMechanism) invalid('endpoint mechanism');
|
||||
const observedAt = isoString(record.observedAt, 'observedAt');
|
||||
const validatedAt = isoString(record.validatedAt, 'validatedAt');
|
||||
const validUntil = isoString(record.validUntil, 'validUntil');
|
||||
if (Date.parse(validUntil) <= Date.parse(observedAt)) invalid('endpoint validity');
|
||||
return { mechanism, observedAt, publicIpv4: publicIpv4(record.publicIpv4), publicPort: port(record.publicPort, 'publicPort'), validatedAt, validUntil };
|
||||
if (compareNetworkV2Timestamps(validUntil, observedAt) <= 0)
|
||||
invalid('endpoint validity');
|
||||
return {
|
||||
mechanism,
|
||||
observedAt,
|
||||
publicIpv4: publicIpv4(record.publicIpv4),
|
||||
publicPort: port(record.publicPort, 'publicPort'),
|
||||
validatedAt,
|
||||
validUntil,
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalDesiredChannelV2(channel: NetworkDesiredChannelV2): object {
|
||||
if (channel.protocol === 'tcp') return { channelId: channel.channelId, desiredPresence: channel.desiredPresence, externalPort: channel.externalPort, groupId: channel.groupId, internalPort: channel.internalPort, name: channel.name, natmapDesiredEnabled: channel.natmapDesiredEnabled, protocol: channel.protocol };
|
||||
return { channelId: channel.channelId, desiredPresence: channel.desiredPresence, externalPort: channel.externalPort, groupId: channel.groupId, internalPort: channel.internalPort, keeperDesiredEnabled: channel.keeperDesiredEnabled, name: channel.name, ...(channel.probeRequestId === undefined ? {} : { probeRequestId: channel.probeRequestId }), protocol: channel.protocol };
|
||||
if (channel.protocol === 'tcp')
|
||||
return {
|
||||
channelId: channel.channelId,
|
||||
desiredPresence: channel.desiredPresence,
|
||||
externalPort: channel.externalPort,
|
||||
groupId: channel.groupId,
|
||||
internalPort: channel.internalPort,
|
||||
name: channel.name,
|
||||
natmapDesiredEnabled: channel.natmapDesiredEnabled,
|
||||
protocol: channel.protocol,
|
||||
};
|
||||
return {
|
||||
channelId: channel.channelId,
|
||||
desiredPresence: channel.desiredPresence,
|
||||
externalPort: channel.externalPort,
|
||||
groupId: channel.groupId,
|
||||
internalPort: channel.internalPort,
|
||||
keeperDesiredEnabled: channel.keeperDesiredEnabled,
|
||||
name: channel.name,
|
||||
...(channel.probeRequestId === undefined
|
||||
? {}
|
||||
: { probeRequestId: channel.probeRequestId }),
|
||||
protocol: channel.protocol,
|
||||
};
|
||||
}
|
||||
|
||||
function compareDesiredChannelsV2(left: NetworkDesiredChannelV2, right: NetworkDesiredChannelV2): number { return (left.channelId < right.channelId ? -1 : left.channelId > right.channelId ? 1 : 0) || (left.protocol < right.protocol ? -1 : left.protocol > right.protocol ? 1 : 0) || left.externalPort - right.externalPort; }
|
||||
function parseWirePayloadV2(payload: NetworkV2WirePayload): unknown { let text: string; if (typeof payload === 'string') { if (Buffer.byteLength(payload, 'utf8') > NETWORK_AGENT_V2_MAX_MESSAGE_BYTES) invalid('message size'); text = payload; } else if (Buffer.isBuffer(payload)) { if (payload.length > NETWORK_AGENT_V2_MAX_MESSAGE_BYTES) invalid('message size'); try { text = new TextDecoder('utf-8', { fatal: true }).decode(payload); } catch { invalid('message UTF-8'); } } else { invalid('message payload'); } try { return JSON.parse(text); } catch { invalid('message JSON'); } }
|
||||
function optionalEndpoint(value: unknown, mechanism: NetworkV2EndpointMechanism): NetworkEndpointLeaseV2 | undefined { return value === undefined ? undefined : parseEndpointV2(value, mechanism); }
|
||||
function optionalBounded(value: unknown, name: string, max: number): string | undefined { return value === undefined ? undefined : boundedString(value, name, max); }
|
||||
function optionalErrorCode(value: unknown, name: string): string | undefined { return value === undefined ? undefined : errorCode(value, name); }
|
||||
function protocolOf(value: unknown, name: string): NetworkV2Protocol { if (typeof value !== 'object' || value === null || Array.isArray(value)) invalid(name); return enumValue((value as Record<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 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}`); }
|
||||
function compareDesiredChannelsV2(
|
||||
left: NetworkDesiredChannelV2,
|
||||
right: NetworkDesiredChannelV2,
|
||||
): number {
|
||||
return (
|
||||
(left.channelId < right.channelId
|
||||
? -1
|
||||
: left.channelId > right.channelId
|
||||
? 1
|
||||
: 0) ||
|
||||
(left.protocol < right.protocol
|
||||
? -1
|
||||
: left.protocol > right.protocol
|
||||
? 1
|
||||
: 0) ||
|
||||
left.externalPort - right.externalPort
|
||||
);
|
||||
}
|
||||
function parseWirePayloadV2(payload: NetworkV2WirePayload): unknown {
|
||||
let text: string;
|
||||
if (typeof payload === 'string') {
|
||||
if (Buffer.byteLength(payload, 'utf8') > NETWORK_AGENT_V2_MAX_MESSAGE_BYTES)
|
||||
invalid('message size');
|
||||
text = payload;
|
||||
} else if (Buffer.isBuffer(payload)) {
|
||||
if (payload.length > NETWORK_AGENT_V2_MAX_MESSAGE_BYTES)
|
||||
invalid('message size');
|
||||
try {
|
||||
text = new TextDecoder('utf-8', { fatal: true }).decode(payload);
|
||||
} catch {
|
||||
invalid('message UTF-8');
|
||||
}
|
||||
} else {
|
||||
invalid('message payload');
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
invalid('message JSON');
|
||||
}
|
||||
}
|
||||
function optionalEndpoint(
|
||||
value: unknown,
|
||||
mechanism: NetworkV2EndpointMechanism,
|
||||
): NetworkEndpointLeaseV2 | undefined {
|
||||
return value === undefined ? undefined : parseEndpointV2(value, mechanism);
|
||||
}
|
||||
function optionalBounded(
|
||||
value: unknown,
|
||||
name: string,
|
||||
max: number,
|
||||
): string | undefined {
|
||||
return value === undefined ? undefined : boundedString(value, name, max);
|
||||
}
|
||||
function optionalErrorCode(value: unknown, name: string): string | undefined {
|
||||
return value === undefined ? undefined : errorCode(value, name);
|
||||
}
|
||||
function protocolOf(value: unknown, name: string): NetworkV2Protocol {
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
||||
invalid(name);
|
||||
return enumValue(
|
||||
(value as Record<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}`);
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@ import {
|
||||
KtDateTime,
|
||||
KtDateTimeColumn,
|
||||
} from '@/common';
|
||||
import type { EndpointEventType } from './network-management.types';
|
||||
import type {
|
||||
EndpointEventType,
|
||||
EndpointMechanism,
|
||||
} from './network-management.types';
|
||||
|
||||
@Entity('network_endpoint_history')
|
||||
@Index('uk_network_endpoint_history_event_id', ['eventId'], { unique: true })
|
||||
@ -24,7 +27,18 @@ export class NetworkEndpointHistory {
|
||||
eventType: EndpointEventType;
|
||||
|
||||
@Column({ default: 'udp_stun', length: 16, name: 'mechanism' })
|
||||
mechanism: string;
|
||||
mechanism: EndpointMechanism;
|
||||
|
||||
@Column({ name: 'source_revision', nullable: true, type: 'bigint' })
|
||||
sourceRevision?: string | null;
|
||||
|
||||
@Column({
|
||||
length: 64,
|
||||
name: 'endpoint_identity',
|
||||
nullable: true,
|
||||
type: 'char',
|
||||
})
|
||||
endpointIdentity?: string | null;
|
||||
|
||||
@Column({ length: 15, name: 'public_ipv4', nullable: true })
|
||||
publicIpv4?: string | null;
|
||||
@ -32,6 +46,22 @@ export class NetworkEndpointHistory {
|
||||
@Column({ name: 'public_port', nullable: true, type: 'int' })
|
||||
publicPort?: number | null;
|
||||
|
||||
@KtDateTimeColumn({
|
||||
name: 'endpoint_validated_at',
|
||||
nullable: true,
|
||||
precision: 6,
|
||||
type: 'datetime',
|
||||
})
|
||||
endpointValidatedAt?: KtDateTime | null;
|
||||
|
||||
@KtDateTimeColumn({
|
||||
name: 'endpoint_valid_until',
|
||||
nullable: true,
|
||||
precision: 6,
|
||||
type: 'datetime',
|
||||
})
|
||||
endpointValidUntil?: KtDateTime | null;
|
||||
|
||||
@KtDateTimeColumn({ name: 'first_observed_at', type: 'datetime' })
|
||||
firstObservedAt: KtDateTime;
|
||||
|
||||
|
||||
@ -15,9 +15,11 @@ import type {
|
||||
|
||||
@Entity('network_port_forward')
|
||||
@Index('uk_network_port_forward_active_key', ['activeKey'], { unique: true })
|
||||
@Index('uk_network_port_forward_active_group_protocol_key', [
|
||||
'activeGroupProtocolKey',
|
||||
], { unique: true })
|
||||
@Index(
|
||||
'uk_network_port_forward_active_group_protocol_key',
|
||||
['activeGroupProtocolKey'],
|
||||
{ unique: true },
|
||||
)
|
||||
@Index('idx_network_port_forward_group', ['groupId', 'isDeleted', 'protocol'])
|
||||
export class NetworkPortForward {
|
||||
@PrimaryColumn({ type: 'bigint' })
|
||||
@ -83,6 +85,17 @@ export class NetworkPortForward {
|
||||
@Column({ default: '0', name: 'reported_revision', type: 'bigint' })
|
||||
reportedRevision: string;
|
||||
|
||||
@KtDateTimeColumn({
|
||||
name: 'last_reported_at',
|
||||
nullable: true,
|
||||
precision: 6,
|
||||
type: 'datetime',
|
||||
})
|
||||
lastReportedAt?: KtDateTime | null;
|
||||
|
||||
@Column({ length: 64, name: 'last_reported_at_wire', nullable: true })
|
||||
lastReportedAtWire?: string | null;
|
||||
|
||||
@Column({ default: 'pending', length: 16, name: 'sync_status' })
|
||||
syncStatus: PortForwardSyncStatus;
|
||||
|
||||
@ -113,6 +126,13 @@ export class NetworkPortForward {
|
||||
})
|
||||
currentValidatedAt?: KtDateTime | null;
|
||||
|
||||
@Column({
|
||||
length: 64,
|
||||
name: 'current_validated_at_wire',
|
||||
nullable: true,
|
||||
})
|
||||
currentValidatedAtWire?: string | null;
|
||||
|
||||
@KtDateTimeColumn({
|
||||
name: 'current_valid_until',
|
||||
nullable: true,
|
||||
@ -120,6 +140,14 @@ export class NetworkPortForward {
|
||||
})
|
||||
currentValidUntil?: KtDateTime | null;
|
||||
|
||||
@Column({
|
||||
length: 64,
|
||||
name: 'current_endpoint_identity',
|
||||
nullable: true,
|
||||
type: 'char',
|
||||
})
|
||||
currentEndpointIdentity?: string | null;
|
||||
|
||||
@Column({ length: 15, name: 'last_observed_ipv4', nullable: true })
|
||||
lastObservedIpv4?: string | null;
|
||||
|
||||
@ -141,6 +169,13 @@ export class NetworkPortForward {
|
||||
})
|
||||
lastObservedValidatedAt?: KtDateTime | null;
|
||||
|
||||
@Column({
|
||||
length: 64,
|
||||
name: 'last_observed_validated_at_wire',
|
||||
nullable: true,
|
||||
})
|
||||
lastObservedValidatedAtWire?: string | null;
|
||||
|
||||
@Column({ length: 15, name: 'candidate_public_ipv4', nullable: true })
|
||||
candidatePublicIpv4?: string | null;
|
||||
|
||||
@ -163,6 +198,13 @@ export class NetworkPortForward {
|
||||
})
|
||||
candidateValidatedAt?: KtDateTime | null;
|
||||
|
||||
@Column({
|
||||
length: 64,
|
||||
name: 'candidate_validated_at_wire',
|
||||
nullable: true,
|
||||
})
|
||||
candidateValidatedAtWire?: string | null;
|
||||
|
||||
@Column({ length: 15, name: 'last_published_public_ipv4', nullable: true })
|
||||
lastPublishedPublicIpv4?: string | null;
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ export type EndpointEventType =
|
||||
| 'published'
|
||||
| 'restored'
|
||||
| 'withdrawn';
|
||||
export type EndpointMechanism = 'tcp_natmap' | 'udp_stun';
|
||||
export type NetworkStateChangeSource =
|
||||
| 'ddns'
|
||||
| 'events'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,6 +5,8 @@ import {
|
||||
buildDesiredSnapshotV2,
|
||||
canonicalDesiredChannelDigestV2,
|
||||
canonicalDesiredSnapshotDigestV2,
|
||||
compareNetworkV2Timestamps,
|
||||
endpointLeaseIdentityV2,
|
||||
parseDesiredSnapshotV2,
|
||||
parseEndpointEventV2,
|
||||
parseReportedSnapshotV2,
|
||||
@ -36,6 +38,39 @@ const eventValue = () => ({
|
||||
});
|
||||
|
||||
describe('network agent MQTT v2 contract', () => {
|
||||
it('compares RFC3339Nano instants without millisecond truncation', () => {
|
||||
expect(
|
||||
compareNetworkV2Timestamps(
|
||||
'2026-07-27T00:00:00.000900Z',
|
||||
'2026-07-27T00:00:00.000100Z',
|
||||
),
|
||||
).toBeGreaterThan(0);
|
||||
expect(
|
||||
compareNetworkV2Timestamps(
|
||||
'2026-07-27T08:00:00.123456789+08:00',
|
||||
'2026-07-27T00:00:00.123456789Z',
|
||||
),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps nanosecond-distinct endpoint leases as distinct identities', () => {
|
||||
const endpoint = {
|
||||
mechanism: 'udp_stun' as const,
|
||||
observedAt: '2026-07-27T00:00:00.000100Z',
|
||||
publicIpv4: '8.8.8.8',
|
||||
publicPort: 8213,
|
||||
validatedAt: '2026-07-27T00:00:01.000100Z',
|
||||
validUntil: '2026-07-27T00:01:00.000100Z',
|
||||
};
|
||||
|
||||
expect(endpointLeaseIdentityV2(endpoint)).not.toBe(
|
||||
endpointLeaseIdentityV2({
|
||||
...endpoint,
|
||||
validatedAt: '2026-07-27T00:00:01.000900Z',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('parses the shared desired fixture and validates both literal digests', () => {
|
||||
const desired = parseDesiredSnapshotV2(
|
||||
rawFixture('network-v2-desired.json'),
|
||||
@ -55,8 +90,14 @@ describe('network agent MQTT v2 contract', () => {
|
||||
['unknown field', (value: any) => (value.extra = true)],
|
||||
['schema/body mismatch', (value: any) => (value.schemaVersion = 1)],
|
||||
['numeric ID drift', (value: any) => (value.channels[0].channelId = 2)],
|
||||
['UDP NATMap pair', (value: any) => (value.channels[0].natmapDesiredEnabled = true)],
|
||||
['TCP Keeper pair', (value: any) => (value.channels[1].keeperDesiredEnabled = true)],
|
||||
[
|
||||
'UDP NATMap pair',
|
||||
(value: any) => (value.channels[0].natmapDesiredEnabled = true),
|
||||
],
|
||||
[
|
||||
'TCP Keeper pair',
|
||||
(value: any) => (value.channels[1].keeperDesiredEnabled = true),
|
||||
],
|
||||
])('rejects %s before v2 messages reach transport', (_, patch) => {
|
||||
const value = fixture('network-v2-desired.json') as any;
|
||||
patch(value);
|
||||
@ -79,31 +120,45 @@ describe('network agent MQTT v2 contract', () => {
|
||||
});
|
||||
|
||||
it('accepts v2 status and matching endpoint events before transport routing', () => {
|
||||
expect(
|
||||
parseStatusSnapshotV2(JSON.stringify(statusValue())),
|
||||
).toMatchObject({ online: true });
|
||||
expect(
|
||||
parseEndpointEventV2(JSON.stringify(eventValue())),
|
||||
).toMatchObject({ type: 'withdrawn' });
|
||||
expect(parseStatusSnapshotV2(JSON.stringify(statusValue()))).toMatchObject({
|
||||
online: true,
|
||||
});
|
||||
expect(parseEndpointEventV2(JSON.stringify(eventValue()))).toMatchObject({
|
||||
type: 'withdrawn',
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['desired', parseDesiredSnapshotV2, () => rawFixture('network-v2-desired.json')],
|
||||
['reported', parseReportedSnapshotV2, () => rawFixture('network-v2-reported.json')],
|
||||
[
|
||||
'desired',
|
||||
parseDesiredSnapshotV2,
|
||||
() => rawFixture('network-v2-desired.json'),
|
||||
],
|
||||
[
|
||||
'reported',
|
||||
parseReportedSnapshotV2,
|
||||
() => rawFixture('network-v2-reported.json'),
|
||||
],
|
||||
['status', parseStatusSnapshotV2, () => JSON.stringify(statusValue())],
|
||||
['event', parseEndpointEventV2, () => JSON.stringify(eventValue())],
|
||||
])('enforces raw size and trailing JSON for %s', (_, parser, validPayload) => {
|
||||
])(
|
||||
'enforces raw size and trailing JSON for %s',
|
||||
(_, parser, validPayload) => {
|
||||
expect(parser(validPayload())).toBeDefined();
|
||||
expect(() => parser(`${validPayload()}{}`)).toThrow();
|
||||
expect(() =>
|
||||
parser(Buffer.alloc(NETWORK_AGENT_V2_MAX_MESSAGE_BYTES + 1)),
|
||||
).toThrow();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it('rejects invalid RFC3339 calendar dates and explicit null optionals', () => {
|
||||
expect(() =>
|
||||
parseStatusSnapshotV2(
|
||||
JSON.stringify({ ...statusValue(), observedAt: '2026-02-29T00:00:00Z' }),
|
||||
JSON.stringify({
|
||||
...statusValue(),
|
||||
observedAt: '2026-02-29T00:00:00Z',
|
||||
}),
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
@ -112,9 +167,7 @@ describe('network agent MQTT v2 contract', () => {
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
parseEndpointEventV2(
|
||||
JSON.stringify({ ...eventValue(), endpoint: null }),
|
||||
),
|
||||
parseEndpointEventV2(JSON.stringify({ ...eventValue(), endpoint: null })),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@ -163,12 +216,15 @@ describe('network agent MQTT v2 contract', () => {
|
||||
const desiredWithName = (name: string) => {
|
||||
const value = fixture('network-v2-desired.json');
|
||||
value.channels[0].name = name;
|
||||
value.channels[0].channelDesiredDigest =
|
||||
canonicalDesiredChannelDigestV2(value.channels[0]);
|
||||
value.channels[0].channelDesiredDigest = canonicalDesiredChannelDigestV2(
|
||||
value.channels[0],
|
||||
);
|
||||
value.snapshotDigest = canonicalDesiredSnapshotDigestV2(value);
|
||||
return JSON.stringify(value);
|
||||
};
|
||||
expect(parseDesiredSnapshotV2(desiredWithName('界'.repeat(42)))).toBeDefined();
|
||||
expect(
|
||||
parseDesiredSnapshotV2(desiredWithName('界'.repeat(42))),
|
||||
).toBeDefined();
|
||||
expect(() =>
|
||||
parseDesiredSnapshotV2(desiredWithName('界'.repeat(43))),
|
||||
).toThrow();
|
||||
@ -216,9 +272,14 @@ describe('network agent MQTT v2 contract', () => {
|
||||
const desired = buildDesiredSnapshotV2(state, channels);
|
||||
expect(desired).toMatchObject({ schemaVersion: 2, snapshotRevision: 7 });
|
||||
expect(desired.channels[0].channelDesiredDigest).toMatch(/^[0-9a-f]{64}$/);
|
||||
expect(desired.snapshotDigest).toBe(canonicalDesiredSnapshotDigestV2(desired));
|
||||
expect(desired.snapshotDigest).toBe(
|
||||
canonicalDesiredSnapshotDigestV2(desired),
|
||||
);
|
||||
expect(() =>
|
||||
buildDesiredSnapshotV2({ ...state, desiredRevision: '9007199254740992' }, channels),
|
||||
buildDesiredSnapshotV2(
|
||||
{ ...state, desiredRevision: '9007199254740992' },
|
||||
channels,
|
||||
),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
@ -241,8 +302,14 @@ describe('network agent MQTT v2 contract', () => {
|
||||
protocol: 'tcp' as const,
|
||||
});
|
||||
|
||||
const desired = buildDesiredSnapshotV2(state, [channel('20'), channel('10')]);
|
||||
expect(desired.channels.map((item) => item.channelId)).toEqual(['10', '20']);
|
||||
const desired = buildDesiredSnapshotV2(state, [
|
||||
channel('20'),
|
||||
channel('10'),
|
||||
]);
|
||||
expect(desired.channels.map((item) => item.channelId)).toEqual([
|
||||
'10',
|
||||
'20',
|
||||
]);
|
||||
expect(parseDesiredSnapshotV2(JSON.stringify(desired))).toEqual(desired);
|
||||
});
|
||||
|
||||
@ -265,7 +332,9 @@ describe('network agent MQTT v2 contract', () => {
|
||||
protocol: 'tcp' as const,
|
||||
};
|
||||
|
||||
expect(() => buildDesiredSnapshotV2(state, [channel, { ...channel }])).toThrow();
|
||||
expect(() =>
|
||||
buildDesiredSnapshotV2(state, [channel, { ...channel }]),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
buildDesiredSnapshotV2(state, [{ ...channel, externalPort: 0 }]),
|
||||
).toThrow();
|
||||
|
||||
@ -50,7 +50,10 @@ describe('network management persistence module', () => {
|
||||
(column) => column.target === NetworkEndpointHistory,
|
||||
);
|
||||
const featureProviders = (
|
||||
Reflect.getMetadata(MODULE_METADATA.IMPORTS, AdminPlatformConfigModule) as {
|
||||
Reflect.getMetadata(
|
||||
MODULE_METADATA.IMPORTS,
|
||||
AdminPlatformConfigModule,
|
||||
) as {
|
||||
module?: unknown;
|
||||
providers?: { provide?: unknown }[];
|
||||
}[]
|
||||
@ -67,46 +70,130 @@ describe('network management persistence module', () => {
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
groupColumns.find((column) => column.propertyName === 'createTime')
|
||||
?.mode,
|
||||
groupColumns.find((column) => column.propertyName === 'createTime')?.mode,
|
||||
).toBe('createDate');
|
||||
expect(
|
||||
groupColumns.find((column) => column.propertyName === 'updateTime')
|
||||
?.mode,
|
||||
groupColumns.find((column) => column.propertyName === 'updateTime')?.mode,
|
||||
).toBe('updateDate');
|
||||
expect(channelColumns.map((column) => column.options.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'active_group_protocol_key',
|
||||
'candidate_observed_at',
|
||||
'candidate_validated_at',
|
||||
'candidate_validated_at_wire',
|
||||
'current_endpoint_identity',
|
||||
'current_validated_at',
|
||||
'current_validated_at_wire',
|
||||
'group_id',
|
||||
'last_observed_validated_at',
|
||||
'last_observed_validated_at_wire',
|
||||
'last_published_at',
|
||||
'last_reported_at',
|
||||
'last_reported_at_wire',
|
||||
'natmap_desired_enabled',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
channelColumns.find((column) => column.propertyName === 'groupId')?.options
|
||||
.type,
|
||||
channelColumns.find((column) => column.propertyName === 'groupId')
|
||||
?.options.type,
|
||||
).toBe('bigint');
|
||||
expect(
|
||||
channelColumns.find(
|
||||
(column) => column.propertyName === 'candidateObservedAt',
|
||||
)?.options.type,
|
||||
).toBe('datetime');
|
||||
expect(
|
||||
channelColumns.find((column) => column.propertyName === 'lastReportedAt')
|
||||
?.options,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'last_reported_at',
|
||||
nullable: true,
|
||||
precision: 6,
|
||||
type: 'datetime',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
channelColumns.find(
|
||||
(column) => column.propertyName === 'lastReportedAtWire',
|
||||
)?.options,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
length: 64,
|
||||
name: 'last_reported_at_wire',
|
||||
nullable: true,
|
||||
}),
|
||||
);
|
||||
expect(agentColumns.map((column) => column.options.name)).toEqual(
|
||||
expect.arrayContaining([
|
||||
'applied_schema_version',
|
||||
'desired_schema_version',
|
||||
'published_schema_version',
|
||||
'max_supported_schema_version',
|
||||
'tcp_natmap_capable',
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
agentColumns.find((column) => column.propertyName === 'version')?.options
|
||||
.length,
|
||||
).toBe(128);
|
||||
for (const propertyName of [
|
||||
'lastMqttErrorMessage',
|
||||
'lastReconcileErrorMessage',
|
||||
]) {
|
||||
expect(
|
||||
agentColumns.find((column) => column.propertyName === propertyName)
|
||||
?.options.length,
|
||||
).toBe(512);
|
||||
}
|
||||
expect(
|
||||
historyColumns.find((column) => column.propertyName === 'mechanism')
|
||||
?.options.name,
|
||||
).toBe('mechanism');
|
||||
expect(
|
||||
historyColumns.find((column) => column.propertyName === 'sourceRevision')
|
||||
?.options,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
name: 'source_revision',
|
||||
nullable: true,
|
||||
type: 'bigint',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
historyColumns.find(
|
||||
(column) => column.propertyName === 'endpointIdentity',
|
||||
)?.options,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
length: 64,
|
||||
name: 'endpoint_identity',
|
||||
nullable: true,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
Reflect.getMetadata(
|
||||
'design:type',
|
||||
NetworkEndpointHistory.prototype,
|
||||
'sourceRevision',
|
||||
),
|
||||
).toBe(String);
|
||||
for (const [propertyName, columnName] of [
|
||||
['endpointValidatedAt', 'endpoint_validated_at'],
|
||||
['endpointValidUntil', 'endpoint_valid_until'],
|
||||
]) {
|
||||
expect(
|
||||
historyColumns.find((column) => column.propertyName === propertyName)
|
||||
?.options,
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
name: columnName,
|
||||
nullable: true,
|
||||
precision: 6,
|
||||
type: 'datetime',
|
||||
}),
|
||||
);
|
||||
}
|
||||
expect(featureProviders.map((provider) => provider.provide)).toContain(
|
||||
getRepositoryToken(NetworkPortForwardGroup),
|
||||
);
|
||||
@ -119,8 +206,7 @@ describe('network management persistence module', () => {
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
storage.relations.filter(
|
||||
(relation) =>
|
||||
storage.relations.filter((relation) =>
|
||||
[NetworkPortForward, NetworkPortForwardGroup].includes(
|
||||
relation.target as never,
|
||||
),
|
||||
|
||||
@ -68,10 +68,7 @@ type FixtureRow = Record<string, null | string>;
|
||||
|
||||
function parseFixtureRows(sql: string): Record<string, FixtureRow> {
|
||||
const rows: Record<string, FixtureRow> = {};
|
||||
const normalized = sql
|
||||
.replace(/--.*$/gm, '')
|
||||
.replace(/`/g, '')
|
||||
.trim();
|
||||
const normalized = sql.replace(/--.*$/gm, '').replace(/`/g, '').trim();
|
||||
const inserts = normalized.matchAll(
|
||||
/insert into ([a-z0-9_]+)\s*\(([\s\S]*?)\)\s*values\s*\(([\s\S]*?)\);/gi,
|
||||
);
|
||||
@ -152,11 +149,17 @@ function expectFinalSchema(sql: string): void {
|
||||
'candidate_public_port int null',
|
||||
'candidate_observed_at datetime(6) null',
|
||||
'candidate_validated_at datetime(6) null',
|
||||
'candidate_validated_at_wire varchar(64) null',
|
||||
'current_endpoint_identity char(64) null',
|
||||
'current_validated_at datetime(6) null',
|
||||
'current_validated_at_wire varchar(64) null',
|
||||
'last_observed_validated_at datetime(6) null',
|
||||
'last_observed_validated_at_wire varchar(64) null',
|
||||
'last_published_public_ipv4 varchar(15) null',
|
||||
'last_published_public_port int null',
|
||||
'last_published_at datetime(6) null',
|
||||
'last_reported_at datetime(6) null',
|
||||
'last_reported_at_wire varchar(64) null',
|
||||
]) {
|
||||
expect(channel).toContain(column);
|
||||
}
|
||||
@ -171,11 +174,25 @@ function expectFinalSchema(sql: string): void {
|
||||
'desired_schema_version int unsigned not null default 1',
|
||||
'published_schema_version int unsigned not null default 1',
|
||||
'max_supported_schema_version int unsigned not null default 1',
|
||||
'applied_schema_version int unsigned not null default 1',
|
||||
'tcp_natmap_capable tinyint(1) not null default 0',
|
||||
'version varchar(128) null',
|
||||
'last_mqtt_error_message varchar(512) null',
|
||||
'last_reconcile_error_message varchar(512) null',
|
||||
]) {
|
||||
expect(agent).toContain(column);
|
||||
}
|
||||
expect(history).toContain("mechanism varchar(16) not null default 'udp_stun'");
|
||||
expect(history).toContain(
|
||||
"mechanism varchar(16) not null default 'udp_stun'",
|
||||
);
|
||||
expect(history).toContain('endpoint_identity char(64) null');
|
||||
for (const column of [
|
||||
'source_revision bigint null',
|
||||
'endpoint_validated_at datetime(6) null',
|
||||
'endpoint_valid_until datetime(6) null',
|
||||
]) {
|
||||
expect(history).toContain(column);
|
||||
}
|
||||
}
|
||||
|
||||
describe('TCP NATMap v2 schema SQL', () => {
|
||||
@ -199,13 +216,28 @@ describe('TCP NATMap v2 schema SQL', () => {
|
||||
it('uses an additive idempotent migration with ordered group backfill', () => {
|
||||
const migration = readNormalizedSql('sql/network-tcp-natmap-v2.sql');
|
||||
|
||||
expect(migration).toContain('create table if not exists network_port_forward_group');
|
||||
expect(migration).toContain(
|
||||
'create table if not exists network_port_forward_group',
|
||||
);
|
||||
expect(migration).toContain('information_schema.columns');
|
||||
expect(migration).toContain('information_schema.statistics');
|
||||
expect(migration).toContain('insert into network_port_forward_group');
|
||||
expect(migration).toContain('select channel.id, channel.name, channel.remark');
|
||||
expect(migration).toContain("'udp'");
|
||||
expect(migration).toContain('update network_port_forward channel set group_id = channel.id');
|
||||
expect(migration).toContain(
|
||||
'select channel.id, channel.name, channel.remark',
|
||||
);
|
||||
expect(migration).toContain(
|
||||
'channel.internal_port, channel.protocol, channel.target_ipv4',
|
||||
);
|
||||
expect(migration).toContain(
|
||||
'update network_port_forward channel set group_id = channel.id',
|
||||
);
|
||||
expect(migration).toContain('modify column version varchar(128) null');
|
||||
expect(migration).toContain(
|
||||
'modify column last_mqtt_error_message varchar(512) null',
|
||||
);
|
||||
expect(migration).toContain(
|
||||
'modify column last_reconcile_error_message varchar(512) null',
|
||||
);
|
||||
expect(migration).toContain(
|
||||
"active_group_protocol_key = case when channel.is_deleted = 0 then concat(channel.group_id, ':', channel.protocol) else null end",
|
||||
);
|
||||
@ -221,15 +253,26 @@ describe('TCP NATMap v2 schema SQL', () => {
|
||||
'candidate_public_port',
|
||||
'candidate_observed_at',
|
||||
'candidate_validated_at',
|
||||
'candidate_validated_at_wire',
|
||||
'current_endpoint_identity',
|
||||
'current_validated_at',
|
||||
'current_validated_at_wire',
|
||||
'last_observed_validated_at',
|
||||
'last_observed_validated_at_wire',
|
||||
'last_published_public_ipv4',
|
||||
'last_published_public_port',
|
||||
'last_published_at',
|
||||
'last_reported_at',
|
||||
'last_reported_at_wire',
|
||||
'desired_schema_version',
|
||||
'published_schema_version',
|
||||
'max_supported_schema_version',
|
||||
'applied_schema_version',
|
||||
'tcp_natmap_capable',
|
||||
'source_revision',
|
||||
'endpoint_validated_at',
|
||||
'endpoint_valid_until',
|
||||
'endpoint_identity',
|
||||
]) {
|
||||
expect(migration).toContain(`column_name = '${columnName}'`);
|
||||
}
|
||||
@ -271,9 +314,31 @@ describe('TCP NATMap v2 schema SQL', () => {
|
||||
|
||||
expect(verify).toContain('from network_port_forward_group');
|
||||
expect(verify).toContain("column_name = 'group_id'");
|
||||
expect(verify).toContain('uk_network_port_forward_active_group_protocol_key');
|
||||
expect(verify).toContain(
|
||||
'uk_network_port_forward_active_group_protocol_key',
|
||||
);
|
||||
expect(verify).toContain('idx_network_port_forward_group');
|
||||
expect(verify).not.toMatch(/\b(insert|update|delete|alter|drop|truncate)\b/);
|
||||
expect(verify).toContain("column_name = 'last_reported_at'");
|
||||
expect(verify).toContain("column_name = 'last_reported_at_wire'");
|
||||
expect(verify).toContain("column_name = 'applied_schema_version'");
|
||||
expect(verify).toContain("column_name = 'version'");
|
||||
expect(verify).toContain("column_name = 'last_mqtt_error_message'");
|
||||
expect(verify).toContain("column_name = 'last_reconcile_error_message'");
|
||||
expect(verify).toContain("column_name = 'source_revision'");
|
||||
expect(verify).toContain("column_name = 'endpoint_validated_at'");
|
||||
expect(verify).toContain("column_name = 'endpoint_valid_until'");
|
||||
expect(verify).toContain("column_name = 'endpoint_identity'");
|
||||
expect(verify).not.toMatch(
|
||||
/\b(insert|update|delete|alter|drop|truncate)\b/,
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills legacy groups only for channels whose group ID is still null', () => {
|
||||
const migration = readNormalizedSql('sql/network-tcp-natmap-v2.sql');
|
||||
|
||||
expect(migration).toMatch(
|
||||
/from network_port_forward channel left join network_port_forward_group grouped on grouped\.id = channel\.id where channel\.group_id is null and grouped\.id is null/,
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves the sanitized existing 8213 UDP channel, history, and DDNS binding', () => {
|
||||
@ -294,13 +359,16 @@ describe('TCP NATMap v2 schema SQL', () => {
|
||||
};
|
||||
const afterHistory: FixtureRow = {
|
||||
...beforeHistory,
|
||||
endpoint_validated_at: null,
|
||||
endpoint_valid_until: null,
|
||||
mechanism: 'udp_stun',
|
||||
source_revision: null,
|
||||
};
|
||||
const afterDdns: FixtureRow = { ...beforeDdns };
|
||||
|
||||
expect(mutationAssignmentTargets(migration, 'network_port_forward')).toEqual(
|
||||
['active_group_protocol_key', 'group_id'],
|
||||
);
|
||||
expect(
|
||||
mutationAssignmentTargets(migration, 'network_port_forward'),
|
||||
).toEqual(['active_group_protocol_key', 'group_id']);
|
||||
expect(
|
||||
mutationAssignmentTargets(migration, 'network_endpoint_history'),
|
||||
).toEqual(['mechanism']);
|
||||
@ -316,9 +384,17 @@ describe('TCP NATMap v2 schema SQL', () => {
|
||||
'group_id',
|
||||
]),
|
||||
).toEqual(beforeChannel);
|
||||
expect(withoutProperties(afterHistory, ['mechanism'])).toEqual(
|
||||
beforeHistory,
|
||||
);
|
||||
expect(
|
||||
withoutProperties(afterHistory, [
|
||||
'endpoint_validated_at',
|
||||
'endpoint_valid_until',
|
||||
'mechanism',
|
||||
'source_revision',
|
||||
]),
|
||||
).toEqual(beforeHistory);
|
||||
expect(afterHistory.source_revision).toBeNull();
|
||||
expect(afterHistory.endpoint_validated_at).toBeNull();
|
||||
expect(afterHistory.endpoint_valid_until).toBeNull();
|
||||
expect(afterDdns).toEqual(beforeDdns);
|
||||
expect(afterChannel.id).toBe('2041600000000008213');
|
||||
expect(afterChannel.active_key).toBe('udp:8213');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user