Air780E 多卡短信中枢(四):通知引擎、认证与验证码安全边界

最后更新:2026-08-04
所属系列:Air780E 多卡短信中枢实战 · 第 4 / 6 篇
文章目录 15 个章节

短信转发功能看起来像一道简单流水线:收到短信,匹配规则,调用一个 Webhook。真正上线后才会发现,通知服务商的“成功”定义各不相同,规则之间会重复命中,错误日志可能泄露 Bot Token,而部署在反向代理后面的 Cookie 和时区也都有自己的陷阱。

更重要的是,这个系统处理的是验证码。安全不是最后加一个登录页,而是从数据模型、默认行为、日志、备份到公开仓库的整条边界。

一、Server 为什么保持单进程、单端口

Server 同时提供三类能力:

  • /ws:Agent WebSocket 网关;
  • /api/*:管理端 REST API;
  • /:构建后的 React 静态页面。

它们由同一个 FastAPI 进程和一个端口提供。对于这类小规模自托管系统,这比拆成多个服务更合适:

  • 认证与配置只有一份;
  • Docker 只持久化一个数据卷;
  • 反向代理只转发一个上游;
  • 备份就是 SQLite 一致快照;
  • 少一个服务,就少一套启动顺序、健康检查和故障模式。

并发主要来自 WebSocket、HTTP 推送和少量 REST 请求,远没有到必须引入分布式组件的程度。

二、通知模型:渠道和规则必须分开

一个“渠道”回答推到哪里:

Bark / Telegram / 飞书 / 企业微信 / 钉钉 / POST / GET / SMTP

一条“规则”回答什么短信要推:

适用 SIM + 匹配方式 + 模式 + 渠道 + 模板 + 优先级

拆开以后,一个 Bark 渠道可以被多条规则复用,改 Token 不需要逐条改规则;同一条短信也可以匹配多个不同渠道。

系统默认没有规则就不推送。相比“新增渠道后自动转发全部短信”,这是更安全的默认值:用户必须明确选择哪些消息可以离开系统。

三、多规则命中同一渠道,只能推一次

假设有两条规则:

全部短信 → Bark
包含“验证码” → Bark(使用验证码模板)

一条验证码短信会同时匹配两条。如果简单遍历规则逐条发送,手机会收到两次通知。

最终规则引擎先按渠道分组,再在每个渠道中选择优先级最高的匹配规则:

匹配规则
  → 按 channel_id 分组
  → 每组取最高 priority
  → 每个渠道发送一次

这样“全部短信”可以作为兜底,“验证码”规则只负责覆盖模板,不会制造重复推送。

其他两个边界也值得锁死:

  • 错误正则只跳过当前规则,不能阻断其他规则;
  • 关键词为空代表不匹配,不代表匹配全部。

表单未填完时,系统宁可少推,也不能突然把全部验证码转发出去。

四、模板渲染要与真实投递共用实现

通知模板支持:

{message} {sender} {card} {timestamp} {device} {iccid}

这里有两个容易忽略的问题。

正文里的花括号不应该再次解析

如果先把正文插进模板,再对整个结果执行格式化,短信正文里的 {code} 可能被当成模板变量,甚至触发异常。

正确做法只扫描原始模板中的占位符,替换一次。正文只是值,不再进入模板解释器。

未知占位符保留原样,而不是悄悄替换为空。这样 {mesage} 这类拼写错误能在预览里直接看见。

预览不能另写一套“差不多”的逻辑

通知页提供规则调试器:输入 SIM、发件号码和短信正文,展示将命中的渠道、标题和正文,但不访问服务商。

预览与真实发送调用同一个:

规则匹配 → 渠道去重 → 上下文生成 → 模板渲染

如果预览另写简化实现,它很快会和生产路径发生差异,最后变成一个会误导用户的假测试。

五、HTTP 200 可能是明确失败

多个通知服务商即使业务失败,也会返回 HTTP 200:

  • Telegram:ok == true 才成功;
  • 企业微信、钉钉:errcode == 0
  • 飞书:code == 0StatusCode == 0
  • Bark:code == 200

例如钉钉机器人配置了关键词安全策略,但正文不含关键词时,HTTP 层仍可能成功,响应体却告诉你:

errcode 310000: keywords not in content

如果通知引擎只检查 response.is_success,日志会显示“已发送”,而用户什么都收不到。

因此适配一个服务商至少要定义:

  1. 请求格式;
  2. HTTP 成功范围;
  3. 业务成功字段;
  4. 可展示的错误字段;
  5. Token 可能出现在哪个位置;
  6. 是否允许重试。

这是通用经验:

第三方 API 的成功语义属于业务协议,不属于 HTTP 协议。

六、重试不是所有场景都一样

真实短信投递按渠道独立重试,默认共三次,并做短暂退避。一条渠道失败不能拖累其他渠道。

但界面上的“测试渠道”按钮只发一次,不重试。原因是人在等待结果,此时最有价值的是服务商当前返回的原始错误,而不是十几秒后得到一个被重试包装过的结果。

SMTP 使用 Python 标准库 smtplib,它是阻塞接口。为了不堵住 FastAPI 与 WebSocket 共用的事件循环,发送放到线程池执行。异步服务里只要混入一个阻塞 DNS、SMTP 或文件操作,就可能把“单个渠道慢”放大成“所有 Agent 心跳超时”。

七、时区在 slim 镜像里不是理所当然

开发机上 ZoneInfo("Asia/Shanghai") 正常,换到 python:3.12-slim 后却可能抛出 ZoneInfoNotFoundError,因为精简镜像没有系统时区数据库。

解决方式不是写死 UTC+8,而是显式依赖 tzdata,同时在时区加载失败时记录警告并退回 UTC。

写死偏移会在有夏令时的地区制造新 bug。时区名称是配置,时区数据是运行时依赖,两者缺一不可。

八、认证的两个隐蔽坑

系统采用单管理员密码和服务端 Session,不提供免密开关。密码通过 scrypt 派生后存储,Session Cookie 只保存随机令牌。

scrypt 的内存参数撞上 OpenSSL 默认上限

选定的 scrypt 参数理论上合理,却刚好碰到 OpenSSL 默认约 32 MiB 的内存限制。表现为密码设置或验证时报底层错误。

修复是根据参数显式设置足够的 maxmem,而不是降低到一个未经评估的弱参数。密码哈希函数的成本参数必须在目标运行时和容器里实测,不能只在文档里算。

生产环境经过 HTTPS 反向代理,Cookie 应带 Secure;但局域网可能直接用 HTTP 访问。

如果无条件设置 Secure,浏览器在 HTTP 下不会回传 Cookie,用户表现为“登录成功后立刻又回登录页”。

最终根据请求实际 scheme 决定,同时要求反向代理正确传递 X-Forwarded-Proto。这说明反向代理不是部署文档里的附录,它会直接影响应用层安全判断。

九、日志里绝不能出现短信正文

应用日志和通知审计日志面向不同读者,但都不应该保存验证码正文。

Agent 只记录类似:

modem-a received SMS from 10086 (42 chars)

通知日志只保存:

  • 渠道;
  • 成功或失败;
  • HTTP 状态;
  • 尝试次数;
  • 服务商错误文本。

请求 URL 也要清理。Telegram Bot Token、企业微信 key、钉钉 access token 经常直接位于 URL 路径或 query 中。如果把完整 URL 写入错误详情,等于把凭据展示在后台日志页面。

这条边界用回归测试固定:测试消息正文包含一个独特验证码,然后断言数据库日志和 Python 日志都不存在它。安全约束如果只写在文档里,很容易在一次“方便排错”的改动中被破坏。

十、数据保留、备份和恢复是同一件事

中心 SQLite 包含:

  • 短信正文;
  • 管理员认证状态;
  • Agent Token;
  • 通知渠道凭据;
  • 任务与日志。

因此备份不是普通业务导出,而是一份完整敏感数据副本。系统提供 SQLite 快照下载和恢复,但部署者仍需要:

  • 限制数据目录与备份文件权限;
  • 对异地备份加密;
  • 设置短信保留期;
  • 定期做恢复演练,而不是只看备份文件存在;
  • 恢复前校验上传文件确实是预期数据库。

TTL 清理和备份并不矛盾:TTL 降低在线数据库暴露面,备份策略决定历史副本保留多久。只清在线库、永久保存每份旧备份,等于没有真正执行数据最小化。

十一、公开部署的安全默认值

Docker Compose 默认把 HTTP 发布到回环地址,而不是全部网卡:

127.0.0.1:8090 → container:8080

公网入口由可信反向代理提供 HTTPS / WSS,并正确转发 WebSocket Upgrade。其他基线包括:

  • Agent Token 与管理密码不进仓库;
  • Agent 配置权限设为 0600
  • 容器不使用 host network;
  • Web 管理端所有业务 API 都要求会话;
  • 原始 AT 命令只允许已认证管理员使用;
  • 不提供公开短信分享链接。

安全设计最重要的不是堆功能,而是让默认路径很难犯错:默认不转发、默认不免密、默认不监听公网、默认不记录正文。

下一篇会讲 Web 界面如何从“后台 CRUD 表格”变成真正可用的短信工具,以及公开仓库前为什么还要审计截图、日志、Git 历史和提交身份。