参考
安全模型
三个信任边界,以及每条匿名路径的代价上界。
「安全」是这个项目三条设计哲学的第一条。这一页是完整的模型。
三个信任边界
公网匿名 ─────────────────────────────────────
只看得到标记为公开的节点
且永远看不到 ip / hostname / remark
agent(持有节点 token)───────────────────────
只能上报自己那个节点的数据
只能收到分配给自己的探测任务
管理员(持有 session cookie)─────────────────
全部管理员
- 鉴权写在类型签名里:面板 handler 的参数里带一个
Admin提取器, 框架在进入函数体之前校验会话,不通过直接 401。忘不掉,也不会写漏 - session 是 256 位随机 token,数据库里只存 sha256,14 天过期
- cookie 是
HttpOnly+SameSite=Lax,HTTPS 下加Secure - 改密码作废所有会话,包括正开着的实时推送流——那条流里带着每个节点的明文 token
- 登录限流:同一地址 15 分钟 5 次失败锁死,另有一道「同一时刻只允许一次密码校验」的并发闸门
SameSite=Lax 加同源 API 就是 CSRF 防护,没有额外的 CSRF token。
X-Forwarded-For 只用于限流和显示,绝不用于鉴权
它是客户端可伪造的。而且只在 peer 本身是本地地址时才采信——hub 直接暴露在公网时, 这个头是攻击者自己写的:每个请求换一个伪造地址,既能绕开锁定,又能让限流表无限长大。
取的是最后一跳,因为两种反代都是追加而不是覆盖,链首是调用方自己写进来的。
节点 token
- 256 位随机值,数据库里存明文——因为面板要能随时把安装命令显示出来
- 只在管理员视图里输出,公开页拿到的 JSON 里没有这个 key
- 可以随时换发,旧的立刻失效,包括已经连着的那条连接
- 走
Authorization: Bearer头,不走 URL query - 无效或缺失一律 401,不区分「格式不对」和「不存在」
明文存储意味着数据库本身就是凭证。 拿到 monitor.db 的人可以冒充任何节点上报假数据。
但同一个文件里已经有会话、GitHub Client Secret 和管理员密码哈希,它本来就必须当作机密对待——
备份要加密。管理员密码和会话不受影响,仍然分别是 argon2id 和 sha256。
数据库文件权限收到 0600,-wal 和 -shm 一起(那两个文件装着同样的行)。
agent 侧
token 写在 /opt/monitor/agent.env,0600,不写在服务定义里——那些内容会出现在
systemctl cat 和日志里。
systemd 单元加固:DynamicUser、NoNewPrivileges、ProtectSystem=strict、ProtectHome、
PrivateTmp、PrivateDevices、RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK、
MemoryMax=64M。
OpenRC 上 agent 以 root 跑——DynamicUser 是 systemd 白送的降权,OpenRC 没有对应开关。
拒绝明文
agent 和 install.sh 在目标不是回环地址时拒绝明文,裸主机名默认升级到 TLS。
token 明文传输是一半理由;另一半是安装时下载的那个二进制走同一条未验证的通道, 而它紧接着要以 root 跑起来——比 token 泄露更糟。
--server 里不允许出现 @:URL 的 userinfo 语法能让「按第一个冒号切出主机」的写法
和真正解析 URL 的一方看到不同的主机。一个 hub 地址没有任何理由带 userinfo,所以直接拒绝而不是剥离。
公开状态页
两层开关:全局的公开页开关,以及每个节点自己的公开标志。
过滤在序列化的时候做:匿名请求拿到的 JSON 里根本没有 hostname、ip、remark 这三个 key。
指标部分走白名单而不是黑名单。理由有两条:
- agent 在另一个仓库、另一条发版节奏上。黑名单意味着那边加一个字段,这边就漏一个字段, 而且是在匿名页面上漏
- 更直接的——拿到任意一个节点 token 的人(一台被拿下的 VPS,或者安装命令粘错了地方)
可以自己往上报里塞
hostname、ip、remark,黑名单会把它们一路送到公开页
国家码是故意放在公共部分的那一个。 它由 hub 从连接地址查出来,但两者不是一回事: 地址指向一台机器,国家只说这台机器在哪个市场,而「这批节点分布在哪几个地区」正是状态页要回答的问题。
每条匿名路径的代价上界
这是新增匿名接口时的硬规矩:说得出单次请求最多让 hub 花多少。内存不是唯一的额度, 占锁时长和出网字节同样算。
| 路径 | 靠什么封顶 |
|---|---|
/api/nodes | 共享快照,缓存 1.9 秒 |
/api/nodes/{id}/metrics | 降采样限响应行数 + 168 小时窗口限扫描行数 |
/agent/{arch} | 流式转发(内存)+ 4 个并发闸门 |
POST /api/agent/register | 窗口一小时 + 256 位 key + 一个窗口 100 个节点 |
| 所有请求 | 请求体 64 KiB,WebSocket 帧同样 64 KiB |
WebSocket 帧上限要单独设:tower 那层握手之后就不管事了,而默认上限是 64 MiB—— 一个节点自己的 token 就能买下整个额度,而它发上来的东西会落库、并原样推给公开页的每一个访客。
已知缺口
hub 转发 agent 二进制时不按摘要核对。 面板里那个 GitHub 代理设置会拼在下载地址前面,
这个前缀指向的主机返回什么,hub 就原样转发什么,install.sh 写进 /opt/monitor 并拉起来——
全链路没有签名也没有摘要。
已收紧的部分:设置只接受 https://(排除 hub 与镜像之间的中间人),agent 的 release 也发
sha256sums.txt(在此之前连可验的材料都没有)。
在补上之前:填进那个框的地址等于把整个机队的代码执行权交给那个镜像。留空直连是默认, 只有 hub 自己拉不到 GitHub 时才需要填,而且只填信得过的。
这是一笔明知而挂着的账,不是疏漏——写在这里是因为你有权知道。
数据库的两条上传路径
POST /api/db/restore 和 POST /api/themes 是全站仅有的两条把调用方的字节写进磁盘的路径。
三层上界各自独立成立,不靠上一层兜底:
- 单请求 8 MiB(也是反代要放行的那个数)
- 整份文件在第一个请求就卡(备份 256 MiB,主题 32 MiB)
- handler 里再自己数一遍收到的字节,写超当场中止并把文件截回片首
临时文件 0600,无论成败都删,连同 SQLite 可能在它旁边建的 -wal / -shm。