重构记录:博客通知系统改造:统一闸门、本地静音与攻击告警

2026年9月10日 blogTech 21 分钟阅读
📖 文章摘要

本地调试被 CPU 告警邮件轰炸,站点被扫却一封通知都没有。我把散落的 6 个发信点收敛成统一闸门,补上环境静音与攻击告警,顺手修掉总开关失效、时间差 8 小时和邮件头注入。

重构记录:博客通知系统改造:统一闸门、本地静音与攻击告警

作者:leap
日期:2026-09-10
个人博客:https://vue2.xyz


前言

我的博客后台有一套邮件通知:新评论、新设备登录、登录失败、网盘被下载、服务器资源告警。功能看着是全的,但它这半年一直往两个相反的方向惹我——本地起服务调试时,CPU 和内存的告警邮件一封接一封;而真被扫描器扫的时候,它安静得像根本没装。这篇记录我把这 6 个散落的发信点收敛成一套闸门、补上环境静音与攻击告警的全过程。

一、先看清旧版到底出了什么问题

旧版的发信点一共 6 处,每处各写各的 HTML 模板、各查各的开关:

场景 位置 检查总开关? 类型开关 冷却
新评论 评论提交 检查 comment_notify_enabled
评论审核通过 后台审核 检查 上者 + comment_notify_pending_only
新设备登录 登录成功 检查 new_device_notify_enabled
连续登录失败 登录失败 检查 login_failure_notify_enabled
网盘被下载 下载接口 检查 pan_notify_enabled 30 秒去重
服务器资源告警 定时线程 不检查

一眼就能看到问题:只有资源告警那一行没检查总开关。它的判断条件写的是"配置了收件邮箱就发",完全不看 notify_enabled。也就是说我把后台的「启用通知」关掉,CPU / 内存 / 磁盘的告警照样往邮箱里灌——这就是本地调试被轰炸的真正原因,我一直以为是"监控太敏感",其实是我关的那个开关根本没被读。

顺着往下查,又翻出三个问题:

  1. 时间差 8 小时。登录类告警的模板里直接展示 datetime.now(timezone.utc),我收到"新设备登录"的邮件,时间看着像 8 小时前发生的;
  2. 模板插值没转义。登录失败告警把请求里的 username 直接拼进 HTML——攻击者可以用一个恶意用户名,往我的告警邮件里塞钓鱼链接;
  3. 本地跑 systemctl。服务无响应时自动重启的逻辑在 Windows 本地也照跑,每轮检查刷一条注定失败的错误日志。

而我最想补的那个能力,恰好完全没有:被攻击时不会通知。路径黑名单扫描、恶意 UA 拦截、IP 封禁,这三条拦截链路全是 403 了事,一条通知都没有。我能知道有人爆破后台(登录失败会发信),但有人扫我整站,我毫不知情。

二、第一层:统一模板层,域名只从环境变量来

改造的第一个决定:不再有第二份模板

原来的 6 个模板各有各的字段,有的带后台链接,有的连时间都没有,全都缺站点名和域名。代码重复倒在其次,真正的麻烦是——我改一次样式要改 6 个地方,改漏一个就是"有个邮件长得不一样"。

所以先抽一个渲染函数出来:

def build_email(title: str, rows, *, ctx: dict, action=None,
                level: str = 'info', intro: str = '') -> str:
    """构造统一邮件 HTML。

    ctx 由 utils.site.get_site_context(db) 提供(站点名 / 域名 / 环境标识)
    rows 是 (标签, 值) 序列;值默认 HTML 转义,要放链接请用 link()
    """
    color, level_label = _LEVELS.get(level, _LEVELS['info'])

    tr_html = []
    for label, value in rows:
        cell = str(value) if isinstance(value, SafeHtml) else html_lib.escape(str(value))
        tr_html.append(f'<tr><td>{html_lib.escape(str(label))}</td>'
                       f'<td>{cell}</td></tr>')
    # …… 头部站点名 + 等级徽标 + 环境徽标,正文表格,按钮,页脚

原理:所有会变的内容默认走 html_lib.escape。需要放链接的场景,用一个 SafeHtml 标记类隔离:

class SafeHtml(str):
    """由本模块构造、已完成转义的 HTML 片段;build_email 不再二次转义。"""
    __slots__ = ()


def link(text, url) -> SafeHtml:
    return SafeHtml(f'<a href="{html_lib.escape(str(url), quote=True)}">{html_lib.escape(str(text))}</a>')

优势:调用方"想把链接塞进邮件"时,只能通过 link() / lines() 这两个出口,而它们自己会转义。也就是说转义漏不了——因为除了这两个函数,没有任何办法构造出 SafeHtml。我不用再靠"记得写 escape"这种纪律来保证安全。

域名则是另一件事,我给自己定了条硬规则:业务代码里不允许出现域名。所有域名、站点名、站内链接都从一个地方取:

# utils/site.py —— 域名/站点名的唯一出口
from config import ENV_LABEL, IS_LOCAL_ENV, SITE_URL

def site_host() -> str:
    """站点域名(不硬编码,取自环境变量 SITE_URL)"""
    return urlsplit(SITE_URL).hostname or SITE_URL

def admin_url(path: str = "") -> str:
    return f"{SITE_URL}/admin{path}"

SITE_URL 是环境变量:本地启动不注入,默认就是 http://localhost:3000;服务器上由 systemd 注入真实域名。换域名不用改一行业务代码

三、第二层:统一闸门,一条路径决定发不发

模板统一了,但"该不该发"还是散在 6 个地方。这是更要命的一半——旧版的坑就出在这儿。

我把判断收敛成一个函数:

def maybe_send(cfg, kind, subject, html, *, to='',
               cooldown_sec=0, cooldown_key='') -> str:
    """统一发送闸门,返回 SENT / SKIPPED / FAILED。"""
    recipient = to or cfg.get('notify_email', '')
    if not recipient:
        return SKIPPED
    if str(cfg.get('notify_enabled', '')).lower() != 'true':
        return SKIPPED                      # 总开关:取消默认=关
    if is_env_muted(cfg, kind):
        return SKIPPED                      # 环境静音
    key = cooldown_key or f'{kind}:{subject}'
    if cooldown_active(key, cooldown_sec):
        return SKIPPED                      # 冷却期内
    if rate_limited(cfg):
        return SKIPPED                      # 全局限流
    if not send_email(recipient, subject, html, cfg):
        return FAILED
    _cooldown[key] = time.time()
    return SENT
flowchart LR
    A["各场景触发"] --> B{"总开关 + 类型开关"}
    B -->|关| X["丢弃"]
    B -->|开| C{"环境静音"}
    C -->|本地且命中类别| X
    C -->|放行| D{"冷却 / 限流"}
    D -->|命中| X
    D -->|放行| E["send_email()"]

原理:闸门顺序是刻意的——收件人 → 总开关 → 环境静音 → 全局限流 → 冷却 → 实发。总开关放在最前面,是为了让"关掉通知"这件事真的意味着全都不发(旧版就栽在这一步)。冷却和限流放最后,因为它们依赖前面的判定结果,也最便宜。

优势:新增一个发信场景时,我只需要组装内容、调一次 maybe_send,四道闸门自动生效。再也不可能出现"某个场景忘了检查总开关"这种事——因为没有任何一条绕过闸门的发送路径。(之前那个坑,本质上是"闸门有 6 份实现",那我漏掉一份是迟早的事。)

环境静音则是我这次最想要的东西。判定不引入新配置,直接看已有的环境变量:

# config.py
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "0.0.0.0", "::1", ""}

def _is_local_site_url(url: str) -> bool:
    host = (urlsplit(url).hostname or "").lower()
    return host in _LOCAL_HOSTS or host.endswith(".local")

IS_LOCAL_ENV = _is_local_site_url(SITE_URL)

通知按用途分成四类,本地默认静音前两类:

类别 覆盖场景 本地默认
resource CPU / 内存 / 磁盘超阈值 静音
service 后端无响应 / 数据库异常 静音
security 登录失败、新设备、攻击告警 发送
business 新评论、审核通过、网盘下载 发送

原理:本地跑 nuxt dev 时编译、热重载都会把 CPU 顶起来,这类指标在开发机上毫无参考价值,静音掉最省心。而安全类和业务类保留——因为本地调试时我正好需要它们来验证模板渲染对不对。

这里我踩过一个隐性的坑值得单说:环境判定是 config.py 模块加载时算一次的,如果只在前端猜 localhost,就会出现"前后端两处口径"的老问题(这个项目历史上就吃过这个亏)。所以我让后端算好,通过 GET /api/admin/monitor/configdetected_env 回给前端展示,前端不自己判断。

四、第三层:攻击告警,采集与判定必须分离

补上攻击告警是这个改造里最有意思的部分,因为它有一个绕不开的约束:安全中间件跑在每一个请求上

我先试过最直接的做法——拦截到攻击就发一封邮件。写下来就意识到不对:被扫描的时候一秒可能几十个请求,按这个写法,扫描器会直接把我邮箱打成筛子,同时 SMTP 的秒级阻塞会拖垮整个站点。

所以最终拆成两半:

def record_event(kind: str, ip: str, path: str = '', ua: str = '',
                 rule: str = '', detail: str = '') -> None:
    """记录一次拦截事件。

    只能做内存操作——本函数位于请求路径上,任何 IO 都会拖慢全站。
    """
    ip = _safe_ip(ip)
    if not ip or kind not in KIND_LABELS:
        return
    now = time.time()
    with _lock:
        bucket = _buckets.setdefault((kind, ip), _new_bucket(kind, ip))
        bucket['ts'].append(now)
        if path or ua:
            bucket['samples'].append({'t': now, 'path': path, 'ua': ua})

中间件那边只加一行:

if path_is_blocked(path, get_enabled_path_rules(db)):
    _record('path_blocked', ip, path=path, ua=ua, rule='路径黑名单')
    decision = JSONResponse(status_code=403, content={"detail": "Forbidden"})

真正的判定交给一个独立线程,每 60 秒扫一次内存桶:

sequenceDiagram
    participant R as 请求
    participant M as 安全中间件
    participant B as 内存事件桶
    participant T as 守护线程
    participant S as SMTP
    R->>M: 命中路径黑名单
    M->>M: 返回 403
    M->>B: record_event(内存计数,无 IO)
    T->>B: 每 60 秒取一次桶
    T->>T: 窗口内命中数 >= 阈值?
    T->>S: 发一封汇总邮件

原理:请求路径上只做一次内存计数(几微秒),所有 IO——查库、查 IP 归属地、连 SMTP——都挪到线程里。攻击再猛,请求侧的开销也是恒定的。

优势:这个结构顺带解决了"防轰炸"。三层拦在前面:

  1. 窗口聚合:同一 IP 在 5 分钟内命中次数达到阈值(默认 10 次)才发一封,扫描器的零星试探不打扰我;
  2. 同源冷却:同一 IP 30 分钟内只发一封,持续攻击时变成"每半小时一份进展汇总";
  3. 全局限流:所有邮件共用每分钟 5 封的上限,防止攻击告警和评论通知叠加把 SMTP 打爆。

邮件内容也尽量给足——只发"有人攻击你"是没用的,得能直接判断要不要处理:

攻击类型:敏感路径扫描
来源 IP:203.0.113.x(北京 / 联通)
命中次数:37 次 / 5 分钟(告警阈值 10 次)
命中规则:路径黑名单
首次命中:2026-09-10 19:02:11
最近命中:2026-09-10 19:03:48
请求样本:1. 19:03:41  /.env  |  Mozilla/5.0 (compatible; xx/1.0)

阈值这些参数我做成了后台可配,并且在设置页写了每个值的推荐理由(为什么是 10 次、5 分钟、30 分钟),而不是丢一个空输入框让人猜。

还有一个刻意的设计:告警线程不挂在「启用监控」开关下。我关掉资源监控是因为不想收 CPU 告警,但这不代表我连被攻击都不想知道——两件事的开关必须独立。

五、踩坑记录

坑 1:「已通知」标记打早了,告警会被静默吞掉

问题:上线前我找了一轮独立代码审查,揪出一个我自己没意识到的设计错误。我原本是在选出待发列表时就给桶打上"已通知"标记,然后才去发信。

原因:一旦 maybe_send 返回的是"被限流跳过"或"发信失败",标记已经打上了,这个 IP 在 30 分钟冷却期内再打我多少次都不会重试。而分布式攻击下,每分钟 5 封的全局上限很容易被打满——第 6 个来源 IP 就会被静默吞掉。也就是说:攻击规模越大,我越收不到告警。这个 bug 恰好长在这次改造最核心的能力上。

解决:标记只在真正发出之后才打,并且区分三种结果:

result = maybe_send(cfg, KIND_SECURITY, subject, html, cooldown_sec=cooldown_sec)
with _lock:
    if result == SENT:
        bucket['notified_ts'] = now            # 只有真发出去了才算已通知
        bucket['retry_after_ts'] = 0.0
    elif result == FAILED:
        bucket['retry_after_ts'] = now + 300    # SMTP 故障:5 分钟后再试
    # SKIPPED(被限流):什么都不打,下个周期自然重试

坑 2:来源 IP 直接进邮件主题,是个邮件头注入面

问题:还是审查发现的。攻击告警的邮件主题里拼了来源 IP:f"攻击告警:{label}({ip})"

原因:这个 IP 来自客户端可控的 X-Real-IP / X-Forwarded-For,而项目里清洗 IP 的函数只做了一个 strip()。如果 IP 字符串里带上 \r\n,它就会一路进到 msg['Subject']——邮件头部出现换行,就可以注入额外的邮件头。生产环境有 nginx 覆写 X-Real-IP 挡着,但本地直连后端、或者哪天代理配置变了,这个面就露出来了。

解决:入口按 IP 字面量严格校验,非法的直接丢弃,同时给发信函数加一道兜底:

_IP_RE = re.compile(r'^[0-9a-fA-F:.]{1,45}$')

def _safe_ip(raw: str) -> str:
    """只接受规范 IP 字面量,否则返回空串(调用方据此丢弃事件)。"""
    ip = (raw or '').strip()
    if not ip or not _IP_RE.match(ip):
        return ''
    try:
        ipaddress.ip_address(ip)
    except ValueError:
        return ''
    return ip
# 兜底:所有主题都从这里过一道,剔除换行
msg['Subject'] = str(subject).replace('\r', ' ').replace('\n', ' ')

原理ipaddress.ip_address() 是标准库的严格解析,比正则可靠。至于主题的换行剔除——它覆盖的是所有主题来源,而不只是 IP 这一处,属于防呆成本极低、收益明确的那种兜底。

坑 3:部署后的冒烟探测报 API 挂了,其实是竞态

问题:部署脚本收尾会在服务器本机做冒烟探测,那次它报 API 健康检查: HTTP 000(期望 200),但同一批的"前台首页 200""后台接口 401"都是正常的。

原因:探测跑在 systemctl restart 之后、uvicorn 还没绑定上端口的那个窗口里。HTTP 000 不是"服务挂了",是"curl 连不上"——两种完全不同的含义。

解决:先看服务状态、再复测,别急着回滚:

systemctl is-active blog-api blog-web
curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8001/api/health   # 200
journalctl -u blog-api -n 25 --no-pager -o cat                            # Application startup complete

复测确认服务正常、日志显示启动完成且无异常,才判定是误报。这条经验我记进了项目笔记——"000"和"500"要当成两件事处理,前者多半是连接问题(进程没起/端口不通),后者才是服务内部出错。

六、新旧对比

维度 改造前 改造后
模板 6 份各自手写,字段不一 1 个 build_email(),统一带站点名/域名/环境/按钮/页脚
域名来源 部分硬编码在模板字符串里 只从环境变量 SITE_URL 读,utils/site.py 是唯一出口
总开关 5 处生效,资源告警绕过 唯一闸门,全部场景生效
冷却是 各发信点自己维护 dict 闸门内统一维护,口径一致
本地调试 与生产无差别,被资源告警轰炸 默认静音资源+服务类,可在后台改
被攻击时 无任何通知 路径扫描/恶意 UA/自动封禁三类汇总告警
时间显示 登录类告警差 8 小时 统一中国时区
插值安全 未转义,可注入 默认转义 + SafeHtml 隔离出口
发信总量 无上限 每分钟 5 封全局上限

七、总结

  • "6 份实现"本身就是 bug 的温床。总开关失效这个坑,根因不是写错一行判断,而是同一件事有 6 个地方各自实现——漏掉一份是迟早的事。收敛成唯一入口之后,这类错误结构性地不可能再发生。
  • 闸门顺序是有意义的。总开关必须排在所有条件之前,否则"关掉通知"这个语义就是不完整的。改造前资源告警把总开关放在条件之后(准确说是根本没放),于是它成了唯一不听指挥的那个。
  • 请求路径上不能有 IO。攻击告警如果按"拦截即发信"写,攻击者用一百个请求就能让我邮箱瘫痪。采集(内存计数)与判定(线程聚合)分离,是这类"高频事件 + 慢速副作用"场景的通用解法。
  • "已通知"标记要对齐"真的发出去了"。这类"提前打标记"的错误很隐蔽:功能自测正常,只有在限流或下游故障的叠加态下才暴露,而那个时刻恰好是你最需要它工作的时候。
  • 任何被拼进邮件头/日志/HTML 的外部输入都要先校验。来源 IP 这种"看起来就是个字符串"的东西,恰恰来自客户端可控的请求头;HTML 转义只能救正文,救不了邮件头。
  • 部署后的健康检查要给服务一点启动时间。HTTP 000 与 5xx 是两个完全不同的问题,先看 is-active 和启动日志再下结论,能避免一次没必要的回滚。

文档版本:v1.0
最后更新:2026-09-10
作者:leap
个人博客https://vue2.xyz

文章创建于:2026年9月10日CC BY-NC-SA 4.0

评论