起因:核实「管理端在 Cloudflare 上是否有效」时发现一个更要紧的问题 —— Worker 完全没有 UptimeRobot 同步能力(runChecks 的探测目标只有 config.monitors)。 而实际配置是 dataFilter=uptimerobot,也就是说直接部署到 Worker 面板会是空的: 它探测 8 个本地条目,而真正在监控的 6 个 UR 目标它根本不认识。 ① Worker 支持 UptimeRobot(Step 1) - edge/cloudflare/uptimerobot.js(新增):Workers 版 UR 只读数据源, 与 prober/node/lib/uptimerobot.js 同一套语义(状态映射/ratios 解析/故障原因) - runChecks 的探测集合扩展为「自建目标 + UR 镜像目标」;UR 目标只拉取不探测, 采用其状态并补齐本地 daily 分桶 - probeScope 与 dataFilter 解耦(与本地探针同语义):config 范围不拉 UR, uptimerobot 范围不探测本地 - 同步失败时标记 sources.uptimerobot.ok=false 并保留上次数据, 绝不把目标判为离线或从面板消失;UR 侧删除的监控自动清理 - 快照新增 probeScope;UR 目标带 meta(面板区分自建/外部)与 uptimeRatios ② 管理端远程访问(Step 2) - settings.admin.allowRemote:探针默认只监听 127.0.0.1,显式打开才监听全网卡, 启动日志就此告警。这是 Cloudflare Tunnel 的安全前提(已实测两种行为) - edge/cloudflare/tunnel.yml(新增):命名隧道配置,只放行 ^/(admin|api/admin/.*|assets/.*)$,其余 404 —— 不把公开面板一起挂到公网 ③ 前后端分离(Step 3) - Worker 侧 /admin 与 /api/admin/* 显式返回 403 + 指向文档的提示, 而非默默落到静态资源变成 404,确保边缘侧没有任何可写面 测试:新增 scripts/verify-worker.cjs(36 项)—— 把 Worker 以 data: URL 动态载入以 绕开 cloudflare:sockets,注入假 KV/静态资源/UptimeRobot 服务器, 不需要 wrangler 也不需要联网。全项目 8 套 310 项全部通过。 过程中抓到两个真 bug: 1. UR 同步失败的降级路径引用了不含 meta 的旧对象,导致 name/url 为 undefined、 UR 目标从面板上消失 2. stats.total 按 config.monitors.length 计数,在 probeScope=uptimerobot 时 与实际参与的目标数不符 另有一条测试夹具教训:内存版 KV 必须实现 get(key, 'json') 的类型参数 (真实 Workers KV 会自动反序列化),否则 state.monitors 恒为 undefined。
14 KiB
安全说明:刷新滥用防护、SSH 探测与信息泄露控制
面向「公网可访问的状态页」这个前提编写。核心结论先给:
| 场景 | 建议 |
|---|---|
| 本地 / 内网自用 | 默认配置即可,无需额外改动 |
| 公网发布 | 静态快照(GitHub Pages / EdgeOne)或 Cloudflare Worker + REFRESH_TOKEN;不要把 Node 探针直接暴露到公网 |
| 目标含内网资产 | 用 local.monitors.json 分层,公开配置只留 id/name/group,并开 hideTargets + redactDetails |
一、/api/refresh 的滥用风险与分层防护
「刷新即重新检测」意味着一个未鉴权的 GET 请求会触发一次对所有目标的出站连接。滥用的三种形态:
- 探测放大器:任何人反复调用,把本服务当作对目标站点施压的跳板(借刀杀人)
- 资源耗尽:每次强制刷新都会对所有目标发起连接,慢目标(如超时 10s)会长时间占用并发
- 目标配额消耗:被诱导高频探测的第三方目标可能限流甚至封禁我们的出口 IP(真实案例:GitHub 未认证 API 上限 60 次/小时,被高频探测打满后返回 403,面板如实记为离线)
防护措施(已实现)
| 位置 | 措施 | 默认 |
|---|---|---|
| Node 探针 | 仅允许内网/回环来源触发;只信任 socket 对端地址,不解析 X-Forwarded-For(该头部可被伪造) |
开启 |
| Node 探针 | 每来源冷却 settings.refreshCooldownSec,超限返回 429 + Retry-After |
5s |
| Node 探针 | 轮次去重:多标签页/多客户端共享同一轮探测;2s 内的重复请求直接复用快照 | 开启 |
| Node 探针 | 单轮上限 22s 兜底(FORCED_MAX_WAIT_MS),防止卡死连接长期占用请求 |
开启 |
| Node 探针 | 总开关 settings.allowRefresh: false,完全关闭强制探测(只读模式) |
可配 |
| Node 探针 | 拒绝响应只回错误码({"error":"refresh-throttled"}),不回显目标与内部细节 |
固定 |
| Worker | 必须配置 REFRESH_TOKEN 才开放 /api/refresh,否则一律 403;token 比较用等长逐字节异或,避免按字符提前返回的时序差异 |
默认关闭 |
| 面板 | 收到 401/403 自动退回只读轮询(页脚标注「只读」);429 仅本轮降级并在按钮提示 | 自动 |
配置参考
{
"settings": {
"refreshInterval": 30, // 面板刷新节奏
"probeOnRefresh": true, // 刷新是否重新探测
"allowRefresh": true, // false = 完全关闭强制探测(公网部署建议)
"refreshLocalOnly": true, // 仅内网/回环来源可触发
"refreshCooldownSec": 5 // 每来源冷却
}
}
单个目标也可退出「刷新即检测」,避免被高频探测压垮配额:
{ "id": "github-api", "interval": 300, "probeOnRefresh": false }
已知限制(请按需叠加外部措施)
- 限流在进程内:多个探针实例不共享计数;需要强约束时请在网关(Nginx / Cloudflare Rate Limiting)再加一层
- 反向代理场景:因为不信任
X-Forwarded-For,前置代理时所有请求都来自代理的内网地址,refreshLocalOnly会失效——此时应设allowRefresh: false,或自行在代理层做认证与限流 - Worker 的同隔离体去重是 best-effort(不同边缘节点各自计数)
二、SSH 这类服务怎么探测
支持三种探测类型,按「侵入性从低到高」:
| 类型 | 做什么 | 适合 |
|---|---|---|
tcp |
仅建立 TCP 连接,测连通性与连接耗时 | 任意端口存活监控 |
banner |
连接后被动读取服务端主动发送的标识串,可与规则比对 | SSH / SMTP / FTP / POP3 / IMAP / HTTP 等主动问候的协议 |
http |
完整 HTTP 请求,读状态码 | Web 服务 |
SSH 配置示例
{
"id": "ssh-gateway",
"name": "跳板机 SSH",
"group": "基础设施",
"type": "banner",
"host": "10.0.0.5", // 真实地址建议放 local.monitors.json
"port": 22,
"timeout": 5,
"interval": 300,
"expectBanner": "^SSH-2\\.0-", // 策略:必须是 SSH 2.0
"revealBanner": false // 默认不回显标识串(含软件版本)
}
expectBanner 让探测同时具备合规检查能力:例如要求 ^SSH-2\.0-OpenSSH_[89],若目标降级到旧版本,探测即判为离线并触发面板告警。
明确不做的三件事
- 不做 SSH 登录/认证尝试:会触发目标侧 fail2ban/sshguard 封禁,需要凭据,还会在目标留下审计噪音
- 不发送任何应用层数据:因此需要客户端先行发包的协议(Redis 的
PING、MySQL 握手)暂不适用——这是刻意保留的安全边界,不是能力缺失 - 不在标识串不匹配时回显实际收到的内容:避免把「目标是什么版本」写进公开的
status.json
banner 探测的副作用仅有一次 TCP 连接与读取,与端口扫描器不同:不产生登录失败记录,也不改动目标任何状态。
三、信息泄露控制
状态页天生是公开的,泄露面有三处,逐个封堵:
| 泄露面 | 风险 | 措施 |
|---|---|---|
public/monitors.json |
面板必需文件,静态部署必然公开,含真实 URL / 内网主机端口 | 私有配置分层:公开文件只写 id/name/group,真实地址放仓库根的 local.monitors.json(已 gitignore),探针按 id 合并覆盖 |
| 通知凭据 | SMTP 密码、MeoW 昵称(等同密钥)泄露 → 被冒名发信/推送 | 与地址一样只放 local.monitors.json 或环境变量;public/monitors.json 中 notify.channels.* 保持 enabled: false |
status.json / /api/status |
探针输出,Pages 部署会公开,detail 可能含地址、版本、错误码 |
settings.redactDetails: true → detail 只保留「正常/异常」;TCP 详情已默认不含地址;UptimeRobot 镜像目标的地址在 hideTargets 开启时不下发;事件时间线在 redactDetails 下只保留类型与时间 |
| 面板渲染 | 直接把地址、错误详情展示给访客 | settings.hideTargets: true → 不渲染任何地址(含 tooltip),显示「已隐藏」 |
| UptimeRobot API Key | 泄露后可读取(只读 Key 不能改配置,但会暴露监控清单) | 只放 local.monitors.json(gitignore)或环境变量 UPTIMEROBOT_API_KEY;public/monitors.json 里 apiKey 必须留空 |
/api/v2/getMonitors(兼容接口) |
无鉴权,暴露全部目标与可用率 | 内容与公开面板一致,故默认开放;如需收敛请在其前方加带认证的代理,或设 allowRefresh: false 并只暴露静态快照 |
对外发布状态页的推荐组合
// local.monitors.json(不入库,仅本机/CI 环境存在)
{
"monitors": [
{ "id": "blog", "url": "https://blog.example.com" },
{ "id": "nas", "host": "192.168.1.10", "port": 5000 }
]
}
// public/monitors.json(公开)
{
"settings": { "hideTargets": true, "redactDetails": true },
"monitors": [
{ "id": "blog", "name": "博客站点", "group": "网站", "type": "http", "interval": 60, "timeout": 10 },
{ "id": "nas", "name": "家庭存储", "group": "基础设施", "type": "tcp", "interval": 300, "timeout": 5 }
]
}
这样即使 status.json 与面板完全公开,访客也只能看到「有哪些服务、是否在线、延迟、可用率」。
补充:dataFilter(both / config / uptimerobot)只决定展示与快照输出哪些目标,不影响探测行为;如果希望某个来源的数据完全不进入公开快照,应使用 config 过滤或把对应目标从配置中移除。
仍然会被公开的信息(请自行评估)
- 服务数量与名称:命名不要包含敏感语义(不要叫「内网财务系统」)
- 延迟特征:多次采样可反推大致网络位置
- 可用率与故障时间:这本身就是状态页要公开的信息
- GitHub Actions 场景:CI 读不到未提交的
local.monitors.json,需用 repository secrets 在 workflow 中生成,或只监控公开目标
四、管理后台(/admin)的鉴权边界
v0.12.0 起,所有会改配置的操作都放在 /admin,必须绑定并通过两步验证才能进入。
部署形态与可写能力
管理端只存在于「本地 Node 探针」这一种部署形态。各形态的可写面:
| 部署形态 | 面板可读 | 探测 | 管理端(2FA) |
|---|---|---|---|
| 本地 Node | ✅ | ✅ | ✅ |
| Cloudflare Workers | ✅ | ✅ | ❌ 403 |
| GitHub Pages / EdgeOne Pages(纯静态) | ✅ | ❌(仅展示 CI 产出的快照) | ❌ 不存在 |
边缘形态访问 /admin 或 /api/admin/* 会得到 403 + 指向本文的提示,而不是 404 —— 明确表达「这里不提供写能力」,避免访客把 404 误读成路径写错。
这不是「没做」,而是这些能力依赖本地状态:2FA 档案与会话存在 data/ 目录与进程内存中,而 Workers 无文件系统、isolate 之间不共享内存(KV 又是最终一致,无法承载「恢复码一次性使用」这类语义)。
远程访问(Cloudflare Tunnel)
探针在内网时若需从外网访问 /admin,用 Cloudflare Tunnel 让内网主动出网建隧道。
探针默认只监听 127.0.0.1,必须显式打开才能被隧道访问:
// local.monitors.json
{ "settings": { "admin": { "allowRemote": true } } }
开启后启动日志会警告「正在监听 0.0.0.0」。请注意该端口同时伺服公开面板 —— 也就是说一旦打开这个开关,/ 与 /api/status 也会跟着对外可达,这两项本身是只读的,但你的目标名称、地址与可用率会一并暴露。隧道配置(edge/cloudflare/tunnel.yml)默认只放行 ^/(admin|api/admin/.*|assets/.*)$,其余路径 404,就是为了不把公开面板一起挂出去。
威胁模型与对应措施
| 威胁 | 措施 |
|---|---|
| 未绑定就被抢先接管管理端 | 绑定需 SHIROKO_ADMIN_SETUP_KEY;未绑定时 /api/admin/* 除绑定流程外一律 403;未设置该环境变量时绑定入口直接关闭(503) |
| 未授权者经隧道访问 | 隧道暴露的是同一端口,防护完全依赖 2FA:TOTP 校验 + 会话哈希 + 失败限流三重 |
| 隧道配置过宽导致面板外泄 | 示例配置只放行管理路径,并注释说明不要把 / 与 /api/status 一起暴露 |
忘记关allowRemote |
启动日志显式警告监听地址;默认值为关闭 |
| 暴力枚举 6 位码(100 万种) | 按 IP 计数,连续 5 次失败后指数退避锁定(1m → 2m → 4m …上限 30m);锁定期间即使验证码正确也拒绝 |
| 会话 id 被窃取 | id 为 32 字节随机数,服务端只存 SHA-256 哈希;cookie 为 HttpOnly(JS 读不到)+ SameSite=Strict;30 分钟空闲失效、最长 2 小时;进程重启清空全部会话 |
| 截图复用验证码 | 记录最近使用的 TOTP counter,同一时间步的码用过即作废 |
| 恢复码泄露 | 只存 SHA-256 哈希(带随机盐),一次性使用,用后立刻从档案中移除 |
| 伪造来源 IP 绕过限流 | 只信任 socket 对端地址,不解析 X-Forwarded-For(可被任意伪造)—— 经隧道访问时对端地址是 Cloudflare 边缘节点,因此限流是按边缘 IP 而非真实客户端统计的,这是隧道形态下已知的粒度损失 |
| 篡改请求 | Path=/ + HttpOnly + SameSite=Strict;请求体上限 64KB,超出返回 413;参数逐项白名单校验(如 probeScope 只接受三个枚举值) |
| 通过管理端把密钥写进仓库 | 所有写操作只落 local.monitors.json(私有、已 gitignore),绝不改 public/monitors.json |
已知限制(请按需叠加外部措施)
- 会话 cookie 未设
Secure:纯 HTTP 下设置Secure会导致 cookie 完全不发送,管理端直接不可用。部署到公网时请放在 HTTPS 反向代理之后,由代理层负责加密传输 - 绑定口令
SHIROKO_ADMIN_SETUP_KEY只能从环境变量读取,不写入任何文件;重启后需要重新设置才能再次绑定或重置 - 2FA 档案(
data/admin-2fa.json)是明文 secret:该文件权限应仅限运行用户,且data/已在.gitignore中 - 会话存在内存里:多实例部署时各实例的会话不共享,需要粘性会话或改为单实例
- 忘记 2FA 且忘记绑定口令时只能停服务、删除
data/admin-2fa.json后重新绑定
验证方式
node scripts/verify-admin.cjs # 102 项:RFC 向量 / 绑定 / 会话 / 防重放 / 限流 / HTTP 边界 / 页面流程
该脚本会先用 RFC 4226 与 RFC 6238 官方测试向量校验自实现的 TOTP(这是整个 2FA 的地基),再覆盖绑定流程、会话生命周期、恢复码一次性、防重放、失败限流,以及「未绑定时所有写接口必须 403」这类鉴权边界。
五、其他基线
.gitignore已包含local.monitors.json与.dev.vars(Cloudflare 本地密钥)- Worker 的
REFRESH_TOKEN用wrangler secret put REFRESH_TOKEN管理,不要写进wrangler.toml - UptimeRobot API Key 在 Worker 上必须用
wrangler secret put UPTIMEROBOT_API_KEY,写进public/monitors.json会随静态资源公开。只用ur*开头的只读 Key - 公开面板(
/)仍是只读前端:无 cookie、无写入接口、无服务端会话;渲染配置字段时统一做 HTML 转义(escapeHtml),XSS 面仅剩配置自身。管理能力全部隔离在/admin与/api/admin/*(需会话) - Node 探针同时充当静态服务器,默认只监听
127.0.0.1;需要远程访问时用settings.admin.allowRemote=true显式打开,并确保前置代理已启用 HTTPS