MiBeeSteward v0.2.0 技术内幕:分布式一致性、反熵与变更检测引擎
上一篇讲了 v0.2.0 有什么。这篇讲为什么这么设计——分布式一致性是这个版本最重的部分,几个看似随意的决定背后都有具体的取舍。
要解决的问题很明确:center 只能看见自己所在的 LAN,但用户有多个 LAN。把 center 扔到每个网段不现实,于是有了 mibee-agent——一个只做「扫本地 + 报 center」的二进制。但 agent 一上,立刻带出一串协议问题:怎么上报、怎么对账、怎么判定掉线、怎么不把 center 写爆。下面逐个拆。
Agent ↔ Center 协议
协议选了 push 报告 + pull 命令的混合模型。agent 是唯一主动方,center 完全被动——这是为了过 NAT:agent 在内网出站 HTTPS 就行,center 不开任何 inbound 端口。
sequenceDiagram
participant A as mibee-agent
participant C as center
A->>C: POST /agents/report<br/>(存活设备 + X-Network-State-Hash)
C-->>A: 200 OK
loop 每 60s 轮询
A->>C: GET /agents/commands
C-->>A: 待执行命令(可空)
A->>C: POST /commands/{id}/ack
Note over A: 执行扫描<br/>硬截止 15min
A->>C: POST /commands/{id}/complete
end
Note over A,C: center 宕机时<br/>backfill 队列(cap=100) 有序重投两条信道各管一摊:report 信道推存活设备(每 30s 一次,携带状态哈希,详见下一节),command 信道拉临时扫描命令(每 60s 轮询一次,ack → 执行 → complete 的闭环)。center 可以从 Web UI 触发「让某 agent 立刻扫一把」,命令进队列,agent 下一轮轮询拿到。
两个工程细节值得拎出来:
硬扫描截止——agent 触发的扫描最多跑 15 分钟(或 timeout×2+60s)。这是血泪教训:没有截止时间的话,一次卡住的 TCP read 会把一条命令永远钉在「acknowledged」状态,agent 看起来还活着但实际不干活。
断连 backfill——center 宕机期间,agent 的报告不丢,先压进一个容量 100 的内存队列,center 恢复后按顺序重投。超过 100 才丢最早的。这意味着短时 center 重启不会让任何网段「失联」。
agent 的配置极简,三项必填:
| |
agent 没有数据库、没有 Web UI、没有用户系统——只扫描 + 上报。mibee-agent -version 报版本,配错了看日志就行。
新增的 agent 相关 API 端点(都在 /api/v1 下):
| 方法 | 路径 | 鉴权 | 用途 |
|---|---|---|---|
| POST | /agents/report | agent token | 上报存活设备 |
| GET | /agents/commands | agent token | 拉取待执行命令 |
| POST | /agents/commands/{id}/ack | agent token | 确认收到命令 |
| POST | /agents/commands/{id}/complete | agent token | 上报命令结果 |
| POST/GET | /agents/tokens | admin | 创建/列出 token |
| POST | /agents/tokens/{id}/revoke | admin | 吊销 token |
| POST | /agents/{agentId}/commands | admin | 下发扫描命令 |
| GET | /agents/commands/all | admin | 列出所有 agent 的命令 |
反熵快路径
agent 每 30 秒推一次报告。如果一个网段很安静——设备没增减、没换 IP——那 30 秒前后的存活设备集是完全一样的。如果 center 每次都老老实实跑一遍 device bridge(指纹分类、推断类型、写设备行),那就是在反复算同一道题。
v0.2.0 的解法是反熵(anti-entropy)哈希快路径:
每次上报带一个 X-Network-State-Hash 头,是存活设备集的标识 + 分类字段做 SHA-256。center 拿到后跟上次存的哈希比对:
- 哈希一致 → 跳过整个 device bridge,只刷新每台设备的租约时间戳(
scan_snapshots.last_seen_at)。这是安静网络的稳态路径,成本≈一次 SHA-256 比对 + N 次时间戳写入。 - 哈希不一致 → 逐台过 device bridge,指纹分类、推断类型/品牌/型号、按需发出
device_added/device_changed事件。
为什么哈希的是「标识+分类字段」而不是整条设备记录?因为有些字段天然会变(比如 RTT、last_seen_at),把它们算进哈希会导致永远「不一致」,快路径就废了。只哈希那些「变了才算真变了」的字段,才能让安静网络真的安静下来。
这个设计借鉴了 Dynamo 风格的反熵思路,但大幅简化——没有 Merkle 树、没有向量时钟,因为这里的「真相」只在 agent 那一侧(它扫的是本地 LAN),center 不需要合并多个副本,只需要决定「要不要重新算」。一个哈希比对就够。
双轨掉线判定
掉线判定是分布式系统里最容易出 bug 的地方。v0.2.0 有两条独立的掉线路径,但都发出同一个 device_lost 事件——上层消费方不用关心来源。
center 自管网段用连续扫描判定:每轮扫描里没出现的设备 miss_count +1,连续 2 轮 miss 才标掉线。这是 v0.1.0 就有的逻辑,适合「center 自己定时扫」的场景——扫描节奏确定,miss_count 有意义。
flowchart TB
SCAN["每轮扫描"] --> MISS["miss_count +1"]
MISS -->|"miss_count < 2"| ONLINE["仍在线"]
MISS -->|"miss_count ≥ 2"| EV1["device_lost"]
classDef proc fill:#FFF3E0,stroke:#E65100,color:#BF360C
classDef ok fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
classDef lost fill:#FFEBEE,stroke:#F44336,color:#C62828
class SCAN,MISS proc
class ONLINE ok
class EV1 lost图中每次扫描未命中则 miss_count 递增;未到阈值(< 2)维持在线,达到阈值(≥ 2)即发出 device_lost 事件。
agent 管管网段用租约(lease)判定:每次上报刷新每台设备的租约,TTL 默认 5 分钟,后台 LeaseSweeper 每 60 秒扫一遍,租约过期就标掉线。按 30s 上报间隔算,大约连续 10 次漏报才会判定掉线。
flowchart TB
RPT["每 30s 上报"] --> LEASE["刷新租约<br/>TTL 5min"]
LEASE --> SWEEP["LeaseSweeper<br/>每 60s 扫"]
SWEEP -->|"租约未过期"| ONLINE2["仍在线"]
SWEEP -->|"租约过期"| EV2["device_lost"]
classDef proc fill:#FFF3E0,stroke:#E65100,color:#BF360C
classDef ok fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
classDef lost fill:#FFEBEE,stroke:#F44336,color:#C62828
class RPT,LEASE,SWEEP proc
class ONLINE2 ok
class EV2 lost图中每次上报刷新设备租约;LeaseSweeper 每 60 秒巡检,租约仍在 TTL 内维持在线,过期则发出 device_lost 事件。
为什么不统一用一种?因为两个场景的「信号源」不一样:
- center 自扫:信号是「这一轮扫到了没」,天然是离散的逐轮判定,
miss_count直接对应「连续几轮没扫到」。 - agent 上报:信号是「最近一次上报在多久之前」。agent 可能整段静默(比如 agent 自己挂了、网络断了),这时候没有「轮」的概念,只能靠时间窗。如果硬套
miss_count,你得先定义「一轮」是多久,然后 agent 挂了的时候 center 还在傻等「下一轮」——判定会延迟且语义模糊。
租约模型更诚实:它直接承认「我只知道最后一次见到这台设备是什么时候」,TTL 过了就是过期。LeaseSweeper 是个简单的后台 goroutine,每 60 秒做一次 DELETE FROM scan_snapshots WHERE last_seen_at < now() - ttl,复杂度 O(过期设备数)。
两条路径都写到同一张 change_log 表、发同一个 device_lost 事件,前端 Changes 页和 SSE 流看到的没有区别。
变更检测引擎
v0.1.0 只记录设备当前状态,v0.2.0 加了变更检测。核心是一个在 center 进程内跑的 Watcher,把三类事件落到 change_log 表。
flowchart TB
SRC["扫描结果<br/>/ 被动发现"] --> DIFF["Watcher<br/>比对前后状态"]
DIFF --> EVENTS["device_added<br/>device_changed<br/>device_lost"]
EVENTS --> LOG[("change_log 表")]
LOG --> OUT["GET /changes<br/>SSE 实时流<br/>Prometheus 计数"]
classDef src fill:#E3F2FD,stroke:#1565C0,color:#1565C0
classDef proc fill:#FFF3E0,stroke:#E65100,color:#BF360C
classDef ev fill:#FFEBEE,stroke:#F44336,color:#C62828
classDef store fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
classDef query fill:#F3E5F5,stroke:#9C27B0,color:#9C27B0
class SRC src
class DIFF proc
class EVENTS ev
class LOG store
class OUT querydevice_changed 跟踪 12 个字段:name、type、brand、model、MAC、IP、status、open ports、detected services、Prometheus URL、node_exporter URL、scan attributes。每条事件存的是结构化的 before→after diff,不是整条快照——查起来直接看到「IP 从 .143 变到 .144」,不用自己比对两条完整记录。
一条 device_changed 事件的 JSON 长这样:
| |
消费侧有三条路:GET /api/v1/changes 分页查询(可按 network + type 过滤)、GET /api/v1/changes/watch SSE 实时流(带 keepalive,浏览器 Changes 页连上就自动推)、/metrics 里的 mibee_changes_total{type=...} Prometheus 计数器。change_log 默认保留 30 天,可在 retention.change_log_days 调。
Watcher 只在 center 跑——agent 不做变更检测,它只上报原始存活集,diff 的职责集中在 center,避免多源判定冲突。
数据驱动指纹引擎
v0.1.0 的设备识别规则是 Go 代码里的 if 和 switch,加一个设备签名要改代码、重新编译、发版。v0.2.0 把识别规则变成了数据——YAML 文件,启动时由 RuleClassifier 加载。
flowchart TB
RULES["YAML 规则<br/>builtin + Recog"] --> LOAD["RuleClassifier<br/>启动加载"]
EVID["扫描证据<br/>SNMP/HTTP/TLS/banner"] --> MATCH["规则匹配"]
LOAD --> MATCH
MATCH -->|"命中声明式规则"| OUT["品牌/型号/类型"]
MATCH -->|"需复合逻辑"| GO["Go 兜底分类器"]
classDef data fill:#E3F2FD,stroke:#1565C0,color:#1565C0
classDef proc fill:#FFF3E0,stroke:#E65100,color:#BF360C
classDef out fill:#E8F5E9,stroke:#2E7D32,color:#1B5E20
classDef fallback fill:#F3E5F5,stroke:#9C27B0,color:#9C27B0
class RULES,EVID data
class LOAD,MATCH proc
class OUT out
class GO fallback规则路径留空就用二进制内嵌的规则集(零配置),也可以指向外部目录方便热更新。开箱即用约 2554 条规则——20 条手写 builtin + 约 2534 条从 Rapid7 的 Recog 语料库导入(Apache-2.0 许可证干净)。nmap 的 NPSL 因为许可证问题故意不导入。
一条 Recog 风格的 YAML 规则大致长这样(简化示例):
| |
关键的工程约束是:不是所有逻辑都能声明式化。少数复合判断还是留在 Go 里,并明确文档说明——SNMP bitmask 设备类型启发式(某些设备用 OID 的特定位组合表示类型,没法用正则表达)、摄像头交叉证据融合(要把 RTSP + ONVIF + SNMP 三路证据按置信度合并)、数据库/远程访问的字节偏移分类器(看响应包特定字节的值)。
为了保证「数据化」没有悄悄改变行为,加了一个 parity 测试:断言数据驱动的 RuleClassifier 输出和它替换掉的手写分类器字节级一致。任何规则迁移引入的行为差异,CI 里就能挡住。独立的指纹引擎拆到了 mibee-fingerprints-go 模块,作为普通 Go 依赖被消费——这意味着别的项目也能复用这套规则库。
第三方规则库通过 cmd/fpimport/ 转换导入,目前支持 Rapid7 Recog 和 IEEE-OUI / IANA-PEN 数据表。
已知限制
几个设计上故意没做的,免得误期待:
- 单实例 SQLite——center 是单实例的,没有多 center 集群。规模到这个量级之前不打算做。
- 不做告警——MiBeeSteward 产出「资产新鲜度」,告警留给 Alertmanager / Uptime Kuma。
/metrics和/sd已经把数据接出去了。 - eBPF 被动观测器需要特殊构建(
make build-with-ebpf)和运行时特权(CAP_BPF / CAP_NET_ADMIN),默认二进制里是 no-op stub。
相关链接
- MiBeeSteward GitHub
- MiBeeSteward v0.2.0 Release Notes
- 指纹引擎独立模块 mibee-fingerprints-go
- v0.2.0 功能概览(promo 版)
v0.2.0 的分布式部分其实就一个核心想法:agent 是真相来源,center 是聚合器,哈希快路径让安静网络几乎零成本。剩下的——租约、双轨掉线、变更引擎——都是围绕这个核心想法的工程化收尾。如果你在搭类似的多站点发现系统,这几个取舍值得参考。