From bbe149fd41bd6572e5de9fca512d31dd1eb2bfcf Mon Sep 17 00:00:00 2001 From: wangjia <809946525@qq.com> Date: Thu, 2 Jul 2026 12:52:08 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E5=85=A8=E6=A0=88=E8=AE=BE=E8=AE=A1?= =?UTF-8?q?=E5=AE=A1=E6=9F=A5=202026-07(=E5=89=8D=E7=AB=AF/=E5=90=8E?= =?UTF-8?q?=E7=AB=AF/=E6=95=B0=E6=8D=AE=E5=BA=93,13=20=E9=A1=B9=E5=8F=91?= =?UTF-8?q?=E7=8E=B0=E5=88=86=E7=BA=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P0:控制面明文 HTTP、SQLite 零备份。P1:同机换账号设备注册 403 死结(migration16 注释里推迟的 UNIQUE(user_id,uuid) 未落地)、disconnect 撤账户级凭证而 connect 发 每设备凭证、agent 用量 Reset 后上报失败即丢(at-most-once)、Redis 白名单单点全员 掉线、argon2id 64MiB 并发 OOM。P2:sessions/audit 无留存、免费额度 UTC 日界、 三时钟口径、hub 内存态、单实例端口无握手、ReportUsage 无事务。 Co-Authored-By: Claude Opus 4.8 --- docs/code-review-2026-07.html | 216 ++++++++++++++++++++++++++++++++++ docs/index.html | 5 + 2 files changed, 221 insertions(+) create mode 100644 docs/code-review-2026-07.html diff --git a/docs/code-review-2026-07.html b/docs/code-review-2026-07.html new file mode 100644 index 0000000..0eaba20 --- /dev/null +++ b/docs/code-review-2026-07.html @@ -0,0 +1,216 @@ + + + + + +全栈设计审查 2026-07(前端 / 后端 / 数据库) + + + +
+← 返回文档索引 + +

全栈设计审查 · 2026-07

+

范围:server/(Go 控制面 + agent)· client/(Flutter)· 数据库 schema(migrations 1–20)· 部署脚本。方法:核心链路逐文件精读(认证 / 会话 / 连接下发 / 用量记账 / 配额卡控 / 设备管理),非全量逐行。13 项发现 P0 ×2 P1 ×5

+ +
+总体评价:架构底子是好的——方言层数据库解耦、argon2id 密码、refresh 单次轮换 + Redis 白名单、gRPC mTLS、 +per-device dp_uuid 归因、Lua 滑窗限流、节点三态判活(DB×agent在线×数据面健康),都属同规模项目少见的干净设计。 +问题集中在两条主线:① 传输与数据安全的「最后一公里」没封口(明文 API、零备份); +② 多处「一次写对、后续演进没跟上」的接缝漂移(connect 发每设备凭证 / disconnect 撤账户凭证; +设备唯一键改造写进注释却没落地;agent 用量取走即焚)。 +
+ +

发现汇总

+ + + + + + + + + + + + + + + +
#严重度一句话
F1P0 安全全栈控制面全程明文 HTTP:密码 / JWT / 会话轮询裸奔公网
F2P0 运维数据库SQLite 生产库零备份——单盘单机,丢了就是全部
F3P1 正确性后端+DB同一台机器换账号登录 → 设备注册永远 403 → 无法连接,且提示的自救方法无效
F4P1 正确性后端connect 下发每设备凭证,disconnect 却撤账户级凭证——断开从未真正吊销
F5P1 可靠性agent用量「取走即焚」:计数器已清零,上报失败数据永久丢(注释写 at-least-once,实为 at-most-once)
F6P1 可靠性后端Redis 重启 ≈ 全员被登出;sessions 表自称权威却不被 refresh 路径参考
F7P1 容量后端argon2id 64 MiB/次登录,1 GB 小机上一波并发登录即可 OOM
F8P2 数据库数据库sessions / audit_log 无限增长,无留存策略;按登录史全量扫描
F9P2 产品后端免费额度按 UTC 日重置 = 北京时间早上 8 点,「今日」口径与用户认知不符
F10P2 一致性全栈免费时长三个时钟各说各话:服务端分钟(有流量才计)、凭证 TTL、客户端倒计时
F11P2 可靠性后端agent 在线状态纯内存:server 重启后短窗内全体拒连
F12P2 客户端客户端单实例探测固定端口 47654 无握手:被占则 App 无法启动,任意本地进程可唤窗
F13P2 后端后端ReportUsage 多条 SQL 无事务,崩溃可留部分记账
+ +

P0 — 必须尽快处理

+ +
+

F1 · 控制面全程明文 HTTP 安全

+

现象:客户端默认 API 基址是 http://103.119.13.48:8080(裸 IP + 明文)。登录密码、 +access/refresh token、会话轮询、设备列表——所有控制面流量在公网明文传输。服务端 argon2id 只保护「存储」,保护不了「传输」。

+

影响:任何链路中间者(ISP、Wi-Fi、GFW 探测设备)可截获密码与 token 直接接管账户。对一个「主打隐私」的产品,这是与定位直接矛盾的短板;且中国链路上明文 HTTP + 可疑 payload 更易被主动探测/干扰。

+

修法:域名 + 反代 TLS(Caddy 一行配置自动 Let's Encrypt,或 nginx+certbot),客户端默认改 +https://api.<domain>;Android 移除 usesCleartextTraffic;服务端 8080 收回 loopback。 +无域名过渡期可先自签 + 客户端证书 pinning(次优)。

+
client/lib/services/api_config.dart:8 · scripts/local_test.sh:20(API_URL)· server :8080 直挂公网
+
+ +
+

F2 · SQLite 生产库零备份 运维

+

现象deploy/ 全目录无任何 backup / dump / litestream 痕迹。用户、订阅、激活码、用量全部在 +pangolin1 单机单盘的一个 SQLite 文件里。

+

影响:磁盘损坏 / 误操作 / VPS 商跑路 = 用户资产全灭,无法恢复付费用户订阅关系(直接经济损失 + 信誉损失)。这是当前全项目期望损失最大的单点。

+

修法(一晚可落地):① 最简:cron 每日 sqlite3 .backup + rclone 推 Cloudflare R2/S3 异地,保留 30 天; +② 更优:Litestream 持续复制到对象存储(秒级 RPO,内存开销可忽略,适合 1GB 小机)。恢复流程写进 runbook 并演练一次。

+
deploy/bootstrap/ · deploy/single-node/deploy.sh(均无备份任务)
+
+ +

P1 — 设计缺陷,建议排期修

+ +
+

F3 · 同机换账号 → 设备注册永远 403,连接被卡死 正确性

+

链路:客户端 device_id 一次生成、安全存储持久、跨账号复用(登出不清)。 +devices.uuid全局 UNIQUE(migration 000001),RegisterIfAbsent 遇到「uuid 已属他人」直接 +ErrForbidden;登录侧注册是 best-effort → 登录成功但设备永远注册不上;随后 +ConnectNode 因设备未注册拒发凭证,提示「请退出后重新登录以重新注册设备」——而重新登录永远解不了这个死结

+

影响:一台机器先后登两个账号(家人共用电脑、用户换号、测试机)→ 第二个账号完全无法连接,且用户按提示操作也无效。migration 16 头注释已写明「UNIQUE(uuid)→UNIQUE(user_id,uuid) 需表重建,风险隔离到单独迁移」——该迁移至今未落地,是典型的「注释里的 TODO 变成生产 bug」。

+

修法:① 落地推迟的迁移:UNIQUE(user_id, uuid)(设备身份按用户隔离,语义即「此用户的此设备」); +dp_uuid 归因按 (user,device) 查本就成立;② 或语义改「重绑」:新登录抢走设备行(转移 owner 并吊销旧主会话)——更贴近「一台设备此刻只属一个账号」的现实;③ 客户端兜底:登出时按账号命名空间存 device_id。推荐 ①+③。

+
server/internal/devices/service.go:209(ErrForbidden)· server/migrations/sqlite/000016_*.up.sql 头注释 · server/internal/httpapi/nodes.go:245(DEVICE_NOT_REGISTERED)· client/lib/services/device_identity.dart:66
+
+ +
+

F4 · disconnect 撤销的不是 connect 发出的凭证 正确性

+

现象ConnectNode 走每设备凭证 EnsureDeviceDpUUID → devDp(nodes.go:244); +DisconnectNode 却吊销账户级 ent.DpUUID 并删账户凭证行(nodes.go:371-377),且接口没有 +device_id 入参。用户主动断开从未真正吊销数据面凭证——每设备凭证在节点上一直活到 TTL(付费 24h)。

+

影响:「断开」的服务端语义失效;被移除/被强退的设备若本地还留着 sing-box 配置,断开后的 +TTL 窗口内仍可直连数据面(绕过控制面判定)。DeleteDevice 路径有自己的 revoker 是对的,但普通 disconnect 是空转。

+

修法:disconnect 请求体加 device_id,查 devDp 后吊销之;账户级 dp_uuid 作为遗留兜底再撤一次亦可。顺手给 revoke 失败加告警(现在 _ = 吞掉)。

+
server/internal/httpapi/nodes.go:244 vs 336-380
+
+ +
+

F5 · 用量「取走即焚」:上报失败 = 数据永久丢 可靠性

+

现象:v2ray 用量源 QueryStats(Reset_: true) 先清零内核计数器拿到 delta; +runUsageReportUsage 一旦失败直接 return err 拆会话重连——刚取走的这窗口数据没有任何缓冲,永久丢失。注释声称 at-least-once,实际是 at-most-once。

+

影响:控制面-agent 之间任何 gRPC 抖动(server 重启、网络闪断——每分钟一窗,天天发生)都在漏记: +免费用户少计分钟 = 变相多送时长;统计页字节数偏低。计费相关数据不该按「尽力而为」设计。

+

修法:Collect 后先并入内存 pending 缓冲,ReportUsage 成功才清;失败保留、下窗口合并重发(按 dp_uuid 累加,幂等安全);再给报文加 window_id,控制面按 (node,window_id) 去重防重发双计。缓冲上限封顶(如 1h)防内存膨胀。

+
server/internal/agentd/usage_v2ray.go:66(Reset_)· server/internal/agentd/usage.go:42-51
+
+ +
+

F6 · Redis 重启 ≈ 全员被登出;「权威」sessions 表不参与 refresh 判定 可靠性

+

现象:refresh token 白名单只活在 Redis(jwt:refresh:*)。single-node 部署用发行版默认 Redis(RDB 快照,非 AOF)——crash/重启丢最近几分钟到全部白名单 → 存量 refresh 全被拒 → 全体用户被迫重新登录。而 sessions 表注释自称「可查询的权威记录」,refresh 路径却从不回查它——两边脑裂:DB 说会话有效,Redis 说无效,以 Redis 为准。

+

影响:1GB 小机上 Redis 恰是 OOM-killer 高危对象;一次意外重启= 一次全量掉线事故 + 客服风暴。

+

修法:refresh 白名单 miss 时回查 sessions 表(jti 存在且未 revoke → 放行并回填 Redis),Redis 降级为缓存而非唯一真相;同时 single-node 部署给 Redis 开 AOF (appendonly yes) + maxmemory 上限。这也顺手消除了「强退后 Redis 删失败仍可刷新」的反向缝隙。

+
server/internal/auth/token.go:228-234 · server/internal/sessions/store.go:1-5(“authoritative”)· deploy/single-node/deploy.sh:104
+
+ +
+

F7 · argon2id 64 MiB/次登录,1 GB 机可被打 OOM 容量/安全

+

现象:argon2id 参数 64 MiB × 4 线程。登录是公开端点:~10 个并发登录请求 ≈ 640 MB 瞬时内存——机器总共 1 GB,还要跑 sing-box + agent + Redis。滑窗限流按 scope(邮箱/IP) 计,攻击者换 IP/邮箱可绕。

+

修法:给密码哈希加全局并发闸(semaphore 1–2 个并发,其余排队),几行代码把内存上限钉死在 128 MiB; +或按 OWASP 备选参数降到 19 MiB×2。限流再加全局维度(每秒总登录数)兜底。

+
server/internal/auth/password.go:19-21 · server/internal/auth/ratelimit.go
+
+ +

P2 — 结构性小患 / 口径问题

+ +
+

F8 · sessions / audit_log 无限增长,无留存策略 数据库

+

每次登录一行 sessions、永不清理;audit_log 纯追加。LastLoginByDevice 按用户全史扫描(ORDER BY created_at ASC 无 LIMIT),HasActiveSession(15s 轮询热路径)只有 user_id 单列索引可用。年级尺度上小机的磁盘与查询都会被拖住。:留存任务(revoked 会话 >90 天、audit >180 天定期删)+ 复合索引 (user_id, device_id, revoked_at);LastLogin 改每设备 MAX 子查询或维护 devices.last_login 列。

+
server/internal/sessions/store.go:54-62,134-152 · migrations 000016(仅两个单列索引)
+
+ +
+

F9 · 免费额度按 UTC 日重置(北京时间 08:00)产品

+

utcToday() / windowEnd.UTC().Truncate(24h):主力用户在国内,「今日剩余」却在早上 8 点跳变,倒计时/额度体验诡异且难解释。:额度日界定死 Asia/Shanghai(产品定位明确,不必 per-user 时区),服务端集中改 utcToday 与记账日期两处即可,客户端展示自动跟随 /me。

+
server/internal/usage/quota.go:40,55 · server/internal/nodes/handler_grpc.go:291
+
+ +
+

F10 · 免费时长三个时钟不一致 一致性

+

同一「10 分钟」有三种度量:① 服务端 minutes_used——有流量的窗口才 +1(挂着不动不扣);② 凭证 TTL——发放时定死墙钟;③ 客户端倒计时——连接起墙钟递减。后果:闲置用户被客户端切断但服务端几乎没扣分 → 重连又是满额倒计时(免费时长实际无上限,只要愿意重连);反之轻流量用户每窗口整分扣。:先定口径——推荐「连接在线即计时」(agent 按凭证存活窗口计 1 分钟,不看流量),三个时钟自然对齐;或接受现状但把客户端倒计时以 /me 剩余为准动态校正(已部分做)。

+
server/internal/agentd/usage_v2ray.go:96-101(有流量才计)· httpapi/nodes.go:235(TTL)· client connection_provider 倒计时
+
+ +
+

F11 · agent 在线状态纯内存,server 重启短窗全体拒连 可靠性

+

hub.IsOnline 是进程内 map;server 重启后到 agent 重连前,ListNodes 全灰、ConnectNode 全拒(503)。当前单节点影响秒级,可接受;但多节点后放大。:启动后给一个宽限窗(如 60s 内 unknown 视为 up),或 agent 心跳落 Redis 带 TTL。与 todo #8(掉线告警)同一片改。

+
+ +
+

F12 · 单实例探测:固定端口 47654、无握手 客户端

+

任何本地进程先占住该端口 → 真 App 启动时 bind 失败误判「已有实例」直接退出(App 无法启动且无提示);反之任意本地进程连一下就能唤起主窗(无害但脏)。:连接后交换 magic 字节验明正身,验不过改用文件锁兜底再启动;唤窗同样验 magic。

+
client/lib/system_tray.dart:15-38
+
+ +
+

F13 · ReportUsage 多条 SQL 无事务 后端

+

每设备 Accumulate + 每用户 Accumulate 是多条独立语句,中途崩溃留部分记账(设备有、账户无)。量级小、图表级偏差,配合 F5 的 window_id 幂等一起收进单事务即可。

+
server/internal/nodes/handler_grpc.go:300-359
+
+ +

做得好的(保持)

+ + +

建议处理顺序

+ + + + + +
批次理由
立刻F2(备份)→ F1(TLS)F2 一晚落地、消掉最大期望损失;F1 需要域名决策,动客户端默认值要随发版
下一迭代F3 + F4(一起动 devices/凭证接缝);F5 + F13(一起动用量链路);F6 + F7(一起动 auth 可靠性)三组各自同一片代码,一组一 PR
排队F8–F12口径决策(F9/F10)先拍板再动手;F11 并入 todo #8
+ +

备注:web/(usercenter/website)本轮未深审(改动频率与暴露面低于 server/client 核心链路); +近期已修复且验证过的不再列出:用量多设备超计(#22)、被移除设备判活(dev==nil)、弱网看门狗误伤(#18)、统计流广播订阅。 +本报告基于 worktree-macos-killswitch @ 2026-07-02。

+ +
+ + diff --git a/docs/index.html b/docs/index.html index 0e1610b..760ffae 100644 --- a/docs/index.html +++ b/docs/index.html @@ -108,6 +108,11 @@

知识库 / 调研

+ +
全栈设计审查 2026-07(前端/后端/数据库)HTML
+
核心链路精读式审查,13 项发现分 P0/P1/P2:明文 HTTP、SQLite 零备份(P0);同机换账号 403 死结、disconnect 撤错凭证、用量取走即焚、Redis 重启全员掉线、argon2id OOM(P1);留存/UTC 日界/三时钟口径等(P2)。附「做得好的」与处理顺序建议。
+
docs/code-review-2026-07.html
+
开发规范 · 可测试性五支柱 HTML
「怎么写才好测」——开发规范作为可测试性前置条件。五支柱(接缝即接口/契约单源/纯逻辑分离/错误是值/可观测)+ 支柱↔测试层咬合矩阵图 + 反例→真实bug→对应支柱对照表。与测试框架文档咬合。