希曼日记

给静态博客加一个催更按钮:ntfy 接入、IP 去重与 Docker 部署

约 13 分钟5,083 字

博客一直由 Astro 构建成静态文件,再交给 Nginx 提供服务。这次想加一个「催更一下」按钮:不用登录,同一 IP 在全站 12 小时内点多少次都只算一次,有新的有效催更就通过自建 ntfy 通知作者。

按钮本身不复杂,麻烦的是它背后的两个承诺:不能重复计数,也不能因为读者多点几次就反复推送。浏览器里的禁用状态只能改善操作体验,兑现这两个承诺需要服务端状态。

最终保留了静态站,只增加一个 Python 标准库服务,用 SQLite 存储计数和去重记录。本文按这次实际实现整理;域名、镜像仓库、通知主题和部署路径示例均已替换,示例凭据不能直接使用。

先确定「一次」的含义

这里采用的是全站、按 IP、从首次有效点击起计算的 12 小时窗口,不是按文章分别计数,也不是每天固定时刻清零。

例如,同一个 IP 上午 9 点第一次点击,计数加一,并尝试向 ntfy 发布一次通知。下午 2 点切到另一篇文章再点,不增加计数,也不推送;窗口仍在晚上 9 点到期,不会因为下午的点击向后延长。晚上 9 点及之后再次点击,才算新的一次。

这个规则不等于识别了一个人。同一 Wi-Fi 或公司出口下的读者可能共享 IP,因此共享窗口;切换网络或更换 IP,则可能再次计数。当前不接入身份与访问管理(IAM),接受的是这个近似口径,而不是严格的「每人一次」。

累计计数也不是独立访客数:同一个 IP 在不同窗口内可以贡献多次。

静态站保留,新增一个小接口

请求路径如下:

Text
浏览器中的催更按钮
│ POST /api/remind
▼
Traefik 公网入口
▼
博客 Nginx
│ 覆盖 X-Real-IP,转发同源请求
▼
reminder:8080(不发布宿主端口)
├── SQLite:全站计数、IP 哈希、窗口截止时间、通知状态
└── ntfy:只向指定主题发布通知

Astro 不需要切换到服务端渲染。静态页面中的脚本请求 /api/remind,Nginx 把这个精确路径转给新服务,其余路径继续按静态文件处理。

也不让浏览器直接请求 ntfy。直接请求会把发布凭据交给访客,而且无法可靠地执行全站 IP 去重。ntfy 的访问权限和浏览器是否允许跨域,是两件不同的事;不能把 CORS 当成凭据保护。

实现分布在以下文件中:

文件职责
src/components/UpdateReminder.astro按钮、提交状态和结果文案
src/components/SiteFooter.astro阅读页面的共用入口
src/pages/index.astro首页入口
server/reminder.pyHTTP 接口、SQLite 去重和 ntfy 发布
server/test_reminder.py并发、过期、重启和异常测试
server/Dockerfile非 root 服务镜像
compose.yml、nginx.conf容器网络、数据卷和反向代理
.github/workflows/ci.yaml测试、构建镜像和部署

ntfy:复用现有服务,给催更单独发凭据

这次接入的是已经运行在 Docker 中的 ntfy,部署时版本为 v2.11.0。它已启用认证,匿名访问默认拒绝,没有为了催更重新搭建一套通知系统,也没有开放匿名发布。

认证相关配置如下,省略了缓存、附件等与本次接入无关的设置:

YAML
base-url: "https://ntfy.example.com"
listen-http: ":80"
behind-proxy: true
auth-file: "/var/lib/ntfy/user.db"
auth-default-access: "deny-all"
enable-login: true

user.db 必须持久化,否则容器重建后,用户、Token 和主题权限也可能丢失。已有部署不应直接用这个片段覆盖完整的 server.yml。

只授予发布权限

催更服务不需要读取通知,也不需要管理其他用户。因此创建普通用户 blog-reminder,只给它目标主题的 write-only 权限。

下面假设容器名为 ntfy-ntfy-1,blog-updates 是示例主题。操作前先检查已有权限,避免误改现有订阅关系:

Shell
docker exec ntfy-ntfy-1 ntfy access

创建普通用户,按交互提示设置随机密码:

Shell
docker exec -it ntfy-ntfy-1 ntfy user add blog-reminder

授予单个主题的发布权限:

Shell
docker exec ntfy-ntfy-1 ntfy access blog-reminder blog-updates write-only

下一条命令会输出完整 Token。应在受控终端执行,并把结果写入服务端凭据文件;不要复制到文章、截图、工单或 CI 日志中。

Shell
docker exec ntfy-ntfy-1 ntfy token add --label=blog-reminder blog-reminder

实际接入沿用了已有的通知主题,现有订阅账号及权限没有变化。如果改成新主题,则还需要为读者端账号授予读取权限,并在客户端订阅;创建发布账号不会自动让手机收到新主题的消息。

手机和桌面如何接收催更

服务端安装、Android / iOS 客户端下载和桌面订阅的完整步骤,见 自建 ntfy:Docker 服务端部署与手机、桌面客户端接入。本文只列出接收催更需要对齐的配置:

客户端配置项本文示例注意事项
自定义服务器 / 基础 URLhttps://ntfy.example.com填 ntfy 的公网地址,不填博客域名、http://ntfy 或 /api/remind
主题 / Topicblog-updates必须与发布端 NTFY_URL 中的实际主题一致;这里是脱敏示例
订阅账号notify-reader 及其密码账号必须在这台 ntfy 上创建,并对实际主题具有 read-only 权限
系统通知允许检查主题是否静音,分别验证前台和锁屏后的接收情况

若客户端只提供完整订阅 URL 字段,填写 https://ntfy.example.com/blog-updates。不要用只写的 blog-reminder 账号或发布 Token 订阅;发布成功但读取被拒绝,正是只写权限的预期行为。已有账号能读取实际通知主题时,可以直接复用,不必另建本文的示例账号。

iOS 官方 App 的后台即时通知还依赖服务端正确设置公网 base-url 和 upstream-base-url: "https://ntfy.sh"。手机仍订阅自建服务器,不改成 ntfy.sh;上游负责唤醒,消息正文由客户端向自建服务器读取。Android 需要检查后台运行限制,桌面关闭页面后的通知则需要单独配置 Web Push,详见安装文章。

凭据只留在服务端

部署机器上的 reminder.env 保存以下配置。尖括号中的内容必须替换,不能把占位符当作正式密钥:

dotEnv
NTFY_URL=http://ntfy/blog-updates
NTFY_TOKEN=<NTFY_PUBLISH_TOKEN>
IP_HASH_SECRET=<STABLE_RANDOM_SECRET>
SITE_ORIGIN=https://blog.example.com
SQLITE_PATH=/data/reminders.sqlite3
NTFY_TIMEOUT_SECONDS=8

NTFY_URL 是包含主题的完整地址,不是只有 ntfy 域名。这里的 HTTP 仅用于同一宿主机内的 Docker 网络通信;如果通知服务迁到另一台机器或跨越不可信网络,应使用 HTTPS。

IP_HASH_SECRET 至少需要 32 个 UTF-8 字节。可以用以下命令生成随机值,再安全地填入环境文件:

Shell
python3 -c 'import secrets; print(secrets.token_hex(32))'

这个密钥必须跨部署保持不变。更换它会让同一 IP 算出不同的去重键,旧窗口也就不能继续约束这个 IP。

环境文件设置为 0600,由部署账号持有;不要使用 PUBLIC_ 前缀把它注入 Astro 客户端,也不要打进镜像。仓库的 .gitignore 排除了 reminder.env 和 SQLite 运行时文件,但忽略规则只是辅助措施,不能代替正确的凭据存放位置。

IP 去重:可信来源、规范化、事务

IP 不能由浏览器指定

服务端只接受 Nginx 覆盖后的 X-Real-IP,不从请求体取 IP,也不自行解析访客提交的 X-Forwarded-For 链。

本站入口是 Traefik,运行在 gerbil 的网络命名空间中。部署时先核对了这一跳的容器地址和转发行为,再在 Nginx 中配置精确的信任地址。

以下是脱敏后的配置结构。192.0.2.10 是文档示例地址,必须替换为实际连接 Nginx 的可信代理地址,不能照抄,更不能放宽成 0.0.0.0/0:

Nginx
# 放在提供站点的 server 块中。
set_real_ip_from 192.0.2.10;
real_ip_header X-Forwarded-For;
real_ip_recursive off;
location = /api/remind {
client_max_body_size 4k;
resolver 127.0.0.11 valid=10s ipv6=off;
set $reminder_upstream http://reminder:8080;
proxy_pass $reminder_upstream;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For "";
proxy_set_header Host $host;
proxy_connect_timeout 3s;
proxy_read_timeout 15s;
proxy_next_upstream off;
access_log off;
}

这份配置依赖本站的单跳转发关系:取可信 Traefik 写入的最右侧地址,而不是信任访客自行附带的地址。多一层 CDN、多一层代理,或者可信容器地址变化,都需要重新验证,不能直接套用。

proxy_set_header X-Real-IP $remote_addr 会覆盖访客同名请求头;清空 X-Forwarded-For 则避免应用再误用另一条来源链。proxy_next_upstream off 禁止 Nginx 自动换上游重试,通知是否重发由应用明确控制。

Docker 容器重建后地址可能变化,因此上游通过 Docker DNS 动态解析,而不是让 Nginx 长期持有旧的服务 IP。

保存 HMAC,不保存原始 IP

有效 IP 先由 Python 的 ipaddress 规范化。IPv4 映射形式的 IPv6 地址会折叠成对应的 IPv4,避免同一地址因为写法不同形成两个键。

之后计算:

Text
去重键 = HMAC-SHA256(IP_HASH_SECRET, 规范化后的 IP)

这里没有使用普通的 SHA256(IP)。IP 的候选空间有限,普通哈希容易被枚举;带密钥的 HMAC 不仅依赖 IP,还依赖服务端保存的随机密钥。

SQLite 保存哈希键、截止时间、预留标识、通知状态和全站计数,不保存原始 IP、文章路径或标题。发往 ntfy 的消息也不包含 IP 或哈希值。不过,入口代理仍可能有独立日志,不能据此宣称整条链路都不记录 IP;哈希键本身也仍然可以关联同一来源。

检查和计数必须放在同一个事务里

先查询、再在事务外加一,会让两个并发请求同时看到「还没点过」,然后各自计数并发送通知。

实现使用 SQLite 的 BEGIN IMMEDIATE,把查询窗口、写入预留记录和更新总数放在同一个事务中。流程可概括为:

Text
开始写事务
查询当前 IP 的记录
如果 now < retry_at:返回重复结果,不修改截止时间
否则:
写入新的预留标识
设置 retry_at = now + 12 小时
设置 notification = pending
全站计数加一
提交事务
仅新预留记录尝试发送 ntfy
写回通知结果

时间恰好等于 retry_at 时,旧窗口已到期,可以再次计数。总数来自事务中的读取快照;如果其他 IP 随后产生新的催更,页面收到的总数可能已经不是全站最新值,它不是实时推送的计数面板。

「已记录」和「已通知」必须分开

最容易误写的是这句逻辑:「ntfy 失败就删掉记录,让用户重试。」

请求超时时,服务端未必知道 ntfy 有没有收到消息。可能是 ntfy 已接收,返回结果时连接才断开。如果立刻释放去重记录,下一次点击就可能再推送一遍。

本次选择先提交计数和预留记录,再尝试发布通知。重复请求只返回已有结果,不再次发送。接口成功响应的结构如下,数字和时间仅作格式示例:

JSON
{
"count": 42,
"duplicate": false,
"retryAt": "2026-09-06T21:00:00.000Z",
"notification": "sent"
}

HTTP 200 表示催更已经记录,不表示手机一定收到了通知。

通知状态含义
pending已预留,最终通知结果尚未持久化;进程崩溃后也可能保留此状态
sentntfy 返回 2xx,确认接收;不代表客户端已经展示
failedntfy 明确返回非 2xx
unknown超时或传输错误,无法确认 ntfy 是否接收

这是一项明确的取舍:同一 IP 的同一窗口最多进行一次应用层发送尝试,不保证通知最终必达。 如果进程在提交预留后、发送前崩溃,计数已经增加,通知却可能没有发出。当前没有后台补发任务,失败和不确定结果也不会在窗口内自动重试。

ntfy 的发布端禁用了重定向,避免凭据被转发到意外地址,或者重定向导致重新发布。消息正文使用 UTF-8,承载中文页面标题;HTTP Title 头使用固定 ASCII 文本,Click 链接由服务端的 SITE_ORIGIN 和校验后的相对路径组成。

前端只负责提交和解释结果

按钮不需要一个前端框架,也不需要先请求 IP 查询服务。Astro 组件中的浏览器脚本发起同源请求:

JavaScript
const response = await fetch('/api/remind', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
path: location.pathname,
title: document.title.slice(0, 200),
}),
signal: AbortSignal.timeout(15000),
});

使用 pathname 而不是完整地址,避免把查询参数和片段带进通知。后端仍会独立校验:请求体最多 4096 字节,只允许 path、title 两个字段;路径必须是站内相对路径,不接受域名、查询、片段、反斜杠或控制字符。页面标题属于访客可修改的输入,不应当作可信管理指令。

后端要求 Origin 精确匹配 SITE_ORIGIN。这个检查可以拒绝普通浏览器的跨站调用,但脚本客户端能伪造 Origin,因此它不是身份认证,也不是完整的反滥用方案。

交互状态保持简单:

  • 请求中禁用按钮,显示「正在提交」。
  • 新记录且 ntfy 接收成功,提示「已通知作者」。
  • 已在窗口内,提示「这次不重复计数」,显示可再次计数的时间。
  • 已记录但通知未确认,明确区分计数成功和通知结果。
  • 网络错误时允许重试,但不声称之前的请求一定没有生效。

按钮本身带 hidden,由脚本初始化后显示;无 JavaScript 时不留下失效控件。没有 API 的静态镜像站也隐藏按钮。这里没有用 localStorage 决定是否计数,刷新、换浏览器后的结果仍由服务端判断。

Docker:代码只读,数据可写

服务没有第三方 Python 依赖,镜像使用 python:3.13-alpine,以 UID/GID 10001:10001 运行。代码位于 /app,数据库位于 /data。

下面是催更服务的 Compose 接入片段,需要合并到现有博客配置中,并保留博客原有镜像、TLS 挂载和其他设置:

YAML
services:
blog:
depends_on:
reminder:
condition: service_healthy
networks:
- pangolin_frontend
- blog_backend
reminder:
image: registry.example.com/blog-reminder:latest
container_name: blog-reminder
env_file:
- /srv/blog/reminder.env
restart: unless-stopped
volumes:
- reminder_data:/data
read_only: true
tmpfs:
- /tmp
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
networks:
- blog_backend
- ntfy
volumes:
reminder_data:
networks:
blog_backend:
internal: true
ntfy:
external: true
name: ntfy_default
pangolin_frontend:
external: true

前置条件是 ntfy_default、pangolin_frontend 已存在,而且 ntfy 能在通知网络中解析到现有服务。新服务没有 ports,浏览器无法绕过 Nginx 直接给后端传一个自选 IP。

根文件系统只读不代表数据库也只读。命名卷挂到 /data,用于保存 SQLite 数据库、WAL 和共享内存文件;镜像预先给这个目录设置了非 root 用户的写权限。如果改用宿主机目录绑定挂载,则必须单独检查目录所有者和权限。

数据卷和 IP_HASH_SECRET 应一起保留。不要把 docker compose down -v 当成日常更新命令,它会删除 Compose 管理的数据卷,去重记录和总数也会随之丢失。SQLite 应使用具有正常文件锁语义的本地存储,而不是随意放进网络共享目录。

部署时真正踩到的权限问题

第一次启动后,Nginx 配置测试通过了,静态页面也能打开,但催更容器一直重启。日志是:

Text
python: can't open file '/app/reminder.py': [Errno 13] Permission denied

问题不在 SQLite,也不在 ntfy。复制进镜像的 Python 文件权限过窄,容器里的非 root 用户无法读取。COPY 成功不代表最终运行用户能打开文件。

修复放在 Dockerfile 中,而不是临时进入容器执行 chmod:

Dockerfile
COPY --chmod=0444 reminder.py /app/reminder.py
USER 10001:10001

代码只需要可读,数据库目录才需要可写。重新构建并启动后,服务进入 healthy。

这次故障也说明,只看到 docker compose up -d 返回成功还不够。启动命令可以成功返回,容器随后才退出。部署验证需要等待健康检查,并检查实际接口:

Shell
docker compose up -d --wait reminder
docker compose ps
docker logs --tail 30 blog-reminder

GET /healthz 检查 SQLite 计数表是否可读,不会发送 ntfy 通知。它能发现数据库不可用,但不能证明 ntfy 凭据正确或消息已送达。

另一个需要保留的部署细节是 Nginx 配置挂载:本站把宿主机配置目录挂到 /etc/nginx/conf.d,会遮蔽镜像里复制进去的配置。只更新镜像、不同步宿主机 nginx.conf,新增 API 路由仍然不会生效。修改挂载配置后,先执行 nginx -t,通过后再 reload。

CI 接管两份镜像

原流水线只构建静态博客镜像。加入催更之后,流程变成:

Text
运行催更服务测试
→ 构建并推送博客镜像
→ 构建并推送 blog-reminder 镜像
→ 同步 compose.yml 和 nginx.conf
→ 服务器拉取镜像并更新容器
→ 检查 Nginx 和站点响应

后端的构建上下文是 server,不是仓库根目录:

Shell
python3 -m unittest discover -s server -p 'test_*.py'
docker build -t registry.example.com/blog-reminder:latest server

reminder.env 不随 CI 上传或覆盖。发布 Token 和哈希密钥在服务器上维护,正常发布只更新代码、镜像和非敏感配置。

首次联调采用了直接在服务器构建镜像的方式,随后将功能代码和流水线变更提交到了仓库。两种方式不能混淆:直接上线并不意味着镜像已经进入远程仓库,推送代码也不等于部署流水线已经成功。故障回滚应使用保留的旧镜像和对应配置,不依赖一个不断变化的 latest 标签;回滚时继续保留数据卷与密钥。

验证不能只看按钮变了颜色

本次后端的 22 项测试覆盖了以下行为:

  • 12 小时窗口及其精确到期边界,重复点击不延长窗口。
  • 同一 IP 跨文章去重,并发请求只生成一条有效预留。
  • SQLite 持久化、重新创建服务后的记录恢复,以及崩溃遗留状态。
  • ntfy 成功、明确失败、超时和不确定结果,不因异常重复发送。
  • Origin、请求体大小、字段、路径和 IP 格式校验。
  • IPv4 与映射 IPv6 的归一化,以及通知中不包含原始 IP。

测试使用临时数据库和模拟 ntfy,不会给真实主题发送消息。浏览器验证则检查了桌面、手机、暗色、无 JavaScript,以及提交成功、重复和失败文案。

最后用真实公网入口发送了一次明确标注的联调通知,再使用伪造的转发头重复请求。首次测试的观察结果是:

请求countduplicatenotification
首次联调1falsesent
相同来源,附带伪造 IP 请求头1truesent

第二次的 retryAt 与第一次相同。之后检查 SQLite,累计计数仍为 1,记录状态为 sent。这验证了本次代理链上的去重行为和 ntfy 接收结果,不是对所有网络拓扑的保证,也没有替手机确认弹窗。

复现时要注意:下面的命令会产生真实计数,并可能发送通知,不是只读探针。将示例域名替换为自己的站点后再执行:

Shell
curl --fail-with-body -sS 'https://blog.example.com/api/remind' \
-H 'Origin: https://blog.example.com' \
-H 'Content-Type: application/json' \
--data-binary '{"path":"/novel/","title":"催更功能联调测试"}'

再次执行相同请求,应返回重复结果;也可以附加 X-Real-IP 和 X-Forwarded-For 测试头,确认公网入口没有让访客指定去重身份。不要直接对私网后端做这个测试来证明代理安全,后端的前提就是信任 Nginx 覆盖后的请求头。

当前边界与后续维护

这套实现解决的是轻量催更,不是一个完整的通知平台或反滥用系统。上线后的维护重点仍然是几处具体边界:

  1. IP 只是近似身份。 共享出口会合并读者,变更 IP 可以重新计数。若以后接入 IAM,可以重新设计身份口径,而不是把 IP 哈希包装成用户 ID。
  2. 通知允许丢失,不允许窗口内自动重发。 若业务改成必须最终送达,需要另行设计持久化待发送队列、重试策略和接收端幂等,不能简单删除失败记录。
  3. 当前没有按请求速率限流。 去重可以减少通知,但不能阻止大量请求消耗连接、线程和数据库资源。公网流量增大时,需要补充入口限流、连接限制及监控。
  4. 超时预算尚有余量问题。 当前前端和 Nginx 读取超时都是 15 秒,SQLite 锁等待上限也是 15 秒,ntfy 超时为 8 秒。严重争用时,计数可能成功,但客户端先超时;现有文案允许安全重试。若要覆盖最坏等待时间,需要一起调整预算,或降低数据库锁等待上限。
  5. 持久化不等于自动备份或自动清理。 当前不会定期删除所有过期 IP 哈希记录;访问同一 IP 时会更新它的窗口。数据卷备份、保留期限和清理策略需要按实际规模补充。

按钮最终仍然只是页脚的一次点击。把可信 IP、计数事务、通知结果和部署状态分开处理,才能让「这次不重复计数」成为可验证的行为,而不只是页面上的一句提示。

实现对应提交:b087679:催更按钮、IP 去重与 ntfy 接入。完整接口和运维说明位于仓库的 server/README.md。