8.6 KiB
8.6 KiB
Fleet:身份、注册与连接
1. 概览
fleet-enroll(一次性运行)
│ ① 用加入令牌注册,生成身份
▼
┌────────── 节点 ──────────┐ ┌──────────── 服务端 ────────────┐
│ Fleet.Hosting │ ② wss 连接 │ Fleet.Server.Link │
│ └─ Fleet.Link ─────────────── 签名认证 ────>│ 连接注册表 · 在线事件 │
│ ├─ Rpc 路由 │<── state.sync ──│ 期望状态提供者(产品实现) │
│ ├─ 期望状态调和 │── outbox.push ─>│ 收件箱(产品实现,持久化后 ACK) │
│ └─ 可靠发件箱 │<── outbox.ack ──│ │
│ │ │ Fleet.Server.Enrollment │
│ Fleet.Link(点对点)──────── TLS + 签名 ────> 另一个节点(例如 Probe → Agent) │
└──────────────────────────┘ └────────────────────────────────┘
2. 身份(Fleet.Identity)
| 对象 | 算法 | 保存位置 |
|---|---|---|
| 节点身份密钥 | ECDSA P-256(BCL 自带;Ed25519 不在 BCL 中) | <identity_dir>/node.key(0600);服务端只保存公钥 |
| 点对点 TLS 证书 | 自签名,ECDSA P-256 | <identity_dir>/tls.pfx;对端按 SPKI SHA-256 指纹校验 |
| 签名串 | `"fleet | v1 |
用途前缀的取值:auth(连接服务端)、peer(点对点)、enroll(注册时的持有证明)。产品可以追加自己的用途(例如 myapp.xxx),但不能占用 fleet 保留的用途。
3. 注册(Fleet.Enrollment)
管理员 ── 创建加入令牌 {labels, max_uses, expires_at, auto_approve, product_meta?} ──> 服务端
<── 令牌明文(只返回一次)+ 安装命令
节点:fleet-enroll --server https://panel --join jt_xxx --name jp-1 --identity-dir /etc/myapp
├─ 生成身份密钥和 TLS 证书
├─ POST <enroll 端点>
│ { join_token, name, pubkey, tls_spki, proof = 签名("enroll", join_token 哈希 + pubkey),
│ node_version, meta(产品自定义的 JSON,经产品的 JsonTypeInfo 校验)}
│ 服务端:校验令牌 → uses+1 → 创建节点(active 或 pending)→ 触发 OnEnrolled 事件(产品挂钩)
└─ 写入 <identity_dir>/node.json { node_id, server, … },然后退出
- 名称冲突:同名节点已存在时,新注册进入
pending,由管理员选择“接管”(旧记录换成新公钥,历史数据保留)或“作为新节点”。 - 节点状态:
pending(能连接,但不下发状态、不接收上报)、active、disabled(拒绝连接)。 - 注册逻辑只存在于
fleet-enroll中,常驻的业务进程不包含注册代码,也不使用 HttpClient。 - 产品可以复用
fleet-enroll本身,也可以把Fleet.Enrollment.Client嵌入自己的安装工具中。
4. RPC(Fleet.Rpc)
- 信封采用 JSON-RPC 2.0;方法名为
领域.动作。fleet.*保留给框架,产品使用自己的前缀,例如monitor.*。 - 二进制帧:
u8 channel | 正文。channel0到15保留给框架,产品从16开始注册自己的二进制通道。 - 错误:
error.data.code为 snake_case 字符串,框架的错误码以fleet_开头,产品可以扩展。 - 与传输方式无关:同一套路由既可以跑在 wss 上,也可以跑在 TLS 帧(
u32 长度 | u8 类型 | 正文)上。
框架保留的方法:
| 方法 | 方向 | 说明 |
|---|---|---|
fleet.challenge / fleet.auth |
服务端→节点 / 节点→服务端 | 签名认证(auth 用途,nonce 30 秒内有效) |
fleet.hello |
节点→服务端 | 节点版本、能力列表(框架 + 产品)、本地策略摘要、当前时间 → 返回时钟偏差 |
fleet.heartbeat |
节点→服务端 | 每 10 s 一次;产品可以附加进程级计数 |
fleet.state.sync |
服务端→节点 | {version, doc} → {applied_version, rejected[]} |
fleet.outbox.ack |
服务端→节点 | {ids[], duplicates[]} |
fleet.revoked |
服务端→节点 | 节点被禁用或接管,随后断开 |
5. 连接生命周期(Fleet.Link)
connect(wss) → fleet.challenge → fleet.auth → fleet.hello → 状态同步 → 正常运行
▲ │
└──── 指数退避重连(1 s → 60 s,带随机抖动;401 或 revoked 时停止重试)◀─ 断开
- 能力协商:双方交换 capabilities;调用对方不支持的方法时,在本地直接返回
fleet_unsupported,不会发出请求。 - 节点本地策略:
allow_methods(白名单,默认只包含本地已注册的方法)、max_frame_bytes、max_inflight。
6. 可靠发件箱(Outbox)
产品 → outbox.EnqueueAsync(msg) → 有界队列(按字节和条数限制;满时按策略:DropOldest 或 Reject)
→ 发送(二进制通道或 JSON)→ 服务端收件箱处理并持久化 → fleet.outbox.ack → 从队列移除
- 投递语义为至少一次:每条消息都带有
msg_id(UUIDv7),服务端按此去重。 - 在途数据按字节限制(默认 4 MiB),保证服务端慢时不会把内存撑爆。
- 丢弃消息时会累计缺口计数,通过
OnGap回调通知产品,由产品决定如何上报。 - 默认只保存在内存中,不落盘。产品如果需要持久化,可以自行实现
IOutboxStore(Monitor 不使用)。
7. 期望状态同步(State Sync)
服务端:IStateProvider<TDoc>.GetAsync(node) → {version, doc}
发生变化时调用 NotifyChanged(node) → 推送 fleet.state.sync
节点: IStateReconciler<TDoc>.ApplyAsync(doc) → {applied_version, rejected[]}
重连后,服务端总会重新推送当前版本
- 下发的是完整快照而不是命令,所以天然幂等,断线重连后也能自动收敛。
rejected[]为结构化原因,例如unsupported、budget_exceeded、policy_denied,服务端可以把它展示在面板上。
8. 节点间点对点会话
节点 A(发起方,例如 Probe)── TLS(按 SPKI 校验证书)──> 节点 B(监听方,例如 Agent)
<── fleet.challenge ──
── fleet.auth(peer 用途,节点 A 的私钥签名)──>
节点 B 用服务端下发的对端公钥列表验签(通常经 state.sync 下发)
之后使用同一套 Fleet.Rpc:方法路由、二进制通道、发件箱都可以复用
- 对端的公钥和允许连接的节点列表,由产品通过期望状态下发给监听方。Fleet 只负责认证和会话,拓扑由产品决定。
- 数据面的 UDP、原始 TCP 流等通道由产品自己实现。Fleet 只提供会话密钥派生(
session_key)和首帧 MAC 的辅助函数。
9. 宿主(Fleet.Hosting)
ModuleHost:按依赖顺序启动模块,按相反顺序停止;隔离故障,重启时退避。NodeConfig<T>:源生成 JSON 读取,支持环境变量覆盖,启动时校验。ConfigStore:业务进程唯一允许写文件的地方,只能写配置目录。ServiceShell:处理 SIGTERM 和 Ctrl+C,支持 systemdsd_notify、OpenWrt procd(前台运行,由 procd 负责重启)和 Windows 服务。HealthBeacon:进程完成启动并与服务端握手后,写出{app, version, pid, ok_at}到本地信标文件或命名管道,供fleet-updater确认新版本是否健康。
10. 服务端存储抽象
public interface IFleetStore
{
ValueTask<JoinToken?> FindJoinTokenAsync(ReadOnlyMemory<byte> tokenHash, CancellationToken ct);
ValueTask<Result<NodeRecord>> CreateNodeAsync(NewNode node, CancellationToken ct);
ValueTask<NodeRecord?> GetNodeAsync(NodeId id, CancellationToken ct);
ValueTask UpdatePresenceAsync(NodeId id, Presence presence, CancellationToken ct);
// 令牌、审批、接管、禁用等
}
产品可以使用 Fleet.Server.Sqlite,也可以把这些表放进自己的数据库中。Monitor 的做法是把它们放进自己的 monitor.db,并由同一个单写入者负责写入。