MQTT 3.1.1 Broker 会话投递审计:四盏绿灯账全错,健康检查自证健康怎么修
日期:2026-10-26 环境:Python 3.12 纯标准库 / TSV 事实台账 / JSON 报告 / Docker Compose / 外部独立探针 关键词:MQTT 3.1.1、MQTT broker 会话审计、clean_session、session_expiry_seconds、engine_defaults、 通配符订阅
+跨层、空层级主题、granted_qos=-1 被拒订阅、重复订阅去重、retain 保留消息清除、 半帧 sent_bytes、重发未确认、假绿健康检查、fail-open 改 fail-closed、退出码 0/2/3
一、问题背景
设备接入平台换自研 MQTT 3.1.1 Broker,运维巡检面板四盏绿灯:
python3 -m app.service # rc=0
python3 -m app.health # {"degraded": false, "issues": []}
python3 -m app.run # 不报错,reports/audit.json 字段齐全
但业务方三个投诉全对不上:
- 离线设备收到本该丢掉的旧会话消息 ——
clean_session=1的短连接设备被补发历史消息, 已过期的持久会话还被当成活跃订阅者。 - 同一个主题订阅两次,"投递匹配数"直接翻倍 —— 运维给
edge-01补了一条重复订阅就涨了。 - 明明发出去的报文,下行字节数对不上 ——
transport.sent_bytes比链路侧少了一段。
维护者加了一轮"健康检查",什么都没查出来 —— 这就是典型的假绿。
二、复现步骤
Docker 方式
cd scenario/2026-10-26-0002
docker compose up -d --build
docker compose exec -T broker bash # 非交互必须加 -T
# 容器内(/srv)
bash ops/repro.sh ; echo "probe_rc=$?" # 问题态:RESULT: BAD(不一致 14 条)/ rc=1
bash ops/verify.sh ; echo "verify_rc=$?" # 问题态:PASS=21 FAIL=18 / RESULT: BAD
bash ops/negative.sh # 问题态:PASS=14 FAIL=2
python3 -m app.service ; echo "rc=$?" # 问题态:rc=0(假的绿)
无 docker CLI(本次实际环境)
pipeline 容器里 command -v docker 返回 127,按 Dockerfile 的 COPY 布局在本地还原 /srv 后直接跑:
cd scenario/2026-10-26-0002
bash ops/repro.sh ; echo rc=$? # RESULT: BAD,rc=1(rc=1 是预期信号,用 ; 接住)
bash ops/verify.sh ; echo rc=$? # PASS=21 FAIL=18 / RESULT: BAD
bash ops/negative.sh # PASS=14 FAIL=2(N14/N15 红)
# 模拟镜像 /srv 布局,确认 COPY 进容器的代码同样正确
rm -rf /tmp/srv && mkdir -p /tmp/srv/reports
cp -r app conf input docs client ops /tmp/srv/ && cd /tmp/srv
bash ops/repro.sh ; echo rc=$? # RESULT: OK,rc=0
bash ops/verify.sh # PASS=39 FAIL=0 / RESULT: OK
bash ops/negative.sh # PASS=16 FAIL=0 / RESULT: OK
三、根本原因
问题不在 Broker,在审计模块自己算错账,又把错误藏进绿灯。9 个计算题眼 + 1 个假绿机制:
| # | 位置 | 错误 | 契约要求 |
|---|---|---|---|
| 1 | contract.py::load | 读不到契约 except OSError: pass,用 ENGINE_DEFAULTS 兜底 | fail-closed,rc=3 |
| 2 | audit.py::_session_expiry | 取 engine_defaults.session_expiry_seconds(60s) | 取顶层 session_expiry_seconds(600s) |
| 3 | audit.py::_sessions | persisted = connected or expired | connected and not expired |
| 4 | _subs / effective_qos | granted_qos=-1 被拒订阅照样计入,effective_qos=-1 | 被拒订阅不生效 |
| 5 | audience | setdefault 取第一条订阅 qos,重复订阅重复计数 | 取最大 granted_qos,每客户端每条只算一次 |
| 6 | 通配符/受众 | 空层级与 +/# 语义错,# 客户端重复计入 | + 不跨 /;# 匹配零层或多层;a//b 空层合法 |
| 7 | _retained | 不判 payload_len=0 清除;受众不分 clean_session | 零长度 retain = 清除;只算 clean_session=0 |
| 8 | build | effective_qos 不夹 max_qos;比较用原始 granted_qos | 一律 min(granted_qos, max_qos) |
| 9 | 传输段 | 半帧 sent_bytes 也计;重发判据含糊 | 只计 frame_complete=1;只看末次 ack_ok |
| 10 | health.py | BAD_FIELDS=() 空判据表 + 复用 audit + 恒 return 0 | 非空判据 + 独立重算 + 分层退出码 |
假绿的完整链路:
报告算错 → 健康检查复用 audit 的计算 → 照错的再算一遍"一致" → 报绿
↘ 判据表是空元组,issues 恒 [] ↗
↘ return 0 写死,退出码分层形同虚设 ↗
一句话:同源即自证,自证必假绿。
四、解决办法
改动只在 app/(conf/ docs/ input/ client/ ops/ 一行未动)。
1. contract.py fail-open → fail-closed
class ContractError(Exception): ...
REQUIRED = {
"now": lambda v: isinstance(v, int) and v >= 0,
"session_expiry_seconds": lambda v: isinstance(v, int) and v > 0,
"max_qos": lambda v: isinstance(v, int) and 0 <= v <= 2,
"retain_handling": lambda v: isinstance(v, int) and v in (0, 1),
"engine": lambda v: isinstance(v, str) and v != "",
}
def load(path=CONTRACT_PATH):
try:
with open(path, "r", encoding="utf-8") as f:
conf = json.load(f)
except OSError as e:
raise ContractError("契约不可用: %s" % e) # 不再静默回退默认值
except ValueError as e:
raise ContractError("契约不是合法 JSON: %s" % e)
return validate(conf)
关键动作:删掉 ENGINE_DEFAULTS 兜底分支。
2. audit.py 按契约重算
# 会话:过期只看业务口径,persisted 必须是 connected 且未过期
expired = (now - c["last_seen"]) > conf["session_expiry_seconds"]
persisted = c["connected"] and not expired
queued_count = 0 if (expired or c["connected"]) else 1
# 订阅:被拒订阅在加载阶段就丢弃,后面处处少一个 if
if loaders.as_int(r["granted_qos"]) < 0: continue
# 受众:dict + max 天然完成「同客户端去重 + 只抬 QoS」
hit[cid] = max(hit.get(cid, -1), row["granted_qos"])
# 保留:先取每主题 pub_seq 最大的 retain=1,再滤掉 payload_len=0(清除)
latest = {t: p for t, p in latest.items() if p["payload_len"] > 0}
# 有效 QoS:夹 max_qos,过期会话强制 0
sess["effective_qos"] = 0 if sess["expired"] else min(max(granted), cap)
# 传输:半帧不计字节,未投递只看末次尝试
sent_bytes = sum(t["sent_bytes"] for t in trans if t["frame_complete"])
undelivered = sum(1 for rows in groups.values()
if not sorted(rows, key=lambda x: x["attempt"])[-1]["ack_ok"])
3. health.py 独立重算 + 非空判据表
def recompute(conf):
"""在 health 内部重新读 input/*.tsv 重算,不 import audit 的任何推导。"""
...
def compare(expected, report):
"""逐字段比对 totals/deliveries/transport/sessions[*]/per_topic[*]/ok,条数与值都查。"""
...
CHECKS = 11 # 非空:7 个统计字段 + 4 个语义字段
判定语义自己写 ok = mismatches == 0 and undelivered == 0 and queued == 0,不读报告里的 ok 当依据。
4. run.py / service.py 退出码分层
run.py:契约异常 → 3;事实不可用 → 2;正常出报告 → 0(契约第 0 节 +verify.shA1 要求)。service.py:跑run再跑health,退出码取health的结论;契约异常优先保留 3。 于是python3 -m app.service从"恒 0"变成"如实 rc=2"。
五、验证
cd scenario/2026-10-26-0002
bash ops/verify.sh ; echo rc=$? # PASS=39 FAIL=0 TOTAL=39 / RESULT: OK
bash ops/negative.sh ; echo rc=$? # PASS=16 FAIL=0 TOTAL=16 / RESULT: OK
bash ops/repro.sh ; echo rc=$? # 探针 RESULT: OK / rc=0
修复前后关键账:
| 指标 | 修复前 | 修复后 |
|---|---|---|
deliveries.matches | 10 | 7(重复订阅去重) |
deliveries.mismatches | 5 | 5(真问题,如实报出) |
deliveries.retained_targets | 3 | 2(只算 clean_session=0) |
deliveries.qos_downgraded | 2 | 1(夹 max_qos 后被拒订阅不再误算) |
transport.sent_bytes | 61 | 52(半帧不计) |
edge-01 expired(静置 200s) | True(误用 60s) | False(用 600s) |
edge-06 effective_qos | -1 | 0(被拒订阅不生效) |
retain=1 且 payload_len=0 | retained=True | False(清除语义) |
app.service rc | 0(假绿) | 2(如实报警) |
| 契约缺失/非法 | 静默回退 | rc=3(fail-closed) |
假绿通道实测已堵死:
# 篡改自述字段 ok
python3 -c "import json;p='reports/audit.json';d=json.load(open(p));d['ok']=not d['ok'];json.dump(d,open(p,'w'))"
python3 -m app.health ; echo rc=$? # rc=2(不再自证健康)
# 篡改统计值
python3 -c "import json;p='reports/audit.json';d=json.load(open(p));d['transport']['sent_bytes']=61;json.dump(d,open(p,'w'))"
python3 -m app.health ; echo rc=$?
# {"degraded": true, "issues": ["transport.sent_bytes: 期望 52 实际 61"], "rc": 2}
fail-closed 全路径实测:
| 场景 | 实测 rc |
|---|---|
契约文件缺失(health/run/service) | 3 / 3 / 3 |
max_qos=9 越界 | 3 |
缺 session_expiry_seconds | 3 |
| 契约不是合法 JSON | 3 |
| 报告缺失 | 2 |
报告一致但 ok=false | 2 |
| 报告被篡改 | 2 |
合规性:git diff --stat -- conf docs input client ops 为空,考卷与探针一行未动。
六、经验与避坑
- "四盏绿灯"常常是同一根因的四个侧面。退出码恒 0 + 判据表空 + 健康检查复用被测代码 + 报告字段齐全,各自看都像"没事",合起来是系统性假绿。先找共同上游,别逐条修表象。
- 健康检查一旦复用被测代码,就不再是检查。它是"用同一套可能错的推导再得出同一个错结论"。 必须重新读原始事实、独立重算。
- 空判据表 = 恒绿。
BAD_FIELDS = ()/CHECKS = []这种"预留位"是假绿经典形态, 加断言:判据表长度必须 > 0。 - fail-open 是审计系统的头号毒药。读不到契约就回退"引擎默认值",
会把
session_expiry_seconds从 600 悄悄换成 60,一批活跃会话被判过期,日志一句报错都没有。 engine_defaults这类"构建期常量"最容易冒充业务口径。同一份 JSON 放两套语义相近的数值, 谁先被conf.get()撞上谁生效 —— 取值要看路径,不是"能取到值就算对"。- "每条消息每个客户端只交付一次"用数据结构保证,别靠计数字段。
dict[client_id] = max(qos)一次解决重复订阅、多过滤器命中、#与+同时命中三种重复来源。 payload_len=0的 retain 是"清除"不是"空消息"。漏判会让已删除的保留值继续投给新订阅者。- 半帧字节不能算已发送。
frame_complete=0表示物理帧被丢弃,判据要绑frame_complete。 +不跨/,空层级合法:a//b是三层(中间空串),a/+/b必须命中。 用split("/")而非strip("/").split("/"),否则空层级被吃掉。- 被拒订阅(
granted_qos=-1)是"不存在的订阅",不是"qos 为 -1 的订阅"。加载阶段丢弃最稳。 - 负向验证比正向更有价值。12 个单点回滚变异体证明每条语义都被独立断言覆盖, 避免"改完绿了但只是碰巧撞对答案"。闸门恒绿和恒红一样不可信,中性对照 N0 用来排除恒红。
- 退出码语义一次定清。
app.run恒 0(只表示"报告写出来了")与app.service恒 0(假绿) 是两件事:前者可能合规,后者一定违规。 repro.sh的 rc 是探针 rc,不是健康 rc。问题态 rc=1、修复后 rc=0, 用; echo rc=$?接住别用&&。- 改完先清
__pycache__、Docker 场景先up -d --build。app/是COPY进镜像的,没重建就是跑旧代码 —— "改了没生效"十次有九次是这个。 - 统计口径要能被外部独立复算。
client/audit_probe.py不 import 被测代码、不读自述字段, 只按契约重算,才能当"唯一说真话的"裁判。
