diff --git a/.gitignore b/.gitignore index 34e996d..2ad0afe 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ trae-tokens-local/ deploy_commit.sh commit_restore.sh rebuild_commit.sh + +# Python 字节码(不应入库) +__pycache__/ +*.pyc diff --git a/AUDIT-2026-09-15.md b/AUDIT-2026-09-15.md new file mode 100644 index 0000000..d3d5c91 --- /dev/null +++ b/AUDIT-2026-09-15.md @@ -0,0 +1,282 @@ +# 独立审计报告(2026-09-15) + +> 本报告由独立代码审计产出,**不依赖** `TECHNICAL-DOC.md` 的既有结论。 +> 审计范围:`src/` 全部 Go 生产代码(8320 行)+ `login_ui.py`(89KB)。 +> 每条发现均经**代码定位**或**可复现实验**验证;未验证的推测已明确标注。 + +--- + +## 结论摘要 + +| 严重度 | 数量 | 说明 | +|---|---|---| +| 🔴 高 | 3 | 需尽快处理 | +| 🟡 中 | 4 | 建议排期 | +| 🟢 低 | 3 | 维护性 | + +**与 `TECHNICAL-DOC.md` 的差异**:该文档 12 条发现中,**1 条经实测为误判** +(见 §4),另有 **3 条本报告新增**(其未覆盖)。详见 §4 交叉比对。 + +--- + +## 🔴 高风险 + +### H1. 跨进程并发写凭证文件,可能永久丢失 refreshToken + +**位置**:`src/internal/auth/auth.go:204` + +```go +tmp := a.FilePath + ".tmp" // 固定文件名 +os.WriteFile(tmp, raw, 0o600) +os.Rename(tmp, a.FilePath) +``` + +**问题**:`Auth.mu` 只保护**同进程内**。而实际架构是: + +| 进程 | 触发路径 | 位置 | +|---|---|---| +| `serverd` | 请求前预刷新 | `handler.go:472` | +| `serverd` | 定时保活 | `scheduler.go:374/414` | +| **`ctl`(独立进程)** | 面板点「刷新令牌」 | `ctl/main.go:317` | + +两个进程各自持有独立的 `Auth` 实例与独立的 `mu`,**进程间无互斥**; +且 tmp 文件名固定 → 两进程会写同一个临时文件。 + +**实测复现**(并发 400 次写入): + +``` +错误数: 3 + 错误类型: rename .../auth.json.tmp .../auth.json: no such file or directory +``` + +**最坏后果**(确定性复现):上游 refresh **会轮换 refreshToken**,旧的即失效。 + +``` +落盘的 refreshToken: RT-1 +→ 落盘的是较早的 RT-1;真正的 RT-2 已丢失 +→ RT-1 可能已被上游轮换失效 → 下次刷新失败 → 需重新登录 +``` + +**注意**:文件**不会损坏**(`rename` 是原子的),丢的是**逻辑上的新值**。 + +**建议**: +- tmp 名加随机后缀(`os.CreateTemp` 同目录),消除写交错; +- 或加文件锁 `syscall.Flock`(跨进程互斥); +- 或统一刷新权威:`ctl` 只读,刷新由 `serverd` 独占。 + +--- + +### H2. 面板默认无登录,管理接口对**全网段**开放 + +**位置**:`login_ui.py:600-615`、`config.yaml:32-33` + +**链路**: + +1. `config.yaml` 默认 `webui_user: ""` / `webui_pass: ""` +2. `load_options()` 中 `""` **不是 `None`**,因此**覆盖**掉 + `DEFAULT_OPTIONS` 里的 `admin/admin`: + +```python +for k in DEFAULT_OPTIONS: + if k in data and data[k] is not None: # "" 通过此判断 + opts[k] = data[k] +``` + +3. 结果 `_webui_enabled() == False` +4. `_mgmt_authorized()` 回退到 `_is_local()` +5. `_is_local()` 对 `172.30.*` / `192.168.*` / `10.*` **一律放行** +6. 而 `config.yaml` 有 `ports: 7870/tcp: 7870` → **端口映射到宿主机** + +**后果**:局域网内任意设备访问 `http://:7870/api/*` 即可**无认证** +调用管理接口(含账号列表、刷新令牌、改配置)。 + +**为何 ingress 与 LAN 无法区分**:两者源 IP **都是内网** +(ingress 来自 `172.30.x.x`,LAN 来自 `192.168.x.x`), +`_is_local()` 仅按 IP 段判断,无法分辨。 + +**建议**: +- `config.yaml` 默认不要给空串,或在面板首次启动时**强制生成随机密码**并打印到日志; +- 或移除 `7870/tcp` 的宿主机端口映射(仅走 ingress)。 + +**当前缓解**:`_mgmt_authorized` 已正确实现、且 `ports_description` 有说明, +属"默认配置不安全"而非"代码缺陷"。 + +--- + +### H3. 流式响应中途的上游错误**未识别**,会透传非 SSE 内容 + +**位置**:`src/internal/upstream/sse.go:196`(`Stream`) + +`Stream` 只做**逐行透传**,不解析内容: + +```go +line, err := br.ReadString('\n') +if line != "" { + io.WriteString(w, line) // 原样写出 +``` + +而上游**可能在 HTTP 200 的 body 里报错** —— 这一点由本仓库自己的代码佐证: + +```go +// client.go:26 +ErrHardCredit = provider.ErrHardCredit // 余额不足(402 或 body 关键词)→ 长冷却 +// client.go:47-55 Classify 对 body 关键词做判断 +``` + +`Classify` 在 `handler.go:485` **只在 `status >= 400` 时调用**。 +若上游以 200 + 错误 JSON 响应(单行、无 `data: ` 前缀): + +``` +客户端收到: {"code":1,"msg":"余额不足"} +而非: data: {...}\n\n +``` + +→ OpenAI SDK 解析失败或静默忽略;且**该账号不会被冷却**(未走 Classify), +后续请求继续选中它。 + +**建议**:`Stream` 首行做嗅探 —— 若非 `data: ` 开头,尝试 `Classify` 并按错误处理, +而不是盲目透传。 + +**未验证部分**:上游是否真的会以 200 返回错误 body,**未实测**(需真实账号)。 +但代码中的 `Classify` 已为 body 关键词做了判断,说明作者认为存在此形态。 + +--- + +## 🟡 中风险 + +### M1. 会话 token 可预测,且全局共享单一会话 + +**位置**:`login_ui.py:123-131` + +```python +tok = hashlib.sha256(("%f|%d" % (time.time(), id(SESSION_FILE))).encode()).hexdigest() +``` + +- 熵来源仅 `time.time()`(可猜)+ `id()`(同进程常量) +- **未使用** `secrets` / `os.urandom` +- 单一 `SESSION_FILE` → **全局一个 token**,换人登录顶掉前一会话 + +**建议**:`secrets.token_urlsafe(32)`;如需多会话,改为 token→user 的映射。 + +**实际风险有限**:登录接口有 IP 限流(5 分钟),且需先通过密码。 + +--- + +### M2. `Stream` 的错误被丢弃 + +**位置**:`handler.go:514` + +```go +_ = rt.Upstream.Stream(w, rc) // 返回值被忽略 +``` + +流中途断开(客户端关闭、上游中断)时无任何日志,**排障无迹可循**。 + +--- + +### M3. 无访问日志 / 请求 ID + +`TECHNICAL-DOC` 亦提及。面板的 `log_message` 被禁用 +(`login_ui.py`),`serverd` 仅 `log.Printf` 到 stderr。 +出问题时无法关联"哪个请求对应哪条日志"。 + +--- + +### M4. `login_ui.py` 89KB 单文件、零测试 + +面板 + 反向代理 + 子进程管理 + 全部 HTML/CSS/JS 内嵌于一个文件, +**无任何测试**。任何改动都是盲改。 + +**建议**:至少把 HTML/JS 模板外置,Python 部分按职责拆分。 + +--- + +## 🟢 低风险 / 维护性 + +### L1. `go.mod` 声明未使用的 GUI 依赖 + +`wailsapp/wails/v2`、`energye/systray` 在 addon 编译路径 +(`serverd`/`ctl`/`login`/`logintrae`)中**均未使用**, +徒增构建体积与 `go.sum`。疑似桌面版 fork 残留。 + +### L2. `cmd/login/main.go` 与 `internal/login/login.go` 重复实现 + +WorkBuddy OAuth 逻辑存在两份。 + +### L3. 测试覆盖缺口 + +现有 8 个测试文件覆盖 auth/config/pool/scheduler/server/upstream/traework, +**缺口**: +- 登录流程(PKCE 交换、回调解析)—— 无单测 +- 跨进程集成(面板 ↔ `serverd`)—— 无测试 +- `login_ui.py` —— 零测试 + +--- + +## §4. 与 `TECHNICAL-DOC.md` 的交叉比对 + +### ❌ 该文档的 1 条**误判** + +> **原文(第 3 条,🔴 高)**:"TraeWork 任意 401 → 永久禁用账号 ... +> `handler` 据此 `Pool.Disable`(永久)... **TraeWork 侧 chat 路径未先 refresh**" + +**实测更正**:`handler.go:433` 的预刷新是**平台无关**的, +`rt` 对两个平台是同一个 `provider.Upstream` 接口(`provider.go:87` 定义 `RefreshToken`)。 + +```go +if acct.NeedsRefresh(h.refreshSkewFor(rt.Kind)) { // ← 已按平台取值 + rt.Upstream.RefreshToken(acct) +``` + +**真实问题是"窗口过窄"**(10min vs traework2api 的 24h),**不是"没刷新"**。 +已于本日修复(`RefreshSkew` 按平台区分)。 + +### ✅ 该文档 5 条经本报告**独立确认** + +| 文档结论 | 本报告验证 | +|---|---| +| 时区 UTC 导致签到偏移 | ✅ 确认(已修) | +| TraeWork 强依赖非官方 host | ✅ 确认(代码中 `AgentHost` 硬编码) | +| WorkBuddy 流式 120s 截断 | ✅ 确认(已修) | +| 默认无 API 鉴权 | ⚠️ **部分确认**,见下 | +| `ctl` 与 `serverd` 并发刷新 | ✅ 确认,且**本报告复现了数据丢失**(H1) | + +### ⚠️ 该文档 1 条需**修正表述** + +> **原文(第 5 条,🟡 中)**:"默认 `api_key=""` → `serverd` 不校验。**仅由 HA ingress 提供外层鉴权**" + +**不准确之处**:面板层(`login_ui.py:694-703`)**也**做了 `api_key` 校验, +并非"仅靠 ingress"。且 ingress 只是"外层之一"。 + +**但本报告发现了更严重的问题(H2)**:当 `api_key` 与 `webui` 凭据**都**留空时, +管理接口对内网**完全开放** —— 这是该文档未指出的。 + +### ➕ 本报告**新增** 3 条(该文档未覆盖) + +- **H3** 流式中途错误未识别(透传非 SSE 内容) +- **M1** 会话 token 可预测 + 全局单会话 +- **M2** `Stream` 错误被丢弃 + +--- + +## §5. 建议处理顺序 + +| 优先级 | 项 | 理由 | +|---|---|---| +| 1 | **H2** | 默认配置即暴露管理接口,影响所有用户 | +| 2 | **H1** | 已在实验中复现数据丢失,且触发条件是正常使用 | +| 3 | **H3** | 影响错误处理正确性;但需先确认上游真实行为 | +| 4 | M1 / M4 | 安全加固与可维护性 | +| 5 | L1 / L2 | 清理 | + +--- + +## §6. 审计方法说明 + +- **静态阅读**:`auth` / `pool` / `server` / `upstream` / `traework` / scheduler 全量 +- **可复现实验**:H1 的并发写竞态用最小 Go 程序复现(含数据丢失场景) +- **配置链路推演**:H2 的 `""` vs `None` 覆盖行为用 Python 复现 +- **未验证项已显式标注**:H3 的上游 200+错误body 形态无真实账号验证 + +**未覆盖**:`internal/qoder/*`(443+247+209+168 行)仅做扫描, +未逐行审计;`internal/login_trae`(352+198 行)同上。 diff --git a/__pycache__/login_ui.cpython-311.pyc b/__pycache__/login_ui.cpython-311.pyc deleted file mode 100644 index f81b0e0..0000000 Binary files a/__pycache__/login_ui.cpython-311.pyc and /dev/null differ diff --git a/login_ui.py b/login_ui.py index a724fe2..3680db4 100644 --- a/login_ui.py +++ b/login_ui.py @@ -597,23 +597,48 @@ def _client_ip(self): """返回直连客户端 IP(ingress 由 HA 反代本地转发,来源恒为本机/容器网段)。""" return self.client_address[0] if self.client_address else "" - def _is_local(self): - """HA ingress 转发与本机访问均为本地来源;其余视为公网直连。""" + def _is_loopback(self): + """仅本机回环(ingress 反代与容器内自调用)。""" ip = self._client_ip() - if ip in ("127.0.0.1", "::1", "localhost", ""): - return True - if ip.startswith("172.30.") or ip.startswith("172.16.") or ip.startswith("192.168.") or ip.startswith("10."): + return ip in ("127.0.0.1", "::1", "localhost", "") + + def _is_private(self): + """容器/内网网段(含 HA 的 172.30.x 与常见 LAN 段)。""" + ip = self._client_ip() + if self._is_loopback(): return True - return False + return (ip.startswith("172.30.") or ip.startswith("172.16.") + or ip.startswith("192.168.") or ip.startswith("10.")) + + def _is_ingress(self): + """是否为 HA ingress 转发请求。 + + ingress 会把请求转发到 127.0.0.1 并在路径上保留 + /api/hassio_ingress// 前缀(与第 1081 行的同款判断一致)。 + """ + path = self.path or "" + return path.startswith("/api/hassio_ingress/") or "/hassio_ingress/" in path def _mgmt_authorized(self): - """管理接口鉴权:已登录会话,或(面板未启用登录时)仅限本地来源。 - 防止 webui 凭据留空时管理接口经公网 7870 裸奔。""" + """管理接口鉴权(按可信度从高到低)。 + + 1. 有有效登录会话 → 放行; + 2. 面板**未启用**登录时: + - ingress 转发(HA 已做认证)→ 放行; + - 本机回环 → 放行; + - **其它内网来源(LAN 直连)→ 拒绝**。 + + 第 2 条的 LAN 分支是安全修复:此前把所有内网段一并放行, + 而 config.yaml 默认 webui 凭据为空、且 7870 端口映射到宿主机, + 导致同网段任意设备可无认证调用管理接口 + (含账号列表、刷新令牌、修改配置)。 + """ if _webui_check_cookie(self._cookie()): return True - if not _webui_enabled(): - return self._is_local() - return False + if _webui_enabled(): + return False + # 未启用登录:只信任 ingress 与本机,不再信任整个内网 + return self._is_ingress() or self._is_loopback() def _handle_login_get(self): diff --git a/scripts/test_mgmt_auth.py b/scripts/test_mgmt_auth.py new file mode 100755 index 0000000..9abaf53 --- /dev/null +++ b/scripts/test_mgmt_auth.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +"""验证管理接口鉴权(H2 修复)。 + +问题背景:config.yaml 默认 webui_user/pass 为空 → 面板登录未启用, +此时 _mgmt_authorized() 回退到按来源 IP 判断。原实现把**整个内网** +(192.168.* / 10.* / 172.16.* / 172.30.*)都视为可信,而 7870 端口 +映射到宿主机 → 同网段任意设备可无认证调用管理接口。 + +修复后:未启用登录时只信任 **ingress 转发** 与 **本机回环**。 + +本脚本不启动服务,直接对鉴权函数做真值表验证。 +用法: python3 scripts/test_mgmt_auth.py +""" +import sys + +# ── 被验证的实现(从 login_ui.py 抽取的等价逻辑)────────────────── + +def is_loopback(ip): + return ip in ("127.0.0.1", "::1", "localhost", "") + + +def is_private(ip): + if is_loopback(ip): + return True + return (ip.startswith("172.30.") or ip.startswith("172.16.") + or ip.startswith("192.168.") or ip.startswith("10.")) + + +def is_ingress(path): + return path.startswith("/api/hassio_ingress/") or "/hassio_ingress/" in path + + +# 修复前 +def old_mgmt_authorized(ip, path, webui_enabled, has_session): + if has_session: + return True + if not webui_enabled: + return is_private(ip) # ← 整个内网都放行 + return False + + +# 修复后 +def new_mgmt_authorized(ip, path, webui_enabled, has_session): + if has_session: + return True + if webui_enabled: + return False + return is_ingress(path) or is_loopback(ip) + + +# ── 真值表 ──────────────────────────────────────────────────────── + +CASES = [ + # (说明, ip, path, webui_enabled, has_session, 期望新行为) + ("ingress 转发(HA 已认证)", "127.0.0.1", "/api/hassio_ingress/TOK/api/accounts", False, False, True), + ("本机回环直连", "127.0.0.1", "/api/accounts", False, False, True), + ("容器内自调用", "172.30.32.1", "/api/accounts", False, False, False), # 非 ingress 路径 → 拒 + ("LAN 直连 192.168.x", "192.168.1.50", "/api/accounts", False, False, False), + ("LAN 直连 10.x", "10.0.0.9", "/api/accounts", False, False, False), + ("公网直连", "203.0.113.7", "/api/accounts", False, False, False), + + ("已登录会话(ingress)", "127.0.0.1", "/api/hassio_ingress/TOK/api/accounts", False, True, True), + ("已登录会话(LAN)", "192.168.1.50", "/api/accounts", False, True, True), + + ("启用登录 + 无会话(LAN)", "192.168.1.50", "/api/accounts", True, False, False), + ("启用登录 + 无会话(ingress)", "127.0.0.1", "/api/hassio_ingress/TOK/api/accounts", True, False, False), + ("启用登录 + 有会话", "192.168.1.50", "/api/accounts", True, True, True), +] + + +def main(): + print("=" * 78) + print("管理接口鉴权真值表:修复前 vs 修复后") + print("=" * 78) + print(f"{'场景':<34} {'来源':<16} {'旧':<6} {'新':<6} {'期望':<6} 结果") + print("-" * 78) + + failed = 0 + for desc, ip, path, wen, sess, want in CASES: + old = old_mgmt_authorized(ip, path, wen, sess) + new = new_mgmt_authorized(ip, path, wen, sess) + ok = new == want + if not ok: + failed += 1 + mark = "✅" if ok else "❌" + print(f"{desc:<34} {ip:<16} {str(old):<6} {str(new):<6} {str(want):<6} {mark}") + + print("-" * 78) + print() + print("关键差异(旧→新):") + print(" LAN 直连 192.168.x / 10.x : 旧=True(开放) → 新=False(拒绝) ← 修复点") + print(" ingress 转发 : 旧=True → 新=True (保留)") + print(" 本机回环 : 旧=True → 新=True (保留)") + print() + + if failed: + print(f"❌ {failed} 个用例未达期望") + return 1 + print(f"✅ 全部 {len(CASES)} 个用例通过") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/internal/atomicfile/atomicfile.go b/src/internal/atomicfile/atomicfile.go new file mode 100644 index 0000000..228e655 --- /dev/null +++ b/src/internal/atomicfile/atomicfile.go @@ -0,0 +1,97 @@ +// Package atomicfile 提供跨进程安全的原子文件写入。 +// +// 背景(真实缺陷,非理论风险): +// +// 原实现用【固定的】临时文件名 "path.tmp": +// +// tmp := path + ".tmp" +// os.WriteFile(tmp, raw, 0o600) +// os.Rename(tmp, path) +// +// 单进程内由互斥锁保护时没问题,但本项目的实际架构是**多进程**: +// +// serverd —— 请求前预刷新 / 定时保活,写 auths/*.json +// ctl —— 独立子进程,面板点「刷新令牌」时也写同一文件 +// +// 两个进程各有自己的内存副本与锁,**进程间无互斥**,于是: +// +// 1. 双方写同一个 tmp 文件 → 内容交错; +// 2. 一方先 rename 走,另一方 rename 时 tmp 已不存在 → +// "no such file or directory"(实测并发 400 次出现 3 次); +// 3. 更严重的是**逻辑丢失**:上游 refresh 会轮换 refreshToken, +// 两个进程各拿到一个新值,后落盘者覆盖先落盘者。 +// 若落盘的是已被轮换失效的那个,账号下次刷新即失败,需人工重登。 +// +// 文件本身不会损坏(rename 是原子的),丢的是"较新的那个值"。 +// +// 本包的做法: +// - 临时文件用 os.CreateTemp 生成**唯一名字**(消除写交错与 rename 竞争); +// - 写入前对目标文件路径加**跨进程文件锁**(flock),串行化同一路径的写入; +// - rename 后 fsync 目录,保证崩溃后不丢已提交内容(尽力而为)。 +package atomicfile + +import ( + "fmt" + "os" + "path/filepath" +) + +// WriteFile 原子地把 data 写入 path(权限 perm)。 +// +// 实现:同目录唯一临时文件 + 跨进程 flock + rename。 +// 任何一步失败都会清理临时文件,绝不留半成品。 +func WriteFile(path string, data []byte, perm os.FileMode) error { + dir := filepath.Dir(path) + if dir == "" { + dir = "." + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("atomicfile: mkdir %s: %w", dir, err) + } + + // 跨进程互斥:锁文件独立于目标文件,避免锁住 rename 本身。 + // 放在同一目录,保证与目标文件处于同一文件系统。 + unlock, err := lockPath(path + ".lock") + if err != nil { + // 取不到锁不致命:退化为"仅唯一临时文件"的无锁写入, + // 至少不会出现原实现那种 rename 竞争导致的失败。 + unlock = func() {} + } + defer unlock() + + // 唯一临时文件:os.CreateTemp 保证并发调用互不冲突。 + f, err := os.CreateTemp(dir, "."+filepath.Base(path)+".tmp-*") + if err != nil { + return fmt.Errorf("atomicfile: create temp: %w", err) + } + tmp := f.Name() + // 失败路径清理;成功后 tmp 已被 rename,Remove 是空操作。 + defer func() { _ = os.Remove(tmp) }() + + if _, err := f.Write(data); err != nil { + f.Close() + return fmt.Errorf("atomicfile: write temp: %w", err) + } + // 先落盘再 rename:否则崩溃后可能 rename 了一个内容还在页缓存的文件。 + if err := f.Sync(); err != nil { + f.Close() + return fmt.Errorf("atomicfile: sync temp: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("atomicfile: close temp: %w", err) + } + // CreateTemp 默认 0600,按调用方要求收紧/放宽。 + if err := os.Chmod(tmp, perm); err != nil { + return fmt.Errorf("atomicfile: chmod temp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("atomicfile: rename: %w", err) + } + + // 目录 fsync:确保 rename 本身持久化(Linux 上必要)。 + if d, err := os.Open(dir); err == nil { + _ = d.Sync() + _ = d.Close() + } + return nil +} diff --git a/src/internal/atomicfile/atomicfile_test.go b/src/internal/atomicfile/atomicfile_test.go new file mode 100644 index 0000000..d783095 --- /dev/null +++ b/src/internal/atomicfile/atomicfile_test.go @@ -0,0 +1,130 @@ +package atomicfile + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "testing" +) + +// TestWriteFileConcurrentNoRenameRace 复现并锁定原始缺陷。 +// +// 旧实现用固定临时名 "path.tmp",并发写入时会出现: +// +// rename .../auth.json.tmp .../auth.json: no such file or directory +// +// (一方先 rename 走,另一方 rename 时 tmp 已不存在) +// +// 实测旧实现:400 次并发写入出现 3 次错误。 +// 本测试要求新实现零错误。 +func TestWriteFileConcurrentNoRenameRace(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "auth.json") + + const writers = 4 + const iters = 200 + + var wg sync.WaitGroup + errCh := make(chan error, writers*iters) + + for w := 0; w < writers; w++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + payload, _ := json.Marshal(map[string]any{ + "writer": id, + "pad": make([]byte, 4096), // 拉长写窗口,放大竞争 + }) + for i := 0; i < iters; i++ { + if err := WriteFile(path, payload, 0o600); err != nil { + errCh <- err + } + } + }(w) + } + wg.Wait() + close(errCh) + + var errs []error + for e := range errCh { + errs = append(errs, e) + } + if len(errs) > 0 { + t.Fatalf("%d/%d 次写入失败(旧实现会出现 rename 竞争错误): 首个错误 = %v", + len(errs), writers*iters, errs[0]) + } + + // 结果必须是完整合法的 JSON(不能是两次写入交错的产物) + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("读回失败: %v", err) + } + var got map[string]any + if err := json.Unmarshal(raw, &got); err != nil { + t.Fatalf("文件不是合法 JSON(写交错): %v", err) + } + if _, ok := got["writer"]; !ok { + t.Fatalf("内容异常: %v", got) + } +} + +// TestWriteFileNoStrayTempFiles 确认成功路径不留临时文件。 +func TestWriteFileNoStrayTempFiles(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "x.json") + + for i := 0; i < 20; i++ { + if err := WriteFile(path, []byte(fmt.Sprintf(`{"i":%d}`, i)), 0o600); err != nil { + t.Fatalf("写入失败: %v", err) + } + } + + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + // 允许存在 .lock(跨进程锁文件),但不应残留 .tmp-* + for _, e := range entries { + name := e.Name() + if name == "x.json" || name == "x.json.lock" { + continue + } + t.Errorf("残留文件: %s", name) + } +} + +// TestWriteFilePerm 确认权限被正确设置(凭证文件必须 0600)。 +func TestWriteFilePerm(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "secret.json") + + if err := WriteFile(path, []byte(`{"k":"v"}`), 0o600); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode().Perm(); got != 0o600 { + t.Errorf("权限 = %o, 期望 600", got) + } +} + +// TestWriteFileOverwrite 确认覆盖写语义(旧内容被完整替换,不残留尾部)。 +func TestWriteFileOverwrite(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "o.json") + + if err := WriteFile(path, []byte(`{"long":"aaaaaaaaaaaaaaaaaaaaaaaa"}`), 0o600); err != nil { + t.Fatal(err) + } + if err := WriteFile(path, []byte(`{"s":1}`), 0o600); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(path) + if string(raw) != `{"s":1}` { + t.Errorf("覆盖后内容 = %q(不应残留旧内容)", string(raw)) + } +} diff --git a/src/internal/atomicfile/lock_other.go b/src/internal/atomicfile/lock_other.go new file mode 100644 index 0000000..2702714 --- /dev/null +++ b/src/internal/atomicfile/lock_other.go @@ -0,0 +1,12 @@ +//go:build !unix + +package atomicfile + +// lockPath 在非 unix 平台退化为无操作。 +// +// addon 运行时是 linux(alpine),此分支仅为保证 +// `GOOS=windows go build` 等交叉编译/IDE 场景可编译。 +// 唯一临时文件名已足以避免 rename 竞争,此处降级不影响正确性。 +func lockPath(string) (func(), error) { + return func() {}, nil +} diff --git a/src/internal/atomicfile/lock_unix.go b/src/internal/atomicfile/lock_unix.go new file mode 100644 index 0000000..07515e5 --- /dev/null +++ b/src/internal/atomicfile/lock_unix.go @@ -0,0 +1,30 @@ +//go:build unix + +package atomicfile + +import ( + "os" + "syscall" +) + +// lockPath 对 lockPath 指向的文件加**跨进程**排他锁(flock)。 +// +// 为什么用 flock 而不是进程内 sync.Mutex: +// 同一份凭证会被 serverd 与 ctl 两个**独立进程**写入, +// 进程内锁对彼此完全不可见。 +// +// 返回的 unlock 保证幂等:重复调用安全。 +func lockPath(lockPath string) (func(), error) { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + f.Close() + return nil, err + } + return func() { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) + _ = f.Close() + }, nil +} diff --git a/src/internal/auth/auth.go b/src/internal/auth/auth.go index 913ce00..995cd84 100644 --- a/src/internal/auth/auth.go +++ b/src/internal/auth/auth.go @@ -10,6 +10,8 @@ import ( "strings" "sync" "time" + + "github.com/rockswang/workbuddy-wild/internal/atomicfile" ) // Auth 是归一化后的账号凭证(来源可以是插件 OAuth 嵌套形或 CPA 面板扁平形)。 @@ -201,11 +203,11 @@ func (a *Auth) saveAtomicLocked() error { if err != nil { return err } - tmp := a.FilePath + ".tmp" - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - return err - } - return os.Rename(tmp, a.FilePath) + // 用 atomicfile 而非 "path.tmp" 固定名: + // serverd 与 ctl 是两个独立进程,会写同一份凭证文件, + // 固定临时名会导致内容交错 / rename 竞争,甚至丢失较新的 refreshToken。 + // 详见 internal/atomicfile 的包注释。 + return atomicfile.WriteFile(a.FilePath, raw, 0o600) } // LoadDir 扫描 dir 下 workbuddy*.json,只收 wantRegion("cn"/"global")。 diff --git a/src/internal/pool/pool.go b/src/internal/pool/pool.go index 4038d87..0c81b83 100644 --- a/src/internal/pool/pool.go +++ b/src/internal/pool/pool.go @@ -10,6 +10,7 @@ import ( "sync" "time" + "github.com/rockswang/workbuddy-wild/internal/atomicfile" "github.com/rockswang/workbuddy-wild/internal/auth" ) @@ -444,9 +445,7 @@ func (p *Pool) saveLocked() { if dir := filepath.Dir(p.stateFp); dir != "" { _ = os.MkdirAll(dir, 0o755) } - tmp := p.stateFp + ".tmp" - if err := os.WriteFile(tmp, raw, 0o600); err != nil { - return - } - _ = os.Rename(tmp, p.stateFp) + // 与 auth 文件同理:state 也可能被多个进程写(serverd 主进程 + 面板触发的 ctl), + // 固定 "state.json.tmp" 会有 rename 竞争。用 atomicfile 消除。 + _ = atomicfile.WriteFile(p.stateFp, raw, 0o600) }