diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..43a16bd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,649 @@ +# 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 主分支: + +```bash +git push origin HEAD:main +``` + +提交前后检查: + +```bash +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 会话。 + - 这是生产行为最关键的文件,修改前必须读相关测试。 + +- `internal/web/runner.go` + - 服务端 SSE/探测 runner。 + +- `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。 + +## 关键业务约束 + +这些约束比局部代码风格更重要,不能破坏。 + +1. 同一时间只能有一个真正测速会话。 + - `Server.activeID` 是全局锁。 + - 普通 Web/CLI HTTP 测试和 iperf3 测试必须共用同一个锁。 + - 其它请求必须排队,而不是并发跑测速。 + +2. 队列必须 FIFO。 + - 新请求在有 active 会话时进入 `Server.queue`。 + - active 完成、取消或过期后只提升队列头。 + - 手动停止或 CLI 中断必须释放队列。 + +3. 已产生样本的半截测试必须保存。 + - CLI HTTP 中断:如果 `samples` 非空,先 complete partial,再返回原始中断错误。 + - 浏览器停止/刷新:如果 `state.samples.length > 0`,优先 complete partial。 + - 还没轮到、没有样本的取消只释放队列,不写空记录。 + +4. complete 成功写入记录后才释放会话。 + - 如果 `Store.Append` 失败,complete 应返回 500,并保留会话以便重试。 + - 不能先 `takeSession` 再写盘,否则会丢记录。 + +5. 历史记录必须保护隐私。 + - API 输出的 IP 必须脱敏。 + - ISP 缺失时显示 `未知运营商`。 + - 不要在前端、日志或 API 输出完整用户 IP,除非用户明确要求调试且范围受控。 + +6. 大样本记录必须可保存和读取。 + - complete body 上限:64 MiB。 + - JSONL 单行读取上限:64 MiB。 + - 不要把限制改回 2 MiB 或默认 scanner 64 KiB。 + +7. 峰值口径不能用单次极短请求直接当最大值。 + - 现有逻辑使用持续窗口计算 max,避免计时误差导致几百 Mbps 的假峰值。 + +8. 生产默认不限速。 + - `-bandwidth-limit-mbps 0` 表示不限速。 + - 如果用户要求限速,必须同时更新页面显示和服务启动参数。 + +## 常用开发命令 + +本地测试: + +```bash +go test ./... -count=1 +go vet ./... +``` + +运行服务端: + +```bash +go run ./cmd/netstable serve -listen 127.0.0.1:18080 -data /tmp/netstable-records.jsonl -geo-base '' +``` + +运行 CLI HTTP 测试: + +```bash +go run ./cmd/netstable client -server http://127.0.0.1:18080 -duration 15 +``` + +构建本机二进制: + +```bash +make build +``` + +构建 Linux release 包: + +```bash +make release VERSION=v0.1.0 +``` + +生成文件: + +- `dist/netstable-v0.1.0-linux-amd64.tar.gz` +- `dist/netstable-v0.1.0-linux-arm64.tar.gz` +- `dist/checksums.txt` + +更新 IP 数据库: + +```bash +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`。 +- 修改生产相关配置:先本地校验配置,再线上验证接口。 + +重点回归测试名称: + +- `TestCompleteEndpointAcceptsLargeSamplePayload` +- `TestCompleteEndpointCanRetryWhenPersistFails` +- `TestRunPersistsPartialRecordWhenCanceledAfterSamples` +- `TestRunCompletesBrowserCompatibleSpeedTest` +- `TestRunIperf3CompletesOneOffServerSession` +- `TestCancelActiveTestPromotesQueuedSession` +- `TestCancelQueuedTestRemovesItAndUpdatesPositions` +- `TestBrowserSummaryDoesNotTreatShortBurstsAsSustainedMax` +- `TestStoreListsLargeRecordsWithFullSampleCurves` + +## 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` +- 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` 预期监听: + +- `:80` Caddy HTTP,自动跳转 HTTPS。 +- `:443` Caddy HTTPS。 +- `:18080` NetStable 服务。 +- `127.0.0.1:2019` Caddy admin。 + +### 主节点 Caddy 配置 + +路径: + +```bash +/etc/caddy/Caddyfile +``` + +关键站点块: + +```caddyfile +speedtest.hzct.obscura.work { + reverse_proxy 127.0.0.1:18080 +} +``` + +最近已知备份: + +```bash +/etc/caddy/Caddyfile.bak-20260610110050 +``` + +修改 Caddy 后必须校验: + +```bash +caddy fmt --overwrite /etc/caddy/Caddyfile +caddy validate --config /etc/caddy/Caddyfile +systemctl reload caddy +systemctl is-active caddy +``` + +验证 HTTPS: + +```bash +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 证书日志: + +```bash +journalctl -u caddy --no-pager -n 100 +``` + +### systemd 服务 + +模板文件: + +```bash +deploy/netstable.service +``` + +线上预期服务名: + +```bash +netstable.service +``` + +预期服务配置: + +```ini +ExecStart=/usr/local/bin/netstable -listen 0.0.0.0:18080 -data /var/lib/netstable/records.jsonl -downloads-dir /var/lib/netstable/downloads +``` + +常用命令: + +```bash +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`。 +- 不要在 `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. 发布前检查 + +```bash +git status --short --branch +go test ./... -count=1 +go vet ./... +make release VERSION=v0.1.0 +``` + +确认生成包: + +```bash +ls -lh dist +cat dist/checksums.txt +``` + +### 2. 部署到可 SSH 节点 + +优先用 SSH 标准输入流上传。不要直接 `scp` 覆盖 `/usr/local/bin/netstable`,运行中的 Linux 二进制可能导致写入失败。 + +单节点部署模板: + +```bash +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. 部署后验证 + +本地哈希: + +```bash +shasum -a 256 dist/netstable-v0.1.0-linux-amd64/netstable +``` + +远端哈希: + +```bash +ssh root@SERVER 'sha256sum /usr/local/bin/netstable' +``` + +直连节点检查: + +```bash +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 +``` + +域名检查: + +```bash +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 + +```bash +git status --short --branch +git add +git commit -m "" +git push origin HEAD:main +git ls-remote origin refs/heads/main +``` + +如果用户只要求文档变更,也要提交并推送,除非用户明确不要。 + +## 回滚手册 + +### 代码回滚 + +优先回滚到上一个已知好提交,重新构建并部署。 + +```bash +git log --oneline --decorate -10 +git checkout +go test ./... -count=1 +make release VERSION=v0.1.0 +``` + +然后按“部署到可 SSH 节点”重新上传。 + +不要对用户工作区执行 `git reset --hard`,除非用户明确要求。 + +### Caddy 回滚 + +先找备份: + +```bash +ls -lh /etc/caddy/Caddyfile.bak-* +``` + +恢复: + +```bash +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。修复坏记录时优先备份再处理: + +```bash +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 损坏。 +- 文件权限或磁盘问题。 + +检查: + +```bash +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 路径又被改坏了。 + +优先跑: + +```bash +go test ./internal/web -run TestCompleteEndpointCanRetryWhenPersistFails -count=1 -v +``` + +### 用户停止后队列不释放 + +检查: + +- 浏览器停止时有没有调用 partial complete 或 cancel。 +- CLI 中断时有没有调用 partial complete 或 cancel。 +- `takeSession`、`finishSession`、`promoteNextLocked` 是否被绕过。 + +优先跑: + +```bash +go test ./internal/client -run TestRunCancelsQueuedSessionWhenContextStops -count=1 -v +go test ./internal/web -run 'TestCancelActiveTestPromotesQueuedSession|TestCancelQueuedTestRemovesItAndUpdatesPositions' -count=1 -v +``` + +### 峰值速度明显虚高 + +不要直接相信单个样本的 `mbps`。检查持续吞吐统计: + +```bash +go test ./internal/web -run 'TestBrowserSummaryUsesSustainedTransferRatesForMax|TestBrowserSummaryDoesNotTreatShortBurstsAsSustainedMax' -count=1 -v +``` + +### 域名 HTTPS 失败 + +检查: + +```bash +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 仍可被用户直连伪造。 + +正确处理顺序: + +1. 先决定是否只允许通过 Caddy 访问主节点。 +2. 用防火墙限制公网直接访问 18080,或让 NetStable 只监听 `127.0.0.1:18080`。 +3. 再给服务加 `-trust-proxy-headers`。 +4. 测试 IP 脱敏、ISP 和地区显示。 + +不要只加 `-trust-proxy-headers`。 + +### SSH host key 变化 + +`103.46.93.38` 在 2026-09-15 检查时出现 host key changed。处理方式: + +1. 不要直接 `ssh-keygen -R` 后继续部署。 +2. 先通过控制台或可信渠道确认服务器是否重装、迁移或 IP 复用。 +3. 确认无误后再更新 `~/.ssh/known_hosts`。 +4. 更新后先只跑只读检查,再部署。 + +## 安全和隐私 + +- 不要把完整用户 IP 写到公开 API 响应。 +- 不要把 Gitea token、密码、SSH 私钥写入仓库、日志或最终回复。 +- Gitea 凭据由本机 Git credential helper 管理;不要打印明文。 +- 生产记录包含用户网络信息,下载或复制前先确认用途。 +- 对外开放的下载文件名必须限制在已知二进制包名,不能做任意文件下载。 + +## CI 和 Release + +Gitea Actions 文件: + +```bash +.gitea/workflows/ci.yml +``` + +当前行为: + +- push 到 `main`、`master`、`codex/**`:运行测试和 vet。 +- pull request:运行测试和 vet。 +- tag `v*` 或手动触发:构建 release tar.gz 和 checksums,并上传 artifact。 + +如果改 Makefile、release 包内容或运行时依赖,必须同步检查 CI。 + +## 最终交付检查清单 + +代码或文档改动完成后: + +```bash +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/` + +然后提交并推送: + +```bash +git add +git commit -m "" +git push origin HEAD:main +``` + +最终回复必须说明: + +- 改了什么。 +- 跑了哪些验证。 +- 是否已提交和推送 Gitea。 +- 如涉及生产,列出线上验证结果或明确说明未部署。