# Fleet:通用节点框架 「很多节点连到一个面板」这类产品都要解决同一批问题:节点怎么接入、怎么证明身份、怎么保持连接、怎么可靠上报、怎么接收配置。Fleet 把这些做成与业务无关的库,MoShiro 是它的第一个使用者;产品只写业务。Fleet 不引用任何 `MoShiro.*`,也不出现产品的名字、端口和协议前缀,由 `ArchitectureTests` 在构建时检查。 ```text ┌──────────────────── 产品层(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` 序列化,不使用反射。 5. 更新不在 Fleet 里:Fleet 不包含更新逻辑,更新由节点外部的服务管理器或更新器完成。Quadlet 装的节点和 Backend 由退出后 systemd 重新拉镜像、脚本装的节点由宿主机上的更新器验签更新,见 [`docs/ops/deployment.md`](../../docs/ops/deployment.md);纯二进制节点的更新见 [路线图](../../docs/roadmap.md)。 ## 身份 | 对象 | 算法 | 保存位置 | | --- | --- | --- | | 节点身份密钥 | ECDSA P-256(BCL 自带) | `/node.key`(0600);服务端只保存公钥 | | 点对点 TLS 证书 | 自签名 ECDSA P-256 | `/tls.pfx`;对端按 SPKI SHA-256 指纹校验 | | 签名串 | `"fleet|v1||" + 各字段` | 带用途前缀,防止同一个签名在不同场景被复用 | 用途:`auth`(连接服务端)、`peer`(点对点)、`enroll`(注册时的持有证明)。产品可以追加自己的用途,不能占用 `fleet` 保留的。 ## 注册 ```text 管理员 ── 创建加入令牌 {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 └─ 写入 /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`](../../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` | 服务端→节点 | 节点被禁用或接管,随后断开 | ## 连接 ```text 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` 给每个节点算 `{version, doc}`,变化时 `NotifyChanged` 推送;节点 `StateHandler` 调和后返回 `{applied_version, rejected[]}`。下发的是完整快照,天然幂等,重连后服务端总会重发当前版本。`rejected[]` 带结构化原因(`unsupported`、`budget_exceeded`、`policy_denied`),面板可以直接展示。 ## 点对点会话 ```text 节点 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`、`IInbox`、`INodeEventSink`、`IEnrollmentHook`。MoShiro 把 `IFleetStore` 放进自己的 `moshiro.db`,和业务数据由同一个单写入者写入。