Rust stable(1.98 验证过)、Node 24(CI 用的也是 24)。
rustup component add clippy rustfmt
cd web-admin && npm ci && cd ..没有第三步。 默认主题不需要 clone、不需要 Node:cargo build 按 web-theme.pin 下载发布好
的主题包、校验 sha256、解到 target/theme/(scripts/theme.sh 做的,build.rs 调它)。理由见
decisions.md。
改主题:在主题仓库自己的检出里改,npm run dev 起 Vite,它把 /api 和 WebSocket 代理到
9911 的 hub。发版两步:主题仓库打 tag(CI 自动发布 theme.tar.gz + .sha256),hub 这边把
web-theme.pin 换成新的 <tag> <sha256>。
想拿一个还没发布的主题构建 hub:把构建好的 dist/ + theme.json 放进 target/theme/,
再往 target/theme/.pin 写一行和 web-theme.pin 一样的内容,脚本就会跳过下载。
符号链接不行:rust-embed 的 debug 模式和 frontend.rs 的 read_inside 一样,canonicalize
之后确认文件仍在声明目录内,跟出去的一律拒绝。症状是 / 返回 404 而 /admin 正常。
# 后台要先构建,hub 会把它的 dist 嵌进二进制
cd web-admin && npm run build && cd ..
# 默认主题不用管:build.rs 按 web-theme.pin 取回来放好
cargo build --releasedebug 构建时 rust-embed 从磁盘读两个 dist/(web-admin/dist 和 target/theme/dist),所以开发期间改完后台不用重新 cargo build,重跑 npm run build 就行。release 才真正嵌入。
cargo test
cargo clippy --all-targets
cargo fmt --all覆盖重点:
| 位置 | 覆盖什么 |
|---|---|
src/db.rs |
流量累加、重启与缩水对齐、日/月周期滚动、级联删除、排序完整性、prune |
src/auth.rs |
密码哈希、限流锁定的完整生命周期、cookie 往返、OAuth 回调往返、转发头 |
src/agent_ws.rs |
RPC 分发、每分钟落盘、探测结果归属、会话拆除竞态、header 鉴权 |
src/api.rs |
公开视图过滤、快照缓存与两个受众、读权限、密钥不回读 |
src/frontend.rs |
主题路径穿越与越界符号链接、主题短名的两道守卫 |
src/main.rs |
cookie 标志(--site 或 X-Forwarded-Proto)与明文告警、对外地址推导、静态路由、账单滚动、首次运行 |
写测试的原则:一段非平凡逻辑留一个能跑的检查就够,不要每个函数一个测试。优先测边界和不变量, 不测 getter。
一条在逻辑被改坏之后依然通过的断言是摆设。加断言之前先想清楚:什么样的代码改动会让它变红? 想不出来就别写。这个仓库踩过的几种:
- 主键替你去重。
metric和ping_record都是INSERT OR REPLACE+ 复合主键,同一秒写进去 的几条记录本来就会塌成一行。所以「五份上报只落一行」这种计数断言,不管每分钟落盘的门控在不在都是 绿的。要么让各条记录带上不同的键,要么改断言别的东西(落盘时间戳是不是对齐到整分钟)。 - 容器替你去重。 「限流表不会无限增长」曾经断言
map.len() <= 去重后的地址数,而HashMap的键天生保证这一点,把清扫逻辑整个删掉照样绿。 - 常量时间窗关不掉。 锁定窗口写死成 15 分钟,「过期自动解锁」这条分支在测试里永远够不着。
Throttle的窗口因此是字段而不是常量,生产路径取LOCKOUT,测试塞个几十毫秒的。 - 重建出来的东西和缓存的一模一样。 验证快照缓存生效不能拿两次读取比相等——不走缓存重建一遍 字节也一样。得在两次读取之间悄悄改掉底层数据。
recv().await等的是永远不来的消息。 断言通道已关闭要用try_recv():真出了回归,recv().await会把整个测试挂死,CI 上是超时不是报错,比不测还难查。
确认一批测试到底护住了什么:把逻辑挨个改坏,看有没有测试变红。
# 例:让同 boot 的读数缩水时不再钳零,「总流量永不回退」应该立刻报警
sed -i 's/(rx - last_rx).max(0), (tx - last_tx).max(0)/rx - last_rx, tx - last_tx/' src/db.rs
cargo test # 期望 db::tests::a_shrinking_reading... 变红
git checkout -- src/db.rs批量跑有两个坑:改完源码要让 mtime 前进一秒(同一秒内连着改,cargo 会拿旧产物顶替,好测试
也显示成漏网),以及别用 git checkout 还原——没提交的改动会跟着一起没。存一份文件内容再
写回去。
# hub
cargo run -- --listen 127.0.0.1:9911 --db /tmp/dev.db --themes /tmp/themes
# 记下打印出来的一次性密码
# 后台热更新(API 代理到 9911)
cd web-admin && npm run dev后台开发服务器使用 /admin/。默认主题在它自己的检出里改,那边 npm run dev 的 Vite 同样把
/api 和 WebSocket 代理到 9911。
第三方主题不用加入 hub 仓库。构建后按下面的形状复制到 --themes 指向的目录,再到后台「主题」页
切换:
/tmp/themes/<short>/theme.json
/tmp/themes/<short>/dist/index.html
/tmp/themes/<short>/preview.png # 可选,后台主题卡片上的缩略图
目录名必须等于 theme.json 的 short;短名只允许字母、数字、-、_。完整接口契约见默认主题
仓库的 README。
theme.json 的字段全部必填,缺任一个主题就不会出现在列表里(hub 日志有一条 warn 说缺哪个)。
不想填源码地址写 "url": "",卡片自动不显示「源码」链接。
本地开发就用上面这个形状直接复制,改一次看一次;发布给别人装的时候打成
tar czf theme.tar.gz dist theme.json preview.png,对方在后台「主题」页上传即可。
想让用户能点更新按钮:url 写成 https://github.com/<owner>/<repo>,仓库的 release 里放一个
名叫 theme.tar.gz 的资产,tag 写成 v<theme.json 里的 version>(v 可有可无)。hub 比 tag 和已装
版本,相同就不下载。资产必须叫这个名字——hub 不取 release 里的第一个资产。
登录后建一个节点,拿到 token,在 agent 仓库 里跑一个指向本机 hub 的 agent:
cd /path/to/agent
cargo run -- --server http://127.0.0.1:9911 --token <token> --interval 1http:// 只对回环地址放行,正好够本地调试。
改完核心逻辑后跑一遍这个:
H=http://127.0.0.1:9911
# 1. 未登录不能碰面板接口
curl -s -o /dev/null -w "%{http_code}\n" -X POST $H/api/nodes -d '{"name":"x"}' # 期望 401
# 2. 无效 token 连不上
WSH=(-H 'Connection: Upgrade' -H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==')
curl -s -o /dev/null -w "%{http_code}\n" "${WSH[@]}" $H/api/agent/ws # 401
curl -s -o /dev/null -w "%{http_code}\n" "${WSH[@]}" -H 'Authorization: Bearer bad' $H/api/agent/ws # 401
curl -s -o /dev/null -w "%{http_code}\n" "${WSH[@]}" -H "Authorization: Bearer $TOKEN" $H/api/agent/ws # 101
# 3. 公开页不含敏感字段
curl -s $H/api/nodes | python3 -c '
import sys,json; n=json.load(sys.stdin)["nodes"][0]
assert all(k not in n for k in ("ip","remark","hostname")), "泄露了敏感字段"
print("公开视图 OK")'
# 4. 数据口径对得上系统工具
cd /path/to/agent && cargo test crosscheck -- --nocapture
free -b | awk 'NR==2{printf "free used=%.2fG\n", $3/1073741824}'
df -B1 --output=size,used / | tail -1
# 5. 累计流量跨 hub 重启不回退(见 traffic.md 的完整步骤)
# 6. 备份能导出,也能原样恢复(C 是 cookie jar)
# 上传是分片的:?offset= 必须等于服务端已经收到的字节数,收满 total 的那一片返回最终结果
curl -s -b $C -o /tmp/b.db $H/api/db/backup && file /tmp/b.db # SQLite 3.x database
curl -s -o /dev/null -w "%{http_code}\n" $H/api/db/backup # 401,五条 /api/db/* 都是
S=$(stat -c%s /tmp/b.db)
curl -s -b $C -c $C -X POST --data-binary @/tmp/b.db \
"$H/api/db/restore?offset=0&total=$S" # {"ok":true} + 新 cookie
head -c 200000 /dev/urandom > /tmp/junk
curl -s -b $C -X POST --data-binary @/tmp/junk \
"$H/api/db/restore?offset=0&total=200000" # 400,不是这个 hub 的备份
curl -s -b $C -X POST --data-binary @/tmp/junk \
"$H/api/db/restore?offset=99&total=200000" # 400,分片接不上
head -c 9000000 /dev/zero | curl -s -o /dev/null -w "%{http_code}\n" -b $C -X POST \
--data-binary @- "$H/api/db/restore?offset=0&total=9000000" # 413,单请求超 8 MiB
# 7. 主题能上传、当场生效、能删(拿主题仓库发布的 theme.tar.gz,或者把 target/theme/ 打一个)
S=$(stat -c%s /tmp/t.tar.gz)
curl -s -b $C -X POST --data-binary @/tmp/t.tar.gz "$H/api/themes?offset=0&total=$S"
curl -s -b $C $H/api/themes # 列表里多一个
curl -s -b $C -X PUT -d '{"theme":"<short>"}' -H 'content-type: application/json' $H/api/settings
curl -s $H/ | head -c 200 # 公开页已经是新主题,hub 没重启
command ls -1a /tmp/themes # 只有正式目录,无 .staging-*
curl -s -b $C -X DELETE $H/api/themes/<short> # 204,公开页回落内置主题装有 puppeteer 的 Chrome 时可以直接驱动:
CHROME=$(find / -path '*/chrome-linux64/chrome' -type f 2>/dev/null | head -1)
"$CHROME" --headless --disable-gpu --no-sandbox --hide-scrollbars \
--window-size=1280,1400 --screenshot=out.png --virtual-time-budget=6000 \
http://127.0.0.1:9911/需要登录后的页面就用 puppeteer-core 写个几十行的脚本走一遍登录表单。图表用整页截图会拍成空白
——ResponsiveContainer 在整页模式改视口后来不及重绘,要截图表就用视口截图。
推一个 v* tag 触发 .github/workflows/release.yml:构建内置后台、按 web-theme.pin 取回并校验
默认主题,再构建 hub 的 musl 静态二进制。agent 和主题由各自仓库独立发布。
install.sh 只走 hub 的 /agent/{arch} 拿二进制。hub 那一端拼的是 src/main.rs 的 AGENT_REPO
常量,前面可以再加上面板设置的 github_proxy(main::release_url)。
- 别用
pkill -f停进程:它会匹配到正在跑这条命令的 shell 自己,把会话一起干掉。用ss -lptn "sport = :9911"找 PID 再kill - shadcn 的组件(
web-admin/src/components/ui/和主题的src/components/ui/)是 CLI 生成的,不要手改。改样式改各自src/index.css的 CSS 变量 tsconfig里不要加baseUrl,TypeScript 6 里已废弃会直接报错。paths单独用就行erasableSyntaxOnly开着,构造函数参数属性(constructor(public x: number))不能用- lucide-react v1 删掉了品牌图标,没有
Github组件。登录页里是手写的内联 SVG