1) dsh-server-docs/ 从工作区(原 E:\...\aliyun-dsh-server\dsh-server-docs)**整体并入本仓**,
保留目录名 ⇒ 仓库内 dsh-server-docs/... 的相对引用天然继续有效;旧目录(含其 .git)已归档到
工作区 _中间产物_待清理/,未随本提交带入。
2) .gitattributes:新增 `dsh-server-docs/** -text` —— 原文档库是 `* -text` + autocrlf=false,
必须保持纯 LF,否则会被本仓的 CRLF 规则翻掉。
3) 活引用里的绝对路径已全部改到新位置(docs 的 INDEX / README / scripts / skills + 用户级 skills
+ ~/.workbuddy/settings.json 的 hooks);历史档案(04-调整方案/、archive/)按「只增不改」未动。
⚠️ hooks 路径改动需「完全重启会话」才生效(配置是会话启动快照)。
4) 交接单/T08:新增 §16「生产整体切换执行记录」(形态 / 落地动作 / **4 个只有真上线才暴露的真 bug** /
验收证据 / 回滚命令 / 残留项);台账 T08 行 → 已完成并归档;03-路线图 §二 登记 T08 收尾项。
5) 统一称谓:**「本机」只指跑 WorkBuddy 的开发机**,47 / 106 一律写「远程服务器」。
79 lines
4.7 KiB
Markdown
79 lines
4.7 KiB
Markdown
# 98-治本:陈旧 `dsh-auth-*` cookie 回写清理(2026-09-14 落地)
|
||
|
||
- 日期:2026-09-14 | 状态:✅ **已上线**(平台后端已重建重启,端到端实测通过)
|
||
- 触发:用户「**必须要治本**」(承档案 97 之后查明的 431 故障根因)
|
||
|
||
> **TL;DR**
|
||
> dsh **每次实例 (重)启动都换** `dsh-auth-<随机后缀>` 的 **cookie 名**;平台此前只在**转发给实例时**
|
||
> 丢弃旧的(`mergeCookieHeader`),**从不回写浏览器** ⇒ 浏览器 jar **只增不减** ⇒ `Cookie` 请求头
|
||
> 越涨越长 ⇒ 涨到 **~15 KB** 就被 Cloudflare / 本机 nginx 直接 **431** 拒掉 ⇒ **请求根本到不了平台**
|
||
> ⇒ 那条 11 MB 合并脚本取不到 ⇒ 客户端报 `bundle script … failed to load`(界面「Failed to load plugins」)。
|
||
> 修法:**在响应里对陈旧的名字回写 `Set-Cookie: …; Max-Age=0`** ⇒ 浏览器把它们丢掉 ⇒ jar 不再增长。
|
||
|
||
---
|
||
|
||
## 一、实测证据(本机对线上做的)
|
||
|
||
| `Cookie` 请求头大小 | 结果(直连 nginx:443) |
|
||
|---|---|
|
||
| 12,769 B | **401** ← 放行到平台 |
|
||
| **19,149 B** | **431** |
|
||
| 25,529 B | **431** |
|
||
|
||
⇒ 阈值落在 **13–19 KB**;⚠️ 19 KB 时**连 `/` 也 431** ⇒ 一过线就是"整站请求被拒",用户看到"立即报错、什么都没加载"。
|
||
|
||
**旁证(三条反常现象全对上)**:平台日志里**没有**那条 bundle 请求(请求被 CF/nginx 拒在门外)✓;
|
||
**无痕正常**(空 cookie 罐 ⇒ 头只有几百字节)✓;服务器侧一切正常(dsh 启动无错、无 OOM、产物语法通过)✓。
|
||
|
||
📌 **Cloudflare 官方 changelog(2025-10-16)**:此前「总 32 KB / 单头 16 KB」,之后提到 128 KB
|
||
⇒ 本轮 431 **更可能来自本机 nginx**(实测 19 KB 即 431),CF 只是把响应透传回来。
|
||
|
||
## 二、改了什么(只动 `proxy.ts` 一处)
|
||
|
||
在响应头块(重放 cookie 合并之后、`writeHead` 之前)加一段:
|
||
|
||
```ts
|
||
// 只在「本次响应确实下发了 dsh-auth-*」时才动手 —— 那时才确知当前有效的名字
|
||
const scList = Array.isArray(scNow) ? scNow : scNow === undefined ? [] : [String(scNow)]
|
||
const freshNames = scList.map(取名字).filter(Boolean)
|
||
if (freshNames.length > 0) {
|
||
const staleNames = (request.headers.cookie ?? '')
|
||
.split(';').map(取名字).filter((n) => n.startsWith('dsh-auth-') && !freshNames.includes(n))
|
||
if (staleNames.length > 0) {
|
||
headers['set-cookie'] = [...scList, ...staleNames.map((n) => `${n}=; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT`)]
|
||
}
|
||
}
|
||
```
|
||
|
||
**为什么这样判据是安全的**:
|
||
- 只有"实例本次下发了 `dsh-auth-*`"(= 我们知道当前有效名字)时才清;
|
||
- 其余情形(普通 API 响应不带该 cookie)**什么都不做** —— 绝不误删正在用的那个;
|
||
- **不带 `Domain=`**(dsh 下发的是 host-only cookie,带 Domain 反而删不掉);
|
||
- 即使判断有误,档案 51 的「401 → 取新 cookie 透明重放」也会自愈。
|
||
|
||
`scripts/verify-inject.cjs` 增加防回退断言:「proxy.ts 会清理陈旧 dsh-auth cookie」。
|
||
|
||
## 三、验证
|
||
|
||
| 层 | 手段 | 结果 |
|
||
|---|---|---|
|
||
| 类型/构建/单测 | `npm run typecheck` + `npm run verify` + 服务器 `ci.sh` | ✅ EXIT=0 全绿;✅ CI OK(48 pass / 0 fail) |
|
||
| 断言 | `node scripts/verify-inject.cjs` | ✅ 新增项通过 |
|
||
| **端到端(最强)** | 临时 session(R4)→ `/api/dsh/enter` 取带 token 的 URL → **带 2 个假 `dsh-auth-*`** 请求它 | 303 响应含 **3 条 Set-Cookie**,其中 **2 条 `Max-Age=0`** 正是那两个假名字 ✅ |
|
||
|
||
## 四、⚠️ 它救不了"当下",只防复发
|
||
|
||
**已经 431 时,请求在 CF/nginx 就被拒了、到不了平台** ⇒ 平台没法回写清理指令。
|
||
⇒ **用户必须先手动清一次**(F12 → Application → Cookies → 只删名字以 `dsh-auth-` 开头的,**保留 `sid`**);
|
||
此后由本机制维持小 jar。
|
||
|
||
## 五、遗留 / 后续
|
||
|
||
- **根因仍存在**:dsh 官方每(重)启动换 cookie 名(我们不改官方)。本机制只是**跟着清**。
|
||
⇒ 推论:**频繁重启实例会持续制造新 cookie**(今天为部署重启十几次即为此例)——仍是"少重启"的又一个理由。
|
||
- 可选加固(未做):nginx 的 `large_client_header_buffers` 已是 `4 32k`,可不动;**CF 侧限额不可配**,
|
||
所以"保持小 jar"是唯一可靠路径。若日后仍偶发 431,可考虑在平台侧**直接把超过 N 个的 `dsh-auth-*`
|
||
从请求里剥掉**(转发时已有 `mergeCookieHeader` 丢弃逻辑,只是要加"超量"判据)。
|
||
- 回滚:`git revert <本次 commit>` → `npm run build` → `systemctl restart dshs`;
|
||
备份 `/opt/dsh/backups/pre-99-20260914-225704/`。
|