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 @@ + + +
+ + +范围:server/(Go 控制面 + agent)· client/(Flutter)· 数据库 schema(migrations 1–20)· 部署脚本。方法:核心链路逐文件精读(认证 / 会话 / 连接下发 / 用量记账 / 配额卡控 / 设备管理),非全量逐行。13 项发现 P0 ×2 P1 ×5
| # | 严重度 | 域 | 一句话 |
|---|---|---|---|
| F1 | P0 安全 | 全栈 | 控制面全程明文 HTTP:密码 / JWT / 会话轮询裸奔公网 |
| F2 | P0 运维 | 数据库 | SQLite 生产库零备份——单盘单机,丢了就是全部 |
| F3 | P1 正确性 | 后端+DB | 同一台机器换账号登录 → 设备注册永远 403 → 无法连接,且提示的自救方法无效 |
| F4 | P1 正确性 | 后端 | connect 下发每设备凭证,disconnect 却撤账户级凭证——断开从未真正吊销 |
| F5 | P1 可靠性 | agent | 用量「取走即焚」:计数器已清零,上报失败数据永久丢(注释写 at-least-once,实为 at-most-once) |
| F6 | P1 可靠性 | 后端 | Redis 重启 ≈ 全员被登出;sessions 表自称权威却不被 refresh 路径参考 |
| F7 | P1 容量 | 后端 | argon2id 64 MiB/次登录,1 GB 小机上一波并发登录即可 OOM |
| F8 | P2 数据库 | 数据库 | sessions / audit_log 无限增长,无留存策略;按登录史全量扫描 |
| F9 | P2 产品 | 后端 | 免费额度按 UTC 日重置 = 北京时间早上 8 点,「今日」口径与用户认知不符 |
| F10 | P2 一致性 | 全栈 | 免费时长三个时钟各说各话:服务端分钟(有流量才计)、凭证 TTL、客户端倒计时 |
| F11 | P2 可靠性 | 后端 | agent 在线状态纯内存:server 重启后短窗内全体拒连 |
| F12 | P2 客户端 | 客户端 | 单实例探测固定端口 47654 无握手:被占则 App 无法启动,任意本地进程可唤窗 |
| F13 | P2 后端 | 后端 | ReportUsage 多条 SQL 无事务,崩溃可留部分记账 |
现象:客户端默认 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(次优)。
现象:deploy/ 全目录无任何 backup / dump / litestream 痕迹。用户、订阅、激活码、用量全部在
+pangolin1 单机单盘的一个 SQLite 文件里。
影响:磁盘损坏 / 误操作 / VPS 商跑路 = 用户资产全灭,无法恢复付费用户订阅关系(直接经济损失 + 信誉损失)。这是当前全项目期望损失最大的单点。
+修法(一晚可落地):① 最简:cron 每日 sqlite3 .backup + rclone 推 Cloudflare R2/S3 异地,保留 30 天;
+② 更优:Litestream 持续复制到对象存储(秒级 RPO,内存开销可忽略,适合 1GB 小机)。恢复流程写进 runbook 并演练一次。
链路:客户端 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。推荐 ①+③。
现象: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 失败加告警(现在 _ = 吞掉)。
现象:v2ray 用量源 QueryStats(Reset_: true) 先清零内核计数器拿到 delta;
+runUsage 里 ReportUsage 一旦失败直接 return err 拆会话重连——刚取走的这窗口数据没有任何缓冲,永久丢失。注释声称 at-least-once,实际是 at-most-once。
影响:控制面-agent 之间任何 gRPC 抖动(server 重启、网络闪断——每分钟一窗,天天发生)都在漏记: +免费用户少计分钟 = 变相多送时长;统计页字节数偏低。计费相关数据不该按「尽力而为」设计。
+修法:Collect 后先并入内存 pending 缓冲,ReportUsage 成功才清;失败保留、下窗口合并重发(按 dp_uuid 累加,幂等安全);再给报文加 window_id,控制面按 (node,window_id) 去重防重发双计。缓冲上限封顶(如 1h)防内存膨胀。
现象: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 删失败仍可刷新」的反向缝隙。
现象: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。限流再加全局维度(每秒总登录数)兜底。
+每次登录一行 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 列。
utcToday() / windowEnd.UTC().Truncate(24h):主力用户在国内,「今日剩余」却在早上 8 点跳变,倒计时/额度体验诡异且难解释。修:额度日界定死 Asia/Shanghai(产品定位明确,不必 per-user 时区),服务端集中改 utcToday 与记账日期两处即可,客户端展示自动跟随 /me。
同一「10 分钟」有三种度量:① 服务端 minutes_used——有流量的窗口才 +1(挂着不动不扣);② 凭证 TTL——发放时定死墙钟;③ 客户端倒计时——连接起墙钟递减。后果:闲置用户被客户端切断但服务端几乎没扣分 → 重连又是满额倒计时(免费时长实际无上限,只要愿意重连);反之轻流量用户每窗口整分扣。修:先定口径——推荐「连接在线即计时」(agent 按凭证存活窗口计 1 分钟,不看流量),三个时钟自然对齐;或接受现状但把客户端倒计时以 /me 剩余为准动态校正(已部分做)。
+hub.IsOnline 是进程内 map;server 重启后到 agent 重连前,ListNodes 全灰、ConnectNode 全拒(503)。当前单节点影响秒级,可接受;但多节点后放大。修:启动后给一个宽限窗(如 60s 内 unknown 视为 up),或 agent 心跳落 Redis 带 TTL。与 todo #8(掉线告警)同一片改。
任何本地进程先占住该端口 → 真 App 启动时 bind 失败误判「已有实例」直接退出(App 无法启动且无提示);反之任意本地进程连一下就能唤起主窗(无害但脏)。修:连接后交换 magic 字节验明正身,验不过改用文件锁兜底再启动;唤窗同样验 magic。
+每设备 Accumulate + 每用户 Accumulate 是多条独立语句,中途崩溃留部分记账(设备有、账户无)。量级小、图表级偏差,配合 F5 的 window_id 幂等一起收进单事务即可。
+internal/db/dialect.go):裸 SQL + 中性 Upsert/锁语义,时间 Go 端算——MySQL/SQLite 真正可切换,测试免 docker。effectiveNodeStatus),并拒绝向离线 agent「假装下发成功」——正是修过 6 天静默事故后的正确形态。| 批次 | 项 | 理由 |
|---|---|---|
| 立刻 | 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。
+ +