78 lines
4.7 KiB
Markdown
78 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/`。
|