monitor

参考

安全模型

三个信任边界,以及每条匿名路径的代价上界。

「安全」是这个项目三条设计哲学的第一条。这一页是完整的模型。

三个信任边界

公网匿名 ─────────────────────────────────────
  只看得到标记为公开的节点
  且永远看不到 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.env0600不写在服务定义里——那些内容会出现在 systemctl cat 和日志里。

systemd 单元加固:DynamicUserNoNewPrivilegesProtectSystem=strictProtectHomePrivateTmpPrivateDevicesRestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINKMemoryMax=64M

OpenRC 上 agent 以 root 跑——DynamicUser 是 systemd 白送的降权,OpenRC 没有对应开关。

拒绝明文

agent 和 install.sh 在目标不是回环地址时拒绝明文,裸主机名默认升级到 TLS。

token 明文传输是一半理由;另一半是安装时下载的那个二进制走同一条未验证的通道, 而它紧接着要以 root 跑起来——比 token 泄露更糟。

--server 里不允许出现 @:URL 的 userinfo 语法能让「按第一个冒号切出主机」的写法 和真正解析 URL 的一方看到不同的主机。一个 hub 地址没有任何理由带 userinfo,所以直接拒绝而不是剥离。

公开状态页

两层开关:全局的公开页开关,以及每个节点自己的公开标志。

过滤在序列化的时候做:匿名请求拿到的 JSON 里根本没有 hostnameipremark 这三个 key。

指标部分走白名单而不是黑名单。理由有两条:

  1. agent 在另一个仓库、另一条发版节奏上。黑名单意味着那边加一个字段,这边就漏一个字段, 而且是在匿名页面上漏
  2. 更直接的——拿到任意一个节点 token 的人(一台被拿下的 VPS,或者安装命令粘错了地方) 可以自己往上报里塞 hostnameipremark,黑名单会把它们一路送到公开页

国家码是故意放在公共部分的那一个。 它由 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/restorePOST /api/themes 是全站仅有的两条把调用方的字节写进磁盘的路径。 三层上界各自独立成立,不靠上一层兜底:

  • 单请求 8 MiB(也是反代要放行的那个数)
  • 整份文件在第一个请求就卡(备份 256 MiB,主题 32 MiB)
  • handler 里再自己数一遍收到的字节,写超当场中止并把文件截回片首

临时文件 0600,无论成败都删,连同 SQLite 可能在它旁边建的 -wal / -shm