Files
justme ad5efc62ff agent: unify listening and dialing with directed mutual measurements
Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
2026-10-21 11:55:36 +08:00

9.6 KiB
Raw Permalink Blame History

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,和业务数据由同一个单写入者写入。