审计日志最大的问题是:它一直存在,但没人会主动去看。
狸花云的审计表从 v6 起就在记——谁改了用户、谁动了容器、谁触发了重启。数据是齐的,可它只在后台页面里堆着,等着你去翻。而真正需要它的时刻,往往是你不想开电脑的时刻:凌晨三点有人动了某个容器,第二天你在路上,只想用手机看一眼”昨天到底发生了什么事”。
v7.20.4 做的就是这件事:每天 09:00,一封 HTML 邮件,把近 24 小时的审计记录汇总好,发到 contact.email。
一、三条不能破的约束
在动手之前先定了三条线,后面的所有实现都是围绕它们做的取舍:
1. 不引依赖。node_modules 已经够重了,而这个项目的邮件模块从做注册验证码那一版开始就是零依赖——自己开 TLS socket 说 SMTP 协议。
2. 失败不能影响主流程。日报是后台任务,它坏了,用户下单、开机、连终端都不能受任何影响。
3. 同一天不能发两封。重复的报表比没有报表更烦人。
二、定时为什么不用 node-cron
需求只是”每天一次”,为此加一个 cron 库不划算。实际写法是:每 30 分钟醒一次,看一眼现在几点。
setInterval(async () => {
const now = new Date()
if (now.getHours() !== 9) return // 本地 9 点
const today = now.toISOString().slice(0, 10) // UTC 日期
const st = loadAuditMailState()
if (st.lastSent === today) return // 去重靠状态文件
const r = await sendAuditDigest(24)
saveAuditMailState({ lastSent: today, lastResult: r })
}, 30 * 60 * 1000)
三个细节值得说:
去重靠一个状态文件,不是内存变量。data/audit-mail-state.json 里存 lastSent,进程重启之后照样知道今天发过没有——内存变量在容器重启面前一文不值。
首次部署会多一封。如果状态文件不存在(新装的机器),启动 60 秒后先试发一封。原因很实际:不然你得等到第二天早上 9 点,才知道 SMTP 配错了没有。
时区是个已知取舍。判断”几点”用本地小时,去重键用 UTC 日期。服务器是 UTC+8 的时候两者一致,一切正常;哪天有人把容器 TZ 改成 UTC,这封信就会在北京时间 17 点发出。这是写在代码里的取舍,不是漏掉的 bug——但它确实是个需要记住的坑。
三、报表长什么样
统计区间、总数、失败数,然后按类型和按用户各一张表,最后是失败明细,最多 30 条。摘要部分是纯文本 + HTML 双份。
有一个容易被忽略的边界:这个时段如果没有记录,就不发。
if (!rows.length) return { ok: true, skipped: '该时段无审计记录' }
听起来理所当然,但第一版我们差点发了封”记录数 0,失败 0″的空表。一封每天准时到达、内容永远是零的邮件,两周之后就会被扔进垃圾箱——而它本该是异常信号。
四、手搓 SMTP 的六个坑
这是整件事里最花时间的地方。协议本身不复杂,难的是那些”文档里不写、失败时不告诉你原因”的细节。
| 坑 | 症状 | 处理 |
|---|---|---|
| 多行应答 | 一次 data 事件就当完整回复,EHLO 的 250- 续行被误判结束 |
按行切,最后一行匹配 ^\d{3} 才算读完整 |
| 中文主题 | 客户端显示一串问号或直接乱码 | Subject: =?UTF-8?B? |
| 点填充 | 正文里有以 . 开头的行,服务器提前判定 DATA 结束,信被截断 |
行首 . 写成 ..,结尾单独一行 . |
| 双体裁 | 只发 HTML,部分客户端显示空白 | multipart/alternative,plain 在前、HTML 在后 |
| 缺 Message-ID | 大量进垃圾箱,且难追查 | 补 Date(toUTCString)与 Message-ID: |
| 465 与 587 | 587 端口直接上 TLS,握手失败 | 465 隐式 TLS;587 先明文 EHLO,见 STARTTLS 再升级 |
其中”点填充”最阴:
const safe = data.split('\r\n').map((l) => (l.startsWith('.') ? '.' + l : l)).join('\r\n')
socket.write(safe + '\r\n.\r\n')
不转义的话,正文里任何一行以点开头,服务器都会认为信写完了——你的邮件会在那句话之后被整齐地切掉,而且返回码还是 250。你以为发出去了。
多行应答那个坑则是另一回事:SMTP 用 250- 表示”还有下一行”,250 表示”到此为止”。不区分这两种,EHLO 之后拿到的就是一串半截文本,后面所有状态判断全错。
五、它值多少
这封信不漂亮。没有仪表盘、没有图表、没有实时刷新,只是一封排版朴素的 HTML 表格邮件。
但它有一个别的界面都没有的性质:它会在你不主动的时候出现。
监控的价值从来不在”能查到”,而在”不用查也知道”。审计表已经记了几个月,真正被人读完的次数,一只手数得过来。现在它每天早上自己走过来,说一句昨天发生了什么。
代价是一封邮件、一个状态文件、和六个被协议教育过的夜晚。这买卖不亏。