Files

Fleet:通用节点框架

「很多节点连到一个面板」这类产品都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置。Fleet 把这些做成与业务无关的库,MoShiro 是它的第一个使用者;产品只写业务。Fleet 不引用任何 MoShiro.*,也不出现产品的名字、端口和协议前缀,由 ArchitectureTests 在构建时检查。

┌──────────────────── 产品层(MoShiro:Protocol · Measurement · Agent · Backend)────────────────────┐
└────────────────────────────────────────────┬─────────────────────────────────────────────────────────────┘
                                             │ 只依赖 Fleet
┌───────────────────────────── Fleet ────────┴─────────────────────────────────────────────────────────────┐
│ 节点侧(只依赖 BCL,NativeAOT)                          服务端侧(ASP.NET Core)                          │
│ Fleet.Core     Result、退避、有界队列、ID、令牌哈希       Fleet.Server.Abstractions  IFleetStore 等接口      │
│ Fleet.Identity 密钥、签名、证书、KeyStore                 Fleet.Server.Enrollment    加入令牌、注册、审批     │
│ Fleet.Rpc      JSON-RPC 信封、路由、二进制帧              Fleet.Server.Link          wss 网关、在线表、状态下发 │
│ Fleet.Link     会话、认证、重连、发件箱、状态同步、点对点                                                    │
│ Fleet.Hosting  模块宿主、配置、日志、服务外壳               工具:fleet-enroll(一次性注册)                  │
│ Fleet.Enrollment.Client  注册流程(给 fleet-enroll 用)                                                      │
└────────────────────────────────────────────────────────────────────────────────────────────────────────┘

设计原则

  1. 与业务无关:产品通过扩展点接入:元数据类型、RPC 方法、发件箱消息类型、期望状态文档类型。
  2. 安全默认值:Fleet 本身不提供 exec、shell、文件读写、远程下发代码这类方法;节点只响应本地注册过的方法(本地策略 allow_methods 还能收窄);调用对方不支持的方法在本地直接返回 fleet_unsupported。
  3. 权限分离:注册(fleet-enroll,一次性)和常驻业务进程是两个进程;常驻进程不含注册代码,也不使用 HttpClient。
  4. NativeAOT:节点侧只依赖 BCL,泛型扩展点通过产品传入的 JsonTypeInfo<T> 序列化,不使用反射。
  5. 更新不在 Fleet 里:Fleet 不包含更新逻辑,更新由节点外部的服务管理器或更新器完成。Quadlet 装的节点和 Backend 由退出后 systemd 重新拉镜像、脚本装的节点由宿主机上的更新器验签更新,见 docs/ops/deployment.md;纯二进制节点的更新见 路线图。

身份

对象 算法 保存位置
节点身份密钥 ECDSA P-256(BCL 自带) <identity_dir>/node.key(0600);服务端只保存公钥
点对点 TLS 证书 自签名 ECDSA P-256 <identity_dir>/tls.pfx;对端按 SPKI SHA-256 指纹校验
签名串 `"fleet v1

用途:auth(连接服务端)、peer(点对点)、enroll(注册时的持有证明)。产品可以追加自己的用途,不能占用 fleet 保留的。

注册

管理员 ── 创建加入令牌 {labels, max_uses, expires_at, auto_approve} ──> 服务端
       <── 令牌明文(只返回一次)+ 安装命令

节点:fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/myapp
  ├─ 生成身份密钥和 TLS 证书
  ├─ POST 注册端点 { join_token, name, pubkey, tls_spki, proof = 签名("enroll", 令牌哈希 + pubkey), node_version, meta }
  │    服务端:校验令牌 → uses+1 → 创建节点(active 或 pending)→ 触发产品的 IEnrollmentHook
  └─ 写入 <identity_dir>/node.json { node_id, server, … },然后退出
  • 同名节点已存在时,新注册进入 pending,管理员选择「接管」(旧记录换成新公钥,历史数据保留)或「作为新节点」。
  • 节点状态:pending(能连接,但不下发状态、不接收上报)、active、disabled(拒绝连接)。

RPC

  • JSON-RPC 2.0;方法名 领域.动作,fleet.* 保留给框架,产品用自己的前缀(MoShiro 是 moshiro.*)。
  • 二进制帧 u8 channel | 正文:0–15 框架保留,产品从 16 开始。
  • 错误:error.data.code 为 snake_case,框架的以 fleet_ 开头,产品可以扩展。
  • 方法必须声明:每个方法在声明表(MethodTable)里写明发起方、请求还是通知、哪些角色可以参与;不存在双向方法,同一个方法名在一个平面里只有一个发起方。RpcRouter 在注册、分发和发送三处核对,违规回 fleet_protocol_violation,超过上限断开连接。详见 docs/security/security.md 第 6 节。
  • 与传输无关:同一套路由跑在 wss 上,也跑在 TLS 帧(u32 长度 | u8 类型 | 正文)上。
保留方法 方向 说明
fleet.challenge / fleet.auth 服务端→节点 / 节点→服务端 签名认证(auth 用途,nonce 30 秒内有效)
fleet.hello 节点→服务端 版本、能力、本地策略摘要、当前时间 → 返回时钟偏差;产品的元数据也放在这里
fleet.heartbeat 节点→服务端 每 10 秒一次,产品可以附加计数
fleet.stopping 节点→服务端 有序关闭前通知服务端,携带 reason(例如 stopping 或 updating),用于准确区分离线和计划停机
fleet.state.sync 服务端→节点 {version, doc} → {applied_version, rejected[]}
fleet.outbox.ack 服务端→节点 {ids[], duplicates[]}
fleet.revoked 服务端→节点 节点被禁用或接管,随后断开

连接

connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 → 正常运行
      ▲                                                                  │
      └──── 指数退避重连(1 s → 60 s,带抖动;401 或 revoked 时停止重试)◀─ 断开

本地策略:allow_methods、max_frame_bytes、max_inflight。

可靠发件箱:产品 EnqueueAsync → 有界队列(按字节和条数;满时 DropOldest 或 Reject)→ 发送 → 服务端收件箱(IInbox)持久化后 fleet.outbox.ack → 移除。至少一次,按 msg_id(UUIDv7)去重;在途数据默认限 4 MiB;丢弃会累计缺口,通过 OnGap 通知产品。只在内存里,不落盘。

期望状态同步:服务端 IStateProvider<TDoc> 给每个节点算 {version, doc},变化时 NotifyChanged 推送;节点 StateHandler<TDoc> 调和后返回 {applied_version, rejected[]}。下发的是完整快照,天然幂等,重连后服务端总会重发当前版本。rejected[] 带结构化原因(unsupported、budget_exceeded、policy_denied),面板可以直接展示。

点对点会话

节点 A(发起方,如 Agent A)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,如 Agent B)
    <── fleet.peer.challenge ──
    ── fleet.peer.auth(peer 用途,A 的私钥签名)──>
    B 用服务端下发的对端公钥列表验签(经 state.sync 下发),之后复用同一套 Rpc:路由、二进制通道、发件箱

握手用两个方法:fleet.peer.challenge(监听方→拨号方,通知)和 fleet.peer.auth(拨号方→监听方,请求);握手完成后,两侧各自只能发起声明表里属于自己这一侧的方法。

对端公钥和允许连接的节点由产品通过期望状态下发给监听方,Fleet 只管认证和会话,拓扑由产品决定;UDP、原始 TCP 数据面由产品自己实现,Fleet 只提供会话密钥派生和首帧 MAC 的辅助函数。

宿主与服务端抽象

  • ModuleHost:按依赖顺序启动模块、相反顺序停止,隔离故障,重启时退避。
  • NodeConfig:源生成 JSON 读取,环境变量覆盖,启动时校验。
  • ConfigStore:常驻进程里唯一允许写文件的地方,只能写配置目录,写入原子替换、权限 0600;父目录不存在会创建。
  • ServiceShell:SIGTERM、Ctrl+C,systemd sd_notify、OpenWrt procd(前台运行)和 Windows 服务。
  • 服务端接口:IFleetStore(节点、令牌、公钥、状态版本)、IStateProvider<TDoc>、IInbox、INodeEventSink、IEnrollmentHook。MoShiro 把 IFleetStore 放进自己的 moshiro.db,和业务数据由同一个单写入者写入。