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 端口。

mermaid
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 的配置极简,三项必填:

yaml
1
2
3
4
5
6
7
# configs/agent.yaml
center:
  url: "http://center.example.com:8080"
  auth_token: "<agent-token-from-center>"   # 32 随机字节,center 只存 SHA-256
  report_interval: "30s"
network:
  name: "lan-hq"        # agent 覆盖的逻辑网络名,必须与 center 上建的 network 一致

agent 没有数据库、没有 Web UI、没有用户系统——只扫描 + 上报。mibee-agent -version 报版本,配错了看日志就行。

新增的 agent 相关 API 端点(都在 /api/v1 下):

方法路径鉴权用途
POST/agents/reportagent token上报存活设备
GET/agents/commandsagent token拉取待执行命令
POST/agents/commands/{id}/ackagent token确认收到命令
POST/agents/commands/{id}/completeagent token上报命令结果
POST/GET/agents/tokensadmin创建/列出 token
POST/agents/tokens/{id}/revokeadmin吊销 token
POST/agents/{agentId}/commandsadmin下发扫描命令
GET/agents/commands/alladmin列出所有 agent 的命令

反熵快路径

agent 每 30 秒推一次报告。如果一个网段很安静——设备没增减、没换 IP——那 30 秒前后的存活设备集是完全一样的。如果 center 每次都老老实实跑一遍 device bridge(指纹分类、推断类型、写设备行),那就是在反复算同一道题。

v0.2.0 的解法是反熵(anti-entropy)哈希快路径:

反熵哈希快路径① Agent 上报携带状态哈希agent存活设备集POST /agents/report② X-Network-State-HashSHA-256标识 + 分类字段比对③ Center 与上次比对✓ 一致✗ 不一致④ 稳态 / 调和跳过 device bridge仅刷新租约逐台 device bridge重建设备画像⑤ 每次上报刷新租约(TTL 5 分钟,扫描 60 秒)scan_snapshots.last_seen_at哈希一致 → 刷租约是安静网络唯一要做的工作⑥ 不一致路径:device bridge 重建画像逐台:指纹分类 → 推断类型/品牌/型号 → 写设备行按需发出 device_added / device_changed 事件为什么要哈希而不是直接信任上报?安静网络每 30 秒重发同一份存活集——哈希让 center 跳过成千上万次冗余的 device bridge 调用稳态成本 ≈ 每次上报一次 SHA-256 比对 + 一次租约时间戳写入

每次上报带一个 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 有意义。

mermaid
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 次漏报才会判定掉线。

mermaid
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 表。

mermaid
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 query

device_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 长这样:

json
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
{
  "device_id": 42,
  "type": "device_changed",
  "network_id": 3,
  "changes": {
    "ip": { "before": "10.0.0.143", "after": "10.0.0.144" },
    "status": { "before": "online", "after": "online" }
  },
  "captured_at": "2026-07-13T02:14:31Z"
}

消费侧有三条路: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 代码里的 ifswitch,加一个设备签名要改代码、重新编译、发版。v0.2.0 把识别规则变成了数据——YAML 文件,启动时由 RuleClassifier 加载。

mermaid
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 规则大致长这样(简化示例):

yaml
1
2
3
4
5
6
7
- fingerprint:
    os: "Ubuntu"
    os_version: "<%= data.fetch('version') %>"
  match: "Ubuntu(?:-)?(\\d+(?:\\.\\d+)*)"
  params:
    - match_group: 1
      name: version

关键的工程约束是:不是所有逻辑都能声明式化。少数复合判断还是留在 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。

相关链接

v0.2.0 的分布式部分其实就一个核心想法:agent 是真相来源,center 是聚合器,哈希快路径让安静网络几乎零成本。剩下的——租约、双轨掉线、变更引擎——都是围绕这个核心想法的工程化收尾。如果你在搭类似的多站点发现系统,这几个取舍值得参考。