# Main 转发通道接入方案 本文对应当前 `forward_server` 实现的接入契约。本文只描述 Main 的后续修改,不能视为 Main、Node 或 Web 已完成接入。各项目在自己的目录维护协议类型和校验,不跨目录导入生产协议源码。 ## 1. 已实现的转发服务接口 启动接口已升级为配置对象,不再支持旧的位置参数: ```ts const relay = await start_forward_server({ forward_id: 0, key: config.forward_secret_key, http_server, path: '/api/forward_server', namespace: '/', allowed_origins: config.forward_allowed_origins, allow_no_origin: true, control: authenticated_control, logger: console_print, }); ``` `forward_id` 为非负安全整数,内置服务为 `0`。`node_id/bot_id/user_id` 为正安全整数。密钥至少 16 字符,不允许已知默认值兜底。允许的 Origin 必须是准确的 HTTP(S) Origin,不能填写 `*` 或包含路径。Node 可以不发送 Origin,但仍必须完成 token 和会话鉴权。 借用 `http_server` 时不能再指定 `host/port`;自建服务使用 `host/port`。返回的 handle 包含 `io/http_server/store/forward_id/path/namespace/owns_http_server`、`bind_control(transport)` 和幂等的 `stop()`。Main 保留 handle,在进程退出时显式停止;转发服务的关闭不会关闭借用的主站 HTTP server。 关闭默认最多等待 5 秒,可用 `limits.close_timeout_ms` 调整;超时强制释放自有 TCP 连接并返回失败,仍复位实例状态。借用宿主的 TCP 连接不归 Forward 强制销毁。 重复相同配置启动返回同一实例;配置变化必须先停止再启动。控制重连通过 `bind_control()` 重新安装处理器,不能通过重复启动来隐式换控制连接。旧的 disposer 不影响新控制绑定。 控制 transport 只要求 `on/off/emit`,支持 Main 内置 EventEmitter、Main 服务端 Socket 及 Node 客户端 Socket。数据 Socket 无法调用控制命令。 ## 2. 控制通道 wire 契约 所有时间均为 Unix 毫秒,UUID 使用 `crypto.randomUUID()`。`node_session_id` 是当前已认证 Node 连接标识,允许非空、最大 256 UTF-8 字节字符串,不要求 UUID。Main 当前内部保存的 `socket.id` 可以作为该值,但必须在 Node 注册成功 ACK 中显式下发。 ```ts type ForwardAck = | {status: 'success'; data: T} | {status: 'error'; data: { code: ForwardErrorCode; message: string; retryable: boolean; }}; interface SessionScope { forward_id: number; forward_session_id: string; node_session_id: string; node_id: number; bot_id: number; } ``` 输入 DTO 为精确字段白名单,不能附加数据库实体、内部配置或其他字段。 | 事件 | 请求 | 成功 data | | --- | --- | --- | | `forward_open_session` | `SessionScope & {lease_expires_at:number}` | 实际 scope 与 `lease_expires_at` | | `forward_authorize_grant` | `{forward_session_id,grant_id,user_id,permissions,expires_at}` | 同结构的实际授权信息 | | `forward_revoke_session` | `{forward_session_id,reason}` | `{forward_session_id,revoked:true}` | | `forward_revoke_grant` | `{forward_session_id,grant_id,reason}` | `{forward_session_id,grant_id,revoked:true}` | 同一 session 的 scope 不可改变,同一 Node/Bot 不能同时登记不同 session;先撤销旧会话,再建立新会话。同一 grant 的用户和权限不可变,权限改变时撤销后分配新 UUID。续期使用原身份和权限,延长时间。撤销消息幂等,reason 为非空、最大 256 UTF-8 字节字符串。 Forward 在本进程中保留有界撤销记录,期限为最大 token 寿命加时钟偏差,防止迟到的 open/authorize 复活已撤销身份。未过期撤销记录计入 session/grant 容量,达到限制返回 RATE_LIMITED,不提前逐出撤销记录。Main 收到容量限制时应退避,不能紧循环分配新 UUID。 默认 session 租约上限为当前时间后 5 分钟;grant 到期不得超过 session。Main 应统一计算截止时间,给网络传输保留余量,不能在远端处理时已经过期。撤销的 UUID 不得重新使用。 `forward_session_status` 为 Forward 向 Main 的单向通知,字段为: ```ts interface ForwardSessionStatus extends SessionScope { node_ready: boolean; reason: string; state: 'open' | 'ready' | 'unavailable' | 'closed'; reported_at: number; } ``` Main 只接受当前转发控制连接的报告,再核对本地 session 的完整 scope。`closed` 立即使本地会话不可复用,`unavailable` 表示转发 Node 未就绪,不覆盖 Bot 游戏状态。通知没有 ACK;申请成功仍须依靠有截止时间的命令 ACK,不能依赖通知到达时序。 Socket.IO 控制请求使用带超时的单 Socket ACK。EventEmitter 适配器使用自己的定时器和一次性回调,不能调用 `.timeout()` 或 `.disconnect()`。禁止将旧的数字 ACK 解析器用于本协议。 ## 3. Token 签发契约 Main 在本项目的 token 工具中实现签发,格式保持 AES-256-GCM:`base64url(IV[12] + tag[16] + ciphertext)`,密钥为共享 secret 的 SHA-256。使用随机 IV,JSON 使用 UTF-8,不输出密钥或 token 到日志。 ```ts interface RelayClaims extends SessionScope { v: 1; role: 'node' | 'web'; user_id?: number; grant_id?: string; permissions: RelayMethod[]; issued_at: number; join_before: number; expires_at: number; jti: string; } ``` Node token 不带 `user_id/grant_id`;Web token 必须同时带这两个字段。`jti/forward_session_id/grant_id` 为 UUID。权限不重复,只能取 `bot.snapshot/bot.logs/bot.subscribe/bot.unsubscribe/bot.chat`。Web 权限不得超出 Main 已登记的 grant,Node 权限应覆盖它需要执行的 RPC。 事件读取权限为:`log` 需要 `bot.subscribe` 与 `bot.logs`;`status_update/message/bot_error` 需要 `bot.subscribe` 与 `bot.snapshot`。取消订阅需要 `bot.unsubscribe`。首版完整交互可授予上述五个方法,纯只读页面不能包含 `bot.chat`。订阅默认空,重连后 Web 必须重新设置。 默认 `issued_at < join_before <= expires_at`,token 寿命不超过 5 分钟,入房或 reauth 窗口不超过 60 秒,时钟偏差 5 秒。凭证文本最大 8192 字节。建议到期前 60 秒续期,重连超过入房窗口时需获取新 token,不能因为旧 token 尚未到 expires_at 就直接重入。 Main 与 Forward 使用相同合法/非法测试样例和无生产密钥的固定测试向量。不能只用各自加解密往返测试判断跨服务兼容。 固定向量位于 `tests/token.test.ts` 的首个测试,密钥 `relay-test-key-not-a-production-secret`、IV 十六进制 `000102030405060708090a0b`、验证时间 `1800000000000`,包含完整预期 token 文本和 claims。该固定 IV 仅用于测试;生产签发必须使用随机 IV。 ## 4. Main 文件修改安排 | 文件或模块 | 后续工作 | | --- | --- | | `index.ts` | 使用新 options,内置 ID 为 0,保留 handle,显式 stop;删除默认密钥兜底 | | `runtime/forward_server/socket.ts` | 健康注册表、当前控制连接 generation、disposer、控制请求适配器 | | `runtime/forward_server/event_handler/` | 接收并校验 `forward_session_status`,只接受当前连接 | | `router/node/main_socket.ts` | Node 注册成功 ACK 下发 `node_session_id` | | `runtime/node/socket.ts` | 提供当前 Node 会话查询、替换/移除撤销 hook | | `service/forward/` 或现有等效目录 | session/grant 存储、申请合并、授权、续期、撤销、定时回收 | | `router/node/action.ts` | 修正原缺少 `/` 的路由,接入申请/续期/释放服务 | | `types/`、`utils/` | 本地协议类型、严格 ACK 校验、token 签发、HTTP 错误映射 | | `database/forward/init.ts` 与配置 | endpoint 字段和安全回填、内置服务解析规则 | | 已有删除/迁移/退出/权限入口 | 调用 grant/session 撤销,不复制 Bot 生命周期 | `main_server/forward_server`、`node_server/forward_server` 均为 `../forward_server` 符号链接。Main 不再修改另一份 relay 副本,Node 入口调用点也必须同步升级新启动参数;本次没有替 Main/Node 修改这些入口。 ## 5. Main 内存模型 Session 以 `(forward_id,node_id,bot_id,node_session_id)` 查找,分配独立 `forward_session_id`,记录:当前 relay 注册 generation、Node Socket 身份、Bot config_revision/资源分配、租约、建立 Promise、等待者及 ready/closed 状态。 Grant 以随机 `grant_id` 查找,记录:session、user_id、HTTP `sessionID`、权限、期限和撤销状态。不同标签页独立 grant;同一 grant 在 Forward 只保留一个当前 Web Socket。 多个 HTTP 申请合并 session 建立任务,各自保持截止时间和取消状态。一个 HTTP 超时不能关闭其他等待者或有效 grant 仍使用的通道。没有成功返回凭证的申请不保留永久 grant,迟到 Node ACK 不产生 HTTP 成功。 转发服务重启会丢失授权。Main 必须先重新登记 session/grant,再下发新 token 让 Node/Web 恢复;Node 会话或资源分配变化则建立新 UUID,不能恢复旧 scope。Main 自身重启不假装恢复旧内存授权。 ## 6. 初次申请流程 接口:`POST /api/user/node/forward_bot`,body 精确为 `{bot_id}`。 1. 验证登录会话、活跃账号、正安全整数 Bot ID。 2. 使用 `query_bot_info(user_id,bot_id)` 确认归属,保存 Node/team/config_revision。 3. 复用资源授权逻辑确认团队、Node 与 Forward 权限。现有 `authorize_bot_resources(user,bot.node_id,0,bot.team_id)` 可作为提取入口,`proxy_id=0` 跳过与转发无关的代理检查。核对团队 allocation 和成员限额。 4. 获取当前可命令 Node Socket 及 node_session_id,不能仅查看缓存 ONLINE。 5. 获取当前健康 Forward 控制连接及部署 endpoint,捕获注册 generation。 6. 查找或合并 session 建立,控制请求 `forward_open_session` 成功后签发 Node token。 7. 通过当前 Node Socket 发送原计划的 `forward_bot` 命令,等待完整 ready ACK。 8. 再次核对当前 Node、Forward 控制连接、Bot 归属、资源分配及配置版本;变化则拒绝并清理本申请。 9. 创建独立 grant,经 `forward_authorize_grant` ACK 成功后签发 Web token。 10. 显式投影公开 DTO 返回,不返回实体、密钥、Node token 或 node_url。 不检查 Bot 游戏状态、Minecraft 版本、启动队列或代理可用性,不调用 `prepare_bot_resources/start_bot_context`,不把申请写入离线停止队列。 公开 DTO 保持原计划的 `{bot_id,node_id,forward_id,forward_session_id,grant_id,url,path,namespace,token,expires_at}`。url 使用 public_url;token 只能是 Web 角色。 ## 7. Node 接入前置条件 Main 的 `forward_bot` 成功 ACK 必须表示 Node 已完成:连接 Forward、`join_room` ACK、安装 RPC 与上下文观察、`forward_node_ready` ACK。新增数据事件: ```ts // Node -> Forward forward_node_ready({forward_session_id}, callback); // callback: ForwardAck<{node_ready:true}> ``` `join_room/reauth` 只收 `{token}`,成功 data 为完整 SessionScope 加 `role/node_ready/expires_at`。Node 入房后 initially node_ready=false,需要显式确认。 Forward 向 Node 定向发送的 `relay_request` 在原请求上增加 `correlation_id/forward_session_id/deadline_at`。Node 通过同一个 Socket.IO ACK 返回 `ForwardAck`;chat 成功 data 为 `{accepted:true}`,snapshot/logs 按 `forward_server/types.ts` 的 DTO 本地同步。subscribe/unsubscribe 由 Forward 本地处理。 无 BotContext 的 snapshot/status 必须为 `offline/context_exists:false/context_generation:null`;存在上下文时 generation 为 UUID。日志按 seq 递增、ID 唯一且不得超过读取/事件水位,响应数量不得超过请求 limit。所有 DTO 使用准确字段,serializer 不附带原始 Bot、配置或堆栈。 Node/Web 接收 `forward_session_closed {forward_session_id?,reason,code}` 后按原因停止旧授权重试。未入房连接被关闭时可能没有 session ID。`relay_status.reason === 'resync_required'` 要触发快照/日志重新同步。 ## 8. 续期与释放 `POST /api/user/node/forward_bot/renew`,body `{bot_id,forward_session_id,grant_id}`。验证账号、HTTP 会话、Bot 归属、grant 所有权和当前分配;先续 session,再对 Node ensure/reauth 成功,登记延长的 grant,最后签发 Web 新 token。建议返回相同 ForwardAccess 结构,前端调用 reauth。Node 会话/转发分配变化返回 SESSION_STALE,重新初次申请。 `POST /api/user/node/forward_bot/release`,body 相同,严格验证所有权,撤销本 grant 并调用 `forward_revoke_grant`,幂等返回成功。其他标签页 grant 不受影响。通过本地有界终态记录支持重复 release,不能让未知 grant 成为可操作他人授权的入口。 远端撤销失败时本地立即标记不可续期,记录有界重试直到当前健康控制通道恢复或租约到期。不能把“本地已撤销”描述成“远端已断开”。Forward 的 session/grant/token 到期和空闲回收提供兜底。 ## 9. 真实撤销入口 | 入口 | 接入方式 | | --- | --- | | `service/bot/delete.ts` 事务提交后 | 撤销该 Bot 的全部 session | | `service/bot/update.ts` 提交后 | Node/team/资源分配变化时撤销旧 session;普通不影响授权的配置不无条件踢线 | | `router/user/user_info.ts` logout | 按当前 HTTP sessionID 撤销对应 grants,不能只按 user_id 踢掉全部设备 | | `runtime/node/socket.ts` 替换或移除当前连接 | 按 expected node_session_id 撤销旧 session,旧 disconnect 不影响新会话 | | `service/user/team.ts` 成员移除/团队删除 | 提交后重新验证并撤销失效 grant/session | | 授权巡检 | 独立巡检 relay grants 的登录会话、活跃账号和资源权限,覆盖没有 Main Web Socket 的用户 | 现有 `service/user/socket_session.ts` 的巡检只遍历 Main 用户 Socket,不能直接认为它覆盖 relay-only 浏览器。当前未确认存在账号停用和 Node/Forward 重新分配的 HTTP 入口;后续真实添加时接同一个撤销服务,不创建从未调用的 hook 充当完成。 ## 10. HTTP 错误映射 沿用现有 `res.json({code:number,data})`,不整体改变 HTTP envelope。建议增加未占用的转发错误码并在 Main/Web 同步冻结: | 情况 | 建议 code | | --- | --- | | 成功 | 0 | | 未登录 | 498 | | 参数非法 | 484 | | Bot 不存在或不属于用户 | 485 | | 数据库错误 | 480 | | 权限不足 | 499 | | Node 离线 | 501 | | 超时 | 488 | | 内部错误 | 486 | | Relay 不可用 | 510(新增建议) | | SESSION_STALE | 511(新增建议) | | TOKEN_EXPIRED | 512(新增建议) | | CANCELLED | 513(新增建议) | | RATE_LIMITED | 487,结合原有 spam 语义确认 | 错误 data 建议投影为 `{error_code,message,retryable}`,前端不能解析任意服务内部错误。上述新增数字是 Main 的后续方案,尚未修改其码表。NO_CONTEXT、BOT_NOT_ONLINE、RESYNC_REQUIRED 是业务 RPC 语义,不能因 Bot 无上下文而阻止申请。 总预算沿用 Node 建立 8 秒、Main 等待 10 秒、HTTP 12 秒,所有阶段共享绝对 deadline,不能逐阶段叠加无限等待。Node 副作用操作 ACK 超时不可自动重发。 ## 11. 注册表与部署配置 注册表存储 origin、transport、generation、node_session_id、健康状态和 disposer。当前连接重复注册幂等,初始化完成才入健康表并 ACK。旧断开只清理自己拥有的记录。origin=1 借用 Node 控制 Socket,删除 Forward 登记不得断开整个 Node;origin=0 的 EventEmitter 不调用 disconnect。 Forward endpoint 模型增加 `node_url/public_url/path/namespace/allowed_origins/config_revision`,监听 host/port 与公布地址分开。当前数据库启用 synchronize 且未发现迁移目录,新增字段应先可空、回填、校验,再收紧,不能直接添加无默认的非空字段影响现有行。 内置 ID 0 当前只有 runtime 登记并不足够,资源授权仍会查 Forward 实体;需显式 seed/验证内置配置或统一 builtin resolver,并保留权限检查。HTTPS 页面使用 HTTPS 公开入口,代理同时支持 polling 与 WebSocket。 ## 12. 实施与验收顺序 1. 冻结本地 DTO、错误映射及 endpoint 配置;实现 token 测试向量。 2. 修改 Main 启动和健康注册表,Node 注册 ACK 暴露当前会话;接控制 ACK 适配器。 3. 实现 session/grant service,先用模拟 Node 验证并发、deadline、撤销和旧 ACK。 4. 接申请、续期、释放路由以及真实撤销入口;接 Node 新协议后执行真实 relay 联调。 5. 接 Web 并验证多标签页、无上下文、stop/start、凭证续期、服务重启及故障恢复。 验收至少覆盖:全部 Bot 状态和无上下文可申请;foreign Bot/无权限/离线 Node 拒绝;等待期间迁移或替换会话不返回旧凭证;并发标签页共享上行而 grant 独立;release/logout 只影响对应授权;公开响应无密钥/Node token/内部地址;转发重启先登记授权再恢复;Main/Node/relay 分别断线不互相覆盖状态。 当前 relay 的本地测试通过不能代替以上 Main/Node/Web 端到端验收。上线需同批升级协议,不允许新旧客户端混用。 ## 13. 当前交付验证 在 Node.js 22.23.2 下执行 `pnpm run typecheck` 通过,`pnpm test` 共 38 项全部通过。测试包含真实 Socket.IO polling/WebSocket、模拟 Node RPC、异步 adapter、凭证与撤销、限流背压、借用宿主关闭/重启及自有 HTTP 关闭超时;未连接生产数据库、Minecraft 或实际 Main/Node/Web 全流程。 首版会话与授权保存在单进程内存。Socket.IO 固定为 `4.8.3`,RPC 取消及缓冲检测使用该版本的内部 `acks/writeBuffer` 结构;升级依赖时必须重新验证取消与背压测试。借用宿主模式在同一 host/path 保留一个无活动会话的转接监听器,停止时返回 503,重启复用,避免破坏其他 Socket.IO 服务的 HTTP 监听链。