Files
forward_server/MAIN_SERVER_IMPLEMENTATION_PLAN.md
T
2026-10-22 17:07:08 +08:00

248 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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<T> =
| {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 监听链。