The browser and CLI flows both use download/upload/complete; the
/api/tests/{id}/events endpoint and its Runner plumbing were dead code
that could bypass the single-active-test lock via expireActiveLocked
promoting the queue while an SSE stream was still running.
Remove the endpoint, Runner option, DefaultRunner, probe.Run engine and
the frontend subscribe/closeSource leftovers. Add a regression test
asserting the endpoint returns 404 and create responses omit eventsUrl.
19 KiB
AGENTS.md
本文件是 NetStable 项目的维护入口。任何自动化代理、Codex 会话或人工维护者在改动本仓库前都应先读这里。
基本原则
- 用最简单、直接、改动最少的实现,优先复用现有文件、函数和类型。
- 仅为当前实际需求或明显重复逻辑增加抽象,不为未来扩展提前设计。
- 替换实现时移除旧路径,避免新旧双轨。
- 不做顺手重构。除非它直接服务当前问题,否则不要改命名、目录结构、样式或部署流程。
- 不回滚用户或其他维护者的改动。遇到不相关的脏工作区,忽略;遇到相关冲突,先读懂再处理。
- 所有影响生产行为的改动都必须有验证证据:至少跑相关测试;改到公共路径时跑
go test ./... -count=1。 - 完成代码改动后,提交并对齐 Gitea,除非用户明确要求不要提交或不要推送。
项目概况
NetStable 是一个独立 Go Web/CLI 测速项目,用于测试用户浏览器或 CLI 到部署服务器的网络质量。
核心能力:
- 浏览器测速:用户打开网页后测试下载、上传、延迟和稳定性。
- CLI 测速:同一个二进制可作为客户端运行,支持 HTTP 和
iperf3模式。 - 全局锁:同一时间只允许一个用户真正测速,其它用户进入 FIFO 队列。
- 半截记录:已经产生样本的中断/停止测试也必须保存为历史记录;还没轮到且没有样本的排队取消不生成空记录。
- 历史记录:完整样本曲线、摘要、脱敏 IP、地区、运营商写入 JSONL。
- 域名访问:生产主入口是
https://speedtest.hzct.obscura.work/。
仓库信息
- 本地路径:
/Users/kanshan/Documents/speedtest - Gitea 仓库:
https://git.aiot.ml/kanshan/speedtest - 远端名:
origin - 远端主分支:
main - 当前本地工作分支通常是:
codex/go-network-stability-tester - 推送当前 HEAD 到 Gitea 主分支:
git push origin HEAD:main
提交前后检查:
git status --short --branch
git log --oneline --decorate -5
git remote -v
git ls-remote origin refs/heads/main
不要提交 dist/、本地 JSONL 记录、日志或临时文件;这些已在 .gitignore 中排除。
代码结构
-
cmd/netstable/main.go- CLI 入口。
- 默认不带子命令时启动服务端。
serve启动 Web 服务。client运行 HTTP/iperf3 客户端测速。
-
internal/web/server.go- HTTP API、网页静态资源、队列、全局锁、记录完成、下载/上传接口、iperf3 会话。
- 这是生产行为最关键的文件,修改前必须读相关测试。
- 测试 SSE 探测端点(
/api/tests/{id}/events)已移除;浏览器和 CLI 测速统一走 download/upload/complete,页面只保留/api/health/events心跳。
-
internal/client/client.go- HTTP CLI 客户端。
- 负责创建会话、排队等待、下载/上传阶段、complete/cancel。
- 已产生样本后中断,必须走 partial complete,不能只 cancel。
-
internal/client/iperf3.go- CLI iperf3 模式。
- 服务端创建一次性高端口,客户端执行本机
iperf3,完成后回写记录。
-
internal/probe- TCP/HTTP 探测样本和摘要计算。
-
internal/store- JSONL 历史记录读写。
- 单条记录可能很大,读取端已提高到 64 MiB 单行上限。
-
internal/geo- IP 地区/运营商解析。
- 优先内置
ip2region_v4.xdb,再走在线 fallback。
-
internal/limit- 程序级带宽限速。
- 生产当前默认不限速。
-
web/static- 前端 HTML/CSS/JS。
- 一键 CLI 命令由
web/static/app.js根据当前window.location.origin生成。
-
data/ip2region_v4.xdb- 内置 IPv4 数据库。
-
deploy/netstable.service- systemd 服务模板。
-
.gitea/workflows/ci.yml- Gitea Actions:测试、vet、release artifact。
关键业务约束
这些约束比局部代码风格更重要,不能破坏。
-
同一时间只能有一个真正测速会话。
Server.activeID是全局锁。- 普通 Web/CLI HTTP 测试和 iperf3 测试必须共用同一个锁。
- 其它请求必须排队,而不是并发跑测速。
-
队列必须 FIFO。
- 新请求在有 active 会话时进入
Server.queue。 - active 完成、取消或过期后只提升队列头。
- 手动停止或 CLI 中断必须释放队列。
- 新请求在有 active 会话时进入
-
已产生样本的半截测试必须保存。
- CLI HTTP 中断:如果
samples非空,先 complete partial,再返回原始中断错误。 - 浏览器停止/刷新:如果
state.samples.length > 0,优先 complete partial。 - 还没轮到、没有样本的取消只释放队列,不写空记录。
- CLI HTTP 中断:如果
-
complete 成功写入记录后才释放会话。
- 如果
Store.Append失败,complete 应返回 500,并保留会话以便重试。 - 不能先
takeSession再写盘,否则会丢记录。
- 如果
-
历史记录必须保护隐私。
- API 输出的 IP 必须脱敏。
- ISP 缺失时显示
未知运营商。 - 不要在前端、日志或 API 输出完整用户 IP,除非用户明确要求调试且范围受控。
-
大样本记录必须可保存和读取。
- complete body 上限:64 MiB。
- JSONL 单行读取上限:64 MiB。
- 不要把限制改回 2 MiB 或默认 scanner 64 KiB。
-
峰值口径不能用单次极短请求直接当最大值。
- 现有逻辑使用持续窗口计算 max,避免计时误差导致几百 Mbps 的假峰值。
-
生产默认不限速。
-bandwidth-limit-mbps 0表示不限速。- 如果用户要求限速,必须同时更新页面显示和服务启动参数。
常用开发命令
本地测试:
go test ./... -count=1
go vet ./...
运行服务端:
go run ./cmd/netstable serve -listen 127.0.0.1:18080 -data /tmp/netstable-records.jsonl -geo-base ''
运行 CLI HTTP 测试:
go run ./cmd/netstable client -server http://127.0.0.1:18080 -duration 15
构建本机二进制:
make build
构建 Linux release 包:
make release VERSION=v0.1.0
生成文件:
dist/netstable-v0.1.0-linux-amd64.tar.gzdist/netstable-v0.1.0-linux-arm64.tar.gzdist/checksums.txt
更新 IP 数据库:
make update-ip-db
更新数据库后必须检查 THIRD_PARTY.md 和 LICENSES/ip2region-LICENSE.md 是否仍然准确。
测试要求
最小规则:
- 修改 Go 行为:跑对应 package 测试,再跑
go test ./... -count=1。 - 修改公共服务端路径:同时跑
go vet ./...。 - 修改 release/deploy/CI:跑
make release VERSION=v0.1.0。 - 修改前端 JS:跑
node --check web/static/app.js和go test ./web -count=1。 - 修改生产相关配置:先本地校验配置,再线上验证接口。
重点回归测试名称:
TestCompleteEndpointAcceptsLargeSamplePayloadTestCompleteEndpointCanRetryWhenPersistFailsTestRunPersistsPartialRecordWhenCanceledAfterSamplesTestRunCompletesBrowserCompatibleSpeedTestTestRunIperf3CompletesOneOffServerSessionTestCancelActiveTestPromotesQueuedSessionTestCancelQueuedTestRemovesItAndUpdatesPositionsTestEventsEndpointIsRemovedTestBrowserSummaryDoesNotTreatShortBurstsAsSustainedMaxTestStoreListsLargeRecordsWithFullSampleCurves
API 速查
-
GET /- Web UI。
-
GET /api/config- 返回带宽限制标签。
-
GET /api/records?limit=100- 返回历史记录,输出前会脱敏 IP。
limit范围:1 到 1000。
-
GET /api/health/events?limit=1- SSE 心跳,用于页面连接状态。
-
POST /api/tests- 创建 HTTP/Web 测速会话。
- 返回 queue/download/upload/complete/cancel URL。
-
GET /api/tests/{id}/queue- 查询排队位置。
-
POST /api/tests/{id}/complete- 提交浏览器或 HTTP CLI 样本并写历史。
-
POST /api/tests/{id}/cancel- 取消还未产生样本的会话或释放队列。
-
GET /api/download?testId={id}&bytes={n}- 下载测速数据。
-
POST /api/upload?testId={id}- 上传测速数据。
-
POST /api/iperf3/sessions- 创建一次性 iperf3 服务端会话。
-
POST /api/iperf3/sessions/{id}/complete- 提交 iperf3 结果并写历史。
-
GET /downloads/netstable-linux-amd64.tar.gz- CLI 一键下载包。
-
GET /downloads/netstable-linux-arm64.tar.gz- CLI 一键下载包。
生产环境概览
最后核对时间:2026-09-15 Asia/Shanghai。
主入口
- URL:
https://speedtest.hzct.obscura.work/ - HTTPS:Caddy 自动申请 Let's Encrypt 证书。
- 证书核对结果:
- Subject:
CN = speedtest.hzct.obscura.work - Issuer:
Let's Encrypt - 当前证书有效期:
2026-08-09到2026-11-07
- Subject:
- HTTP 会 308 跳转 HTTPS。
生产节点
| 节点 | 用途 | 状态备注 |
|---|---|---|
103.46.93.244 |
主域名节点,Caddy 80/443 反代到本机 NetStable 18080 | HTTPS/API 正常;2026-09-15 检查时 SSH 连接被远端关闭,部署前先恢复 SSH |
110.42.44.35 |
直连备用节点,http://110.42.44.35:18080/ |
2026-09-15 HTTP/API 正常,SSH 可用 |
103.46.93.38 |
历史直连节点,http://103.46.93.38:18080/ |
2026-09-15 检查时 HTTP 空响应,SSH host key 变化;不要盲目信任或部署,必须先人工确认主机身份 |
主节点端口
103.46.93.244 预期监听:
:80Caddy HTTP,自动跳转 HTTPS。:443Caddy HTTPS。:18080NetStable 服务。127.0.0.1:2019Caddy admin。
主节点 Caddy 配置
路径:
/etc/caddy/Caddyfile
关键站点块:
speedtest.hzct.obscura.work {
reverse_proxy 127.0.0.1:18080
}
最近已知备份:
/etc/caddy/Caddyfile.bak-20260610110050
修改 Caddy 后必须校验:
caddy fmt --overwrite /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
systemctl is-active caddy
验证 HTTPS:
curl -fsS -I https://speedtest.hzct.obscura.work/
curl -fsS https://speedtest.hzct.obscura.work/api/config | jq .
echo | openssl s_client -servername speedtest.hzct.obscura.work -connect speedtest.hzct.obscura.work:443 2>/dev/null | openssl x509 -noout -subject -issuer -dates
查看 Caddy 证书日志:
journalctl -u caddy --no-pager -n 100
systemd 服务
模板文件:
deploy/netstable.service
线上预期服务名:
netstable.service
预期服务配置:
ExecStart=/usr/local/bin/netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -downloads-dir /var/lib/netstable/downloads
常用命令:
systemctl status netstable --no-pager -l
systemctl restart netstable
systemctl is-active netstable
journalctl -u netstable --no-pager -n 100
注意:
- 当前模板没有启用
-trust-proxy-headers。 - 如果要通过 Caddy 获取真实客户端 IP,必须同时满足:
- NetStable 只接受可信代理流量,或公网
18080被防火墙挡住。 - 服务启动参数显式加
-trust-proxy-headers。
- NetStable 只接受可信代理流量,或公网
- 不要在
18080仍公网直连时直接开启-trust-proxy-headers,否则用户可伪造X-Forwarded-For。
生产路径
- 二进制:
/usr/local/bin/netstable - 历史记录:
/var/lib/netstable/records.jsonl - CLI 下载包目录:
/var/lib/netstable/downloads - amd64 下载包:
/var/lib/netstable/downloads/netstable-linux-amd64.tar.gz - arm64 下载包:
/var/lib/netstable/downloads/netstable-linux-arm64.tar.gz
发布和部署手册
1. 发布前检查
git status --short --branch
go test ./... -count=1
go vet ./...
make release VERSION=v0.1.0
确认生成包:
ls -lh dist
cat dist/checksums.txt
2. 部署到可 SSH 节点
优先用 SSH 标准输入流上传。不要直接 scp 覆盖 /usr/local/bin/netstable,运行中的 Linux 二进制可能导致写入失败。
单节点部署模板:
host=110.42.44.35
ssh -o BatchMode=yes -o ConnectTimeout=10 root@$host 'mkdir -p /var/lib/netstable/downloads'
ssh -o BatchMode=yes -o ConnectTimeout=10 root@$host 'cat > /tmp/netstable.new' < dist/netstable-v0.1.0-linux-amd64/netstable
ssh -o BatchMode=yes -o ConnectTimeout=10 root@$host 'cat > /var/lib/netstable/downloads/netstable-linux-amd64.tar.gz' < dist/netstable-v0.1.0-linux-amd64.tar.gz
ssh -o BatchMode=yes -o ConnectTimeout=10 root@$host 'cat > /var/lib/netstable/downloads/netstable-linux-arm64.tar.gz' < dist/netstable-v0.1.0-linux-arm64.tar.gz
ssh -o BatchMode=yes -o ConnectTimeout=10 root@$host 'install -m 0755 /tmp/netstable.new /usr/local/bin/netstable && rm -f /tmp/netstable.new && systemctl restart netstable && systemctl is-active netstable'
多节点部署前先确认每台 SSH 身份和服务状态,不要无脑批量推。
3. 部署后验证
本地哈希:
shasum -a 256 dist/netstable-v0.1.0-linux-amd64/netstable
远端哈希:
ssh root@SERVER 'sha256sum /usr/local/bin/netstable'
直连节点检查:
curl -fsS "http://SERVER:18080/api/config" | jq .
curl -fsS "http://SERVER:18080/api/records?limit=1" | jq .
curl -fsS -r 0-127 "http://SERVER:18080/downloads/netstable-linux-amd64.tar.gz" >/tmp/netstable-range.bin
wc -c /tmp/netstable-range.bin
域名检查:
curl -fsS -I https://speedtest.hzct.obscura.work/
curl -fsS https://speedtest.hzct.obscura.work/api/config | jq .
curl -fsS "https://speedtest.hzct.obscura.work/api/records?limit=1" | jq .
curl -fsS "https://speedtest.hzct.obscura.work/api/health/events?limit=1"
curl -fsS -r 0-127 "https://speedtest.hzct.obscura.work/downloads/netstable-linux-amd64.tar.gz" >/tmp/netstable-https-range.bin
wc -c /tmp/netstable-https-range.bin
4. 推送 Gitea
git status --short --branch
git add <changed-files>
git commit -m "<message>"
git push origin HEAD:main
git ls-remote origin refs/heads/main
如果用户只要求文档变更,也要提交并推送,除非用户明确不要。
回滚手册
代码回滚
优先回滚到上一个已知好提交,重新构建并部署。
git log --oneline --decorate -10
git checkout <known-good-commit>
go test ./... -count=1
make release VERSION=v0.1.0
然后按“部署到可 SSH 节点”重新上传。
不要对用户工作区执行 git reset --hard,除非用户明确要求。
Caddy 回滚
先找备份:
ls -lh /etc/caddy/Caddyfile.bak-*
恢复:
cp /etc/caddy/Caddyfile.bak-YYYYMMDDHHMMSS /etc/caddy/Caddyfile
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
systemctl is-active caddy
数据回滚
历史记录是 append-only JSONL。修复坏记录时优先备份再处理:
cp /var/lib/netstable/records.jsonl /var/lib/netstable/records.jsonl.bak-$(date +%Y%m%d%H%M%S)
不要直接清空生产记录,除非用户明确要求。
排障手册
记录接口 500:failed to load records
常见原因:
- JSONL 单行超过读取限制。
- 某行 JSON 损坏。
- 文件权限或磁盘问题。
检查:
journalctl -u netstable --no-pager -n 100
ls -lh /var/lib/netstable/records.jsonl
tail -n 3 /var/lib/netstable/records.jsonl
本项目已经把单行读取上限提高到 64 MiB;不要改回默认 scanner 行为。
complete 保存失败
原则:
- 写记录失败时,会话必须保留,客户端可以重试。
- 如果重试变成 404,说明 complete 路径又被改坏了。
优先跑:
go test ./internal/web -run TestCompleteEndpointCanRetryWhenPersistFails -count=1 -v
用户停止后队列不释放
检查:
- 浏览器停止时有没有调用 partial complete 或 cancel。
- CLI 中断时有没有调用 partial complete 或 cancel。
takeSession、finishSession、promoteNextLocked是否被绕过。
优先跑:
go test ./internal/client -run TestRunCancelsQueuedSessionWhenContextStops -count=1 -v
go test ./internal/web -run 'TestCancelActiveTestPromotesQueuedSession|TestCancelQueuedTestRemovesItAndUpdatesPositions' -count=1 -v
峰值速度明显虚高
不要直接相信单个样本的 mbps。检查持续吞吐统计:
go test ./internal/web -run 'TestBrowserSummaryUsesSustainedTransferRatesForMax|TestBrowserSummaryDoesNotTreatShortBurstsAsSustainedMax' -count=1 -v
域名 HTTPS 失败
检查:
curl -I https://speedtest.hzct.obscura.work/
systemctl status caddy --no-pager -l
journalctl -u caddy --no-pager -n 100
caddy validate --config /etc/caddy/Caddyfile
如果证书过期或申请失败,先看 Caddy ACME 日志。不要手动复制不明来源证书覆盖 Caddy 管理目录。
直连 18080 正常但域名记录 IP 不准
原因通常是反向代理后没有启用可信代理头,或启用了但公网 18080 仍可被用户直连伪造。
正确处理顺序:
- 先决定是否只允许通过 Caddy 访问主节点。
- 用防火墙限制公网直接访问 18080,或让 NetStable 只监听
127.0.0.1:18080。 - 再给服务加
-trust-proxy-headers。 - 测试 IP 脱敏、ISP 和地区显示。
不要只加 -trust-proxy-headers。
SSH host key 变化
103.46.93.38 在 2026-09-15 检查时出现 host key changed。处理方式:
- 不要直接
ssh-keygen -R后继续部署。 - 先通过控制台或可信渠道确认服务器是否重装、迁移或 IP 复用。
- 确认无误后再更新
~/.ssh/known_hosts。 - 更新后先只跑只读检查,再部署。
安全和隐私
- 不要把完整用户 IP 写到公开 API 响应。
- 不要把 Gitea token、密码、SSH 私钥写入仓库、日志或最终回复。
- Gitea 凭据由本机 Git credential helper 管理;不要打印明文。
- 生产记录包含用户网络信息,下载或复制前先确认用途。
- 对外开放的下载文件名必须限制在已知二进制包名,不能做任意文件下载。
CI 和 Release
Gitea Actions 文件:
.gitea/workflows/ci.yml
当前行为:
- push 到
main、master、codex/**:运行测试和 vet。 - pull request:运行测试和 vet。
- tag
v*或手动触发:构建 release tar.gz 和 checksums,并上传 artifact。
如果改 Makefile、release 包内容或运行时依赖,必须同步检查 CI。
最终交付检查清单
代码或文档改动完成后:
git status --short --branch
go test ./... -count=1
go vet ./...
按改动类型补充:
- 前端 JS:
node --check web/static/app.js - release:
make release VERSION=v0.1.0 - 域名/证书:
curl -I https://speedtest.hzct.obscura.work/
然后提交并推送:
git add <changed-files>
git commit -m "<message>"
git push origin HEAD:main
最终回复必须说明:
- 改了什么。
- 跑了哪些验证。
- 是否已提交和推送 Gitea。
- 如涉及生产,列出线上验证结果或明确说明未部署。