短信转发功能看起来像一道简单流水线:收到短信,匹配规则,调用一个 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 == 0或StatusCode == 0; - Bark:
code == 200。
例如钉钉机器人配置了关键词安全策略,但正文不含关键词时,HTTP 层仍可能成功,响应体却告诉你:
errcode 310000: keywords not in content
如果通知引擎只检查 response.is_success,日志会显示“已发送”,而用户什么都收不到。
因此适配一个服务商至少要定义:
- 请求格式;
- HTTP 成功范围;
- 业务成功字段;
- 可展示的错误字段;
- Token 可能出现在哪个位置;
- 是否允许重试。
这是通用经验:
第三方 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,而不是降低到一个未经评估的弱参数。密码哈希函数的成本参数必须在目标运行时和容器里实测,不能只在文档里算。
Cookie 的 Secure 不能脱离实际请求判断
生产环境经过 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 历史和提交身份。