Files
pangolin/docs/code-review-2026-07.html
T
wangjia bbe149fd41 docs: 全栈设计审查 2026-07(前端/后端/数据库,13 项发现分级)
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 <noreply@anthropic.com>
2026-07-02 12:52:08 +08:00

217 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>全栈设计审查 2026-07(前端 / 后端 / 数据库)</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:960px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16px;margin:26px 0 8px;color:var(--accent2)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
pre{background:#0a0c11;border:1px solid var(--border);border-radius:10px;padding:14px 16px;overflow-x:auto;font-family:var(--mono);font-size:13px;line-height:1.55;color:#cdd3df}
.tag{display:inline-block;font-size:12px;font-weight:600;padding:2px 9px;border-radius:999px;vertical-align:middle}
.tag.ok{background:rgba(94,194,122,.16);color:var(--ok)}
.tag.warn{background:rgba(224,184,79,.16);color:var(--warn)}
.tag.bad{background:rgba(224,106,106,.16);color:var(--bad)}
.tag.info{background:rgba(95,176,201,.16);color:var(--accent2)}
.card{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:16px 0}
.card.p0{border-left:3px solid var(--bad)}
.card.p1{border-left:3px solid var(--warn)}
.card.p2{border-left:3px solid var(--accent2)}
.card h3{margin-top:0;color:var(--fg)}
table{width:100%;border-collapse:collapse;margin:16px 0;font-size:14px}
th,td{text-align:left;padding:9px 12px;border-bottom:1px solid var(--border);vertical-align:top}
th{color:var(--fg2);font-weight:600;font-size:13px}
td code{font-size:.85em}
ul,ol{padding-left:22px;margin:10px 0}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
a{color:var(--accent2)}
.back{display:inline-block;margin-bottom:24px;font-size:13px}
b{color:#fff}
.loc{font-family:var(--mono);font-size:12px;color:var(--fg2);margin-top:8px}
</style>
</head>
<body>
<div class="wrap">
<a class="back" href="index.html">← 返回文档索引</a>
<h1>全栈设计审查 · 2026-07</h1>
<p class="sub">范围:<code>server/</code>Go 控制面 + agent)· <code>client/</code>Flutter)· 数据库 schemamigrations 120)· 部署脚本。方法:核心链路逐文件精读(认证 / 会话 / 连接下发 / 用量记账 / 配额卡控 / 设备管理),非全量逐行。<span class="tag info">13 项发现</span> <span class="tag bad">P0 ×2</span> <span class="tag warn">P1 ×5</span></p>
<div class="lead">
总体评价:<b>架构底子是好的</b>——方言层数据库解耦、argon2id 密码、refresh 单次轮换 + Redis 白名单、gRPC mTLS、
per-device dp_uuid 归因、Lua 滑窗限流、节点三态判活(DB×agent在线×数据面健康),都属同规模项目少见的干净设计。
问题集中在<b>两条主线</b>:① 传输与数据安全的「最后一公里」没封口(明文 API、零备份);
② 多处「一次写对、后续演进没跟上」的<b>接缝漂移</b>connect 发每设备凭证 / disconnect 撤账户凭证;
设备唯一键改造写进注释却没落地;agent 用量取走即焚)。
</div>
<h2>发现汇总</h2>
<table>
<tr><th>#</th><th>严重度</th><th></th><th>一句话</th></tr>
<tr><td>F1</td><td><span class="tag bad">P0 安全</span></td><td>全栈</td><td>控制面全程明文 HTTP:密码 / JWT / 会话轮询裸奔公网</td></tr>
<tr><td>F2</td><td><span class="tag bad">P0 运维</span></td><td>数据库</td><td>SQLite 生产库零备份——单盘单机,丢了就是全部</td></tr>
<tr><td>F3</td><td><span class="tag warn">P1 正确性</span></td><td>后端+DB</td><td>同一台机器换账号登录 → 设备注册永远 403 → 无法连接,且提示的自救方法无效</td></tr>
<tr><td>F4</td><td><span class="tag warn">P1 正确性</span></td><td>后端</td><td>connect 下发每设备凭证,disconnect 却撤账户级凭证——断开从未真正吊销</td></tr>
<tr><td>F5</td><td><span class="tag warn">P1 可靠性</span></td><td>agent</td><td>用量「取走即焚」:计数器已清零,上报失败数据永久丢(注释写 at-least-once,实为 at-most-once</td></tr>
<tr><td>F6</td><td><span class="tag warn">P1 可靠性</span></td><td>后端</td><td>Redis 重启 ≈ 全员被登出;sessions 表自称权威却不被 refresh 路径参考</td></tr>
<tr><td>F7</td><td><span class="tag warn">P1 容量</span></td><td>后端</td><td>argon2id 64 MiB/次登录,1 GB 小机上一波并发登录即可 OOM</td></tr>
<tr><td>F8</td><td><span class="tag info">P2 数据库</span></td><td>数据库</td><td>sessions / audit_log 无限增长,无留存策略;按登录史全量扫描</td></tr>
<tr><td>F9</td><td><span class="tag info">P2 产品</span></td><td>后端</td><td>免费额度按 UTC 日重置 = 北京时间早上 8 点,「今日」口径与用户认知不符</td></tr>
<tr><td>F10</td><td><span class="tag info">P2 一致性</span></td><td>全栈</td><td>免费时长三个时钟各说各话:服务端分钟(有流量才计)、凭证 TTL、客户端倒计时</td></tr>
<tr><td>F11</td><td><span class="tag info">P2 可靠性</span></td><td>后端</td><td>agent 在线状态纯内存:server 重启后短窗内全体拒连</td></tr>
<tr><td>F12</td><td><span class="tag info">P2 客户端</span></td><td>客户端</td><td>单实例探测固定端口 47654 无握手:被占则 App 无法启动,任意本地进程可唤窗</td></tr>
<tr><td>F13</td><td><span class="tag info">P2 后端</span></td><td>后端</td><td>ReportUsage 多条 SQL 无事务,崩溃可留部分记账</td></tr>
</table>
<h2>P0 — 必须尽快处理</h2>
<div class="card p0">
<h3>F1 · 控制面全程明文 HTTP <span class="tag bad">安全</span></h3>
<p><b>现象</b>:客户端默认 API 基址是 <code>http://103.119.13.48:8080</code>(裸 IP + 明文)。登录密码、
access/refresh token、会话轮询、设备列表——所有控制面流量在公网明文传输。服务端 argon2id 只保护「存储」,保护不了「传输」。</p>
<p><b>影响</b>:任何链路中间者(ISP、Wi-Fi、GFW 探测设备)可截获密码与 token 直接接管账户。对一个「主打隐私」的产品,这是与定位直接矛盾的短板;且中国链路上明文 HTTP + 可疑 payload 更易被主动探测/干扰。</p>
<p><b>修法</b>:域名 + 反代 TLSCaddy 一行配置自动 Let's Encrypt,或 nginx+certbot),客户端默认改
<code>https://api.&lt;domain&gt;</code>Android 移除 <code>usesCleartextTraffic</code>;服务端 8080 收回 loopback。
无域名过渡期可先自签 + 客户端证书 pinning(次优)。</p>
<div class="loc">client/lib/services/api_config.dart:8 · scripts/local_test.sh:20API_URL)· server :8080 直挂公网</div>
</div>
<div class="card p0">
<h3>F2 · SQLite 生产库零备份 <span class="tag bad">运维</span></h3>
<p><b>现象</b><code>deploy/</code> 全目录无任何 backup / dump / litestream 痕迹。用户、订阅、激活码、用量全部在
pangolin1 单机单盘的一个 SQLite 文件里。</p>
<p><b>影响</b>:磁盘损坏 / 误操作 / VPS 商跑路 = 用户资产全灭,无法恢复付费用户订阅关系(直接经济损失 + 信誉损失)。这是当前全项目期望损失最大的单点。</p>
<p><b>修法</b>(一晚可落地):① 最简:cron 每日 <code>sqlite3 .backup</code> + <code>rclone</code> 推 Cloudflare R2/S3 异地,保留 30 天;
② 更优:Litestream 持续复制到对象存储(秒级 RPO,内存开销可忽略,适合 1GB 小机)。恢复流程写进 runbook 并演练一次。</p>
<div class="loc">deploy/bootstrap/ · deploy/single-node/deploy.sh(均无备份任务)</div>
</div>
<h2>P1 — 设计缺陷,建议排期修</h2>
<div class="card p1">
<h3>F3 · 同机换账号 → 设备注册永远 403,连接被卡死 <span class="tag warn">正确性</span></h3>
<p><b>链路</b>:客户端 <code>device_id</code> 一次生成、安全存储持久、<b>跨账号复用</b>(登出不清)。
<code>devices.uuid</code><b>全局 UNIQUE</b>migration 000001),<code>RegisterIfAbsent</code> 遇到「uuid 已属他人」直接
<code>ErrForbidden</code>;登录侧注册是 best-effort → <b>登录成功但设备永远注册不上</b>;随后
<code>ConnectNode</code> 因设备未注册拒发凭证,提示「请退出后重新登录以重新注册设备」——<b>而重新登录永远解不了这个死结</b></p>
<p><b>影响</b>:一台机器先后登两个账号(家人共用电脑、用户换号、测试机)→ 第二个账号完全无法连接,且用户按提示操作也无效。migration 16 头注释已写明「UNIQUE(uuid)→UNIQUE(user_id,uuid) 需表重建,风险隔离到单独迁移」——<b>该迁移至今未落地</b>,是典型的「注释里的 TODO 变成生产 bug」。</p>
<p><b>修法</b>:① 落地推迟的迁移:<code>UNIQUE(user_id, uuid)</code>(设备身份按用户隔离,语义即「此用户的此设备」);
dp_uuid 归因按 (user,device) 查本就成立;② 或语义改「重绑」:新登录抢走设备行(转移 owner 并吊销旧主会话)——更贴近「一台设备此刻只属一个账号」的现实;③ 客户端兜底:登出时按账号命名空间存 device_id。推荐 ①+③。</p>
<div class="loc">server/internal/devices/service.go:209ErrForbidden)· server/migrations/sqlite/000016_*.up.sql 头注释 · server/internal/httpapi/nodes.go:245DEVICE_NOT_REGISTERED)· client/lib/services/device_identity.dart:66</div>
</div>
<div class="card p1">
<h3>F4 · disconnect 撤销的不是 connect 发出的凭证 <span class="tag warn">正确性</span></h3>
<p><b>现象</b><code>ConnectNode</code> 走每设备凭证 <code>EnsureDeviceDpUUID → devDp</code>nodes.go:244);
<code>DisconnectNode</code> 却吊销<b>账户级</b> <code>ent.DpUUID</code> 并删账户凭证行(nodes.go:371-377),且接口没有
<code>device_id</code> 入参。<b>用户主动断开从未真正吊销数据面凭证</b>——每设备凭证在节点上一直活到 TTL(付费 24h)。</p>
<p><b>影响</b>:「断开」的服务端语义失效;被移除/被强退的设备若本地还留着 sing-box 配置,断开后的
TTL 窗口内仍可直连数据面(绕过控制面判定)。DeleteDevice 路径有自己的 revoker 是对的,但普通 disconnect 是空转。</p>
<p><b>修法</b>disconnect 请求体加 <code>device_id</code>,查 <code>devDp</code> 后吊销之;账户级 dp_uuid 作为遗留兜底再撤一次亦可。顺手给 revoke 失败加告警(现在 <code>_ =</code> 吞掉)。</p>
<div class="loc">server/internal/httpapi/nodes.go:244 vs 336-380</div>
</div>
<div class="card p1">
<h3>F5 · 用量「取走即焚」:上报失败 = 数据永久丢 <span class="tag warn">可靠性</span></h3>
<p><b>现象</b>v2ray 用量源 <code>QueryStats(Reset_: true)</code> <b>先清零内核计数器</b>拿到 delta
<code>runUsage</code><code>ReportUsage</code> 一旦失败直接 <code>return err</code> 拆会话重连——<b>刚取走的这窗口数据没有任何缓冲,永久丢失</b>。注释声称 at-least-once,实际是 at-most-once。</p>
<p><b>影响</b>:控制面-agent 之间任何 gRPC 抖动(server 重启、网络闪断——每分钟一窗,天天发生)都在漏记:
免费用户少计分钟 = 变相多送时长;统计页字节数偏低。计费相关数据不该按「尽力而为」设计。</p>
<p><b>修法</b>Collect 后先并入内存 pending 缓冲,ReportUsage 成功才清;失败保留、下窗口合并重发(按 dp_uuid 累加,幂等安全);再给报文加 <code>window_id</code>,控制面按 (node,window_id) 去重防重发双计。缓冲上限封顶(如 1h)防内存膨胀。</p>
<div class="loc">server/internal/agentd/usage_v2ray.go:66Reset_)· server/internal/agentd/usage.go:42-51</div>
</div>
<div class="card p1">
<h3>F6 · Redis 重启 ≈ 全员被登出;「权威」sessions 表不参与 refresh 判定 <span class="tag warn">可靠性</span></h3>
<p><b>现象</b>refresh token 白名单只活在 Redis<code>jwt:refresh:*</code>)。single-node 部署用发行版默认 RedisRDB 快照,非 AOF)——crash/重启丢最近几分钟到全部白名单 → 存量 refresh 全被拒 → <b>全体用户被迫重新登录</b>。而 sessions 表注释自称「可查询的权威记录」,refresh 路径却从不回查它——两边脑裂:DB 说会话有效,Redis 说无效,以 Redis 为准。</p>
<p><b>影响</b>1GB 小机上 Redis 恰是 OOM-killer 高危对象;一次意外重启= 一次全量掉线事故 + 客服风暴。</p>
<p><b>修法</b>refresh 白名单 miss 时<b>回查 sessions 表</b>jti 存在且未 revoke → 放行并回填 Redis),Redis 降级为缓存而非唯一真相;同时 single-node 部署给 Redis 开 AOF (<code>appendonly yes</code>) + <code>maxmemory</code> 上限。这也顺手消除了「强退后 Redis 删失败仍可刷新」的反向缝隙。</p>
<div class="loc">server/internal/auth/token.go:228-234 · server/internal/sessions/store.go:1-5(“authoritative”)· deploy/single-node/deploy.sh:104</div>
</div>
<div class="card p1">
<h3>F7 · argon2id 64 MiB/次登录,1 GB 机可被打 OOM <span class="tag warn">容量/安全</span></h3>
<p><b>现象</b>argon2id 参数 64 MiB × 4 线程。登录是公开端点:~10 个并发登录请求 ≈ 640 MB 瞬时内存——机器总共 1 GB,还要跑 sing-box + agent + Redis。滑窗限流按 scope(邮箱/IP) 计,攻击者换 IP/邮箱可绕。</p>
<p><b>修法</b>:给密码哈希加<b>全局并发闸</b>semaphore 1–2 个并发,其余排队),几行代码把内存上限钉死在 128 MiB;
或按 OWASP 备选参数降到 19 MiB×2。限流再加全局维度(每秒总登录数)兜底。</p>
<div class="loc">server/internal/auth/password.go:19-21 · server/internal/auth/ratelimit.go</div>
</div>
<h2>P2 — 结构性小患 / 口径问题</h2>
<div class="card p2">
<h3>F8 · sessions / audit_log 无限增长,无留存策略 <span class="tag info">数据库</span></h3>
<p>每次登录一行 sessions、永不清理;audit_log 纯追加。<code>LastLoginByDevice</code> 按用户<b>全史扫描</b>ORDER BY created_at ASC 无 LIMIT),<code>HasActiveSession</code>(15s 轮询热路径)只有 user_id 单列索引可用。年级尺度上小机的磁盘与查询都会被拖住。<b></b>:留存任务(revoked 会话 &gt;90 天、audit &gt;180 天定期删)+ 复合索引 <code>(user_id, device_id, revoked_at)</code>LastLogin 改每设备 MAX 子查询或维护 devices.last_login 列。</p>
<div class="loc">server/internal/sessions/store.go:54-62,134-152 · migrations 000016(仅两个单列索引)</div>
</div>
<div class="card p2">
<h3>F9 · 免费额度按 UTC 日重置(北京时间 08:00)<span class="tag info">产品</span></h3>
<p><code>utcToday()</code> / <code>windowEnd.UTC().Truncate(24h)</code>:主力用户在国内,「今日剩余」却在早上 8 点跳变,倒计时/额度体验诡异且难解释。<b></b>:额度日界定死 <code>Asia/Shanghai</code>(产品定位明确,不必 per-user 时区),服务端集中改 <code>utcToday</code> 与记账日期两处即可,客户端展示自动跟随 /me。</p>
<div class="loc">server/internal/usage/quota.go:40,55 · server/internal/nodes/handler_grpc.go:291</div>
</div>
<div class="card p2">
<h3>F10 · 免费时长三个时钟不一致 <span class="tag info">一致性</span></h3>
<p>同一「10 分钟」有三种度量:① 服务端 minutes_used——<b>有流量的窗口才 +1</b>(挂着不动不扣);② 凭证 TTL——发放时定死墙钟;③ 客户端倒计时——连接起墙钟递减。后果:闲置用户被客户端切断但服务端几乎没扣分 → 重连又是满额倒计时(免费时长实际无上限,只要愿意重连);反之轻流量用户每窗口整分扣。<b></b>:先定口径——推荐「连接在线即计时」(agent 按凭证存活窗口计 1 分钟,不看流量),三个时钟自然对齐;或接受现状但把客户端倒计时以 /me 剩余为准动态校正(已部分做)。</p>
<div class="loc">server/internal/agentd/usage_v2ray.go:96-101(有流量才计)· httpapi/nodes.go:235TTL)· client connection_provider 倒计时</div>
</div>
<div class="card p2">
<h3>F11 · agent 在线状态纯内存,server 重启短窗全体拒连 <span class="tag info">可靠性</span></h3>
<p><code>hub.IsOnline</code> 是进程内 mapserver 重启后到 agent 重连前,ListNodes 全灰、ConnectNode 全拒(503)。当前单节点影响秒级,可接受;但多节点后放大。<b></b>:启动后给一个宽限窗(如 60s 内 unknown 视为 up),或 agent 心跳落 Redis 带 TTL。与 todo #8(掉线告警)同一片改。</p>
</div>
<div class="card p2">
<h3>F12 · 单实例探测:固定端口 47654、无握手 <span class="tag info">客户端</span></h3>
<p>任何本地进程先占住该端口 → 真 App 启动时 bind 失败误判「已有实例」直接退出(<b>App 无法启动且无提示</b>);反之任意本地进程连一下就能唤起主窗(无害但脏)。<b></b>:连接后交换 magic 字节验明正身,验不过改用文件锁兜底再启动;唤窗同样验 magic。</p>
<div class="loc">client/lib/system_tray.dart:15-38</div>
</div>
<div class="card p2">
<h3>F13 · ReportUsage 多条 SQL 无事务 <span class="tag info">后端</span></h3>
<p>每设备 Accumulate + 每用户 Accumulate 是多条独立语句,中途崩溃留部分记账(设备有、账户无)。量级小、图表级偏差,配合 F5 的 window_id 幂等一起收进单事务即可。</p>
<div class="loc">server/internal/nodes/handler_grpc.go:300-359</div>
</div>
<h2>做得好的(保持)</h2>
<ul>
<li><b>方言层</b><code>internal/db/dialect.go</code>):裸 SQL + 中性 Upsert/锁语义,时间 Go 端算——MySQL/SQLite 真正可切换,测试免 docker。</li>
<li><b>认证栈</b>argon2id + RS256 双 kid 轮换 + refresh 单次使用轮换 + typ 声明防混用,教科书级。</li>
<li><b>节点判活</b>DB 状态 × agent gRPC 在线 × 数据面健康三合一(<code>effectiveNodeStatus</code>),并拒绝向离线 agent「假装下发成功」——正是修过 6 天静默事故后的正确形态。</li>
<li><b>per-device dp_uuid + v2ray per-user 计数</b>:归因链路是准的(老 clash 均摊源已弃用、仅遗留代码)。</li>
<li><b>免费额度账户级共享 + 凭证 TTL 硬切断</b>:卡控在服务端成立,客户端绕过也兜得住。</li>
<li><b>迁移成对成套</b>mysql/sqlite 各一份 up/down),审查期未见漂移。</li>
</ul>
<h2>建议处理顺序</h2>
<table>
<tr><th>批次</th><th></th><th>理由</th></tr>
<tr><td><b>立刻</b></td><td>F2(备份)→ F1TLS</td><td>F2 一晚落地、消掉最大期望损失;F1 需要域名决策,动客户端默认值要随发版</td></tr>
<tr><td><b>下一迭代</b></td><td>F3 + F4(一起动 devices/凭证接缝);F5 + F13(一起动用量链路);F6 + F7(一起动 auth 可靠性)</td><td>三组各自同一片代码,一组一 PR</td></tr>
<tr><td><b>排队</b></td><td>F8F12</td><td>口径决策(F9/F10)先拍板再动手;F11 并入 todo #8</td></tr>
</table>
<p class="small">备注:web/usercenter/website)本轮未深审(改动频率与暴露面低于 server/client 核心链路);
近期已修复且验证过的不再列出:用量多设备超计(#22)、被移除设备判活(dev==nil)、弱网看门狗误伤(#18)、统计流广播订阅。
本报告基于 worktree-macos-killswitch @ 2026-07-02。</p>
</div>
</body>
</html>