Skip to content

多节点协调

面板与各节点如何协作。本页讲机制(为什么这么设计);部署操作见 节点与面板

设计目标

新架构参照 MCSManager 的面板↔守护进程模型,解决两个核心问题:

  1. 资源下载流量分摊:边缘节点就近分发,减轻业务节点压力。
  2. 客户端多节点发现的安全性:不硬编码节点地址(改包者可篡改),而是钉 Ed25519 公钥(私钥不在任何线上节点),目录签名保护地址完整性。

角色边界

mermaid
flowchart TB
    Panel["🖥️ 面板\n(节点注册表 + WebUI)"]

    subgraph Business["业务节点 business"]
        GameAPI["游戏 API\n/client /account /auth\n/admin /user /setup"]
        DB[("数据库")]
        CtrlB["管控 WS 服务端\n:9090/ctrl"]
    end

    subgraph Edge["边缘节点 edge(可多个)"]
        Res["/res/* 资源分发"]
        CtrlE["管控 WS 服务端\n:9090/ctrl"]
    end

    Panel -->|"WS 长连接(遥测 + 指令)"| CtrlB
    Panel -->|"WS 长连接(遥测 + 指令)"| CtrlE

    Client["📱 游戏客户端"]
    Client -->|"握手 / 登录 / 存档"| GameAPI
    Client -->|"就近下载资源"| Res
    GameAPI --- DB

管控通道(面板↔节点)

面板主动拨号到节点的 WebSocket 管控端点(/ctrl),建立后:

  • 节点 → 面板:每 5 秒推遥测(CPU、内存、Goroutine 数、运行时间)。
  • 面板 → 节点:下发指令(重启、GC、轮换密钥等),等待回执(按 id 关联)。
  • 节点断线后面板每 30 秒重试连接。

这条通道用节点自持密钥鉴权:节点首次启动生成随机密钥,管理员将密钥复制到面板注册表,面板持有副本用于拨号。节点被击穿也签不出新密钥——密钥生成在节点进程内,私钥不在任何配置文件中传递。

客户端节点发现(签名目录)

游戏客户端在 /client/init 握手时,业务节点可附带已签名的节点目录。安全模型:

mermaid
sequenceDiagram
    autonumber
    participant Adm as 管理员(离线)
    participant Node as 业务节点
    participant Cli as 客户端

    Adm->>Adm: admintool gen-directory-key<br/>→ 公钥(钉进 APK)+ 私钥(离线保管)
    Adm->>Adm: admintool sign-directory<br/>→ directory-signed.json
    Adm->>Node: 将签好的 JSON 配置到 CNV_DIRECTORY_FILE

    Cli->>Node: POST /client/init
    Node-->>Cli: {"directory": {"payload": "<base64url>", "sig": "<base64>"}}
    Cli->>Cli: Ed25519_verify(根公钥, UTF8(payload字符串), decode(sig))
    Cli->>Cli: base64url解码payload → 取seq/nodes/expires_at
    Cli->>Cli: CheckFresh(lastSeq, now)
    Cli->>Cli: 按 caps 字段决定请求发给哪个节点

签发前的强制校验(Directory.Validate

directory.Sign() 在签名前先跑 Validate()不合格直接拒签admintool sign-directory 会把原因打到 stderr 并返回退出码 1)。

能力隔离只能在签发这一侧强制

客户端拿到目录后只验签名,不质疑能力分配是否合理。一份把 save 发给边缘节点的目录,只要签名有效,客户端就会老老实实把云存档凭证发到那台边缘机上——它没有任何依据判断这不对。

也就是说这条不变量客户端兜不住。而后台误勾一个复选框就足以造成它,事故现象(凭证被送到只做资源分发的节点)既不报错也不易察觉。所以宁可让签发当场失败。

校验项:

检查规则
能力隔离role="edge" 的节点不得持有 login / account / save 任一凭证类能力
已知能力caps 只能取 init/login/account/save/resource
非空每个节点必须有 idapi,且 caps 非空(空 caps 的节点客户端永远路由不到)
id 唯一节点 id 不得重复
过期时间expires_at 必须为正——填 0 会被客户端立即判为过期,等于白签

role="business" 的节点持有凭证类能力是正常配置,不受此限。

节点启动自检(CNV_DIRECTORY_FILE

业务节点只是把离线签好的字节原样转发,它手里没有根公钥(公钥钉在客户端 APK 里),因此验不了签。但它仍会在启动时把 payload 解出来做结构自检(directory.DecodeUnverified + Directory.Validate):

情况节点行为
文件读不到Error + 退出码 2
不是合法 {payload,sig} JSONError + 退出码 2
payload 不是合法 base64url / 目录 JSONError + 退出码 2
未通过 Validate(如边缘节点持有 saveError + 退出码 2
已过期(expires_at <= nowWarn仍然启动并下发
正常Info,日志带 seq / 节点数 / expires_at

这是「拒绝启动」而非「降级下发」

配错目录会让节点起不来,这是刻意的:坏目录被下发后,客户端只会静默丢弃它并回退 API_HOST——服务一切正常、日志一片安静,而按 caps 的能力隔离已经失效。与其让它悄悄退化,不如在启动时就炸掉。

排查时看日志里的具体原因;用新版 admintool sign-directory 重新签发即可(它签名前会跑同一套 Validate)。

过期是唯一的例外:只告警不拦。目录过期时服务本身仍可用,客户端会自行忽略并回退,此时让节点起不来反而是更大的故障。

DecodeUnverified 顾名思义不校验签名,返回内容不可信,仅用于上述自检,绝不能拿它替代 Verify 做任何信任决策。

签名格式(JWS 风格)

目录采用 JWS 风格签名:签名覆盖 base64url 编码后的字符串字节,而非裸 JSON 字节。

payload = base64url( UTF-8( 紧凑JSON{seq,issued_at,expires_at,nodes} ) )  // 无 = 填充
sig     = Ed25519_sign( 私钥, UTF-8( payload字符串 ) )                     // 签字符串本身

客户端验签时不需要重序列化 JSON,彻底消除跨语言字段顺序 / omitempty / <>& 转义 / 数字格式的对齐问题。

下发格式(/client/init 响应中的 directory 字段):

json
{
  "payload": "<base64url(UTF-8(紧凑JSON)),无=填充>",
  "sig":     "<standard_base64(Ed25519签名)>"
}

关键安全特性:

特性机制
防伪造私钥离线,节点与面板均不持有;攻击者无法生成合法签名
防回滚seq 单调递增;客户端拒绝 seq < 已知值的目录
防长期利用expires_at 强制刷新;过期目录被拒
最小权限caps 字段锁定凭证类请求(login/account/save)只发声明了对应能力的节点

节点注册与发现流程

mermaid
sequenceDiagram
    autonumber
    participant NNode as 业务/边缘节点
    participant Adm as 管理员
    participant Panel as 面板

    NNode->>NNode: 首次启动,生成 node.key,打印到日志
    Adm->>Panel: POST /api/panel/nodes<br/>{ctrl_url, api_url, key, role, ...}
    Panel->>Panel: 写入 panel_nodes 表
    Panel->>NNode: WebSocket 拨号 ctrl_url,发送 auth{key}
    NNode-->>Panel: auth_ok{id, role, version}
    loop 每 5s
        NNode-->>Panel: telemetry{cpu, mem, goroutines, uptime}
    end
    Adm->>Panel: GET /api/panel/nodes/xxx/status → 遥测摘要

客户端下载源优先级

业务节点把各来源合并成 groups 下发给客户端,顺序即优先级

mermaid
flowchart LR
    G1["1. 管理后台手动配的镜像组\n(最高优先)"] --> G2["2. 业务节点本地 /res\n(若设了 CNV_PRIMARY_RES_DIR)"]
    G2 --> G3["3. 边缘节点\n(来自签名目录 caps=resource)"]

客户端按组尝试下载,失败自动回退。管理员还可通过 /admin 给指定设备手动换线。

安全边界

  • 面板被击穿:攻击者能操控已注册节点(发指令、改注册表),但无法伪造节点目录——签名私钥不在面板。
  • 节点被击穿:攻击者得到节点密钥,能以该节点身份接受面板指令,但无法影响其它节点或面板数据库——节点间相互隔离。
  • 目录被篡改(中间人):客户端用钉扎的公钥验签,任何改动都会导致签名校验失败,请求被拒。

扩展性

  • 边缘节点可水平扩展(纯资源分发,无状态)。
  • 业务节点当前是单节点模型(DB、限流桶、内存心跳表均在进程内)。
  • 多业务节点高可用需要:限流换 Redis、会话存储已在 DB(天然共享)。对私服规模,单业务节点足矣。

文档正文以 CC BY-NC-SA 4.0 授权 · 代码部分以 GPLv3 开源 · 本项目仅作学习研究使用,与版权方无任何关联