Files
dsh_shenxian/dsh-server-docs/04-调整方案/50-会话过期自愈注入脚本.md
T

115 lines
7.0 KiB
Markdown
Raw Normal View History

# 50-会话过期自愈:实例 HTML 注入「重连」脚本(2026-09-11 落地)
## 背景
档案 49 修好了「实例被空闲回收后,请求失败在传输层」的问题(反代自动拉起 + 等待就绪),
但用户实测仍报:
```
client api: session/prompt failed: transport failure for /api/session/prompt: HTTP 401 (gateway/internal)
```
**根因(代码级确认)**:`proxy.ts` 里那段 401 处理**只对"浏览器导航"生效**:
```ts
if (upRes.statusCode === 401 && request.raw.method === 'GET'
&& String(request.headers.accept ?? '').includes('text/html') && freshAuthUrl !== undefined) {
...302 到当前实例的新 token...
}
reply.raw.writeHead(upRes.statusCode ?? 502, headers); upRes.pipe(reply.raw) // ← XHR 一律原样透传
```
完整因果链:
1. 实例空闲被回收 → 2. 用户回到**已打开的页面**继续对话,前端发 XHR,**带着旧实例的 `dsh-auth-*` cookie**
→ 3. 档案 49 的修复把实例**拉起**了(这一步生效)→ 4. 新实例发现 token 是**旧 authority/端口的** → **401**
→ 5. 401 处理不管 XHR → 透传给前端 → dsh 显示 `transport failure` → 6. 用户手动刷新(=导航)才命中 401 处理、302 到新 token。
**→ 档案 49 消灭了"传输失败",但没消灭"认证失败"。**
## 方案取舍(用户选 C 先看效果)
| 方案 | 做法 | 评价 |
|---|---|---|
| **B** | 反代透明重放:XHR 401 时用 `freshAuthUrl()` 取新 token → 换新 cookie → **重放该请求** | 体验最好(前端无感),但要写 cookie 重放(中等复杂度)→ **留待后续** |
| **C(本次采用)** | 在反代返回的 **`text/html`** 里注入平台脚本 → 检测 `/api/*` 返回 **401** → 显示覆盖层动画 + `location.reload()`;reload 会走**已经工作**的"导航 401 → 302 新 token"链路自动恢复 | 简单、复用既有机制,且满足用户要的"当前页面给反馈" |
## 实现(`src/supervisor/proxy.ts`)
1. 新增模块级常量 **`SESSION_RECOVERY_JS`**(1480 字节,ES5 安全、全程 try/catch、幂等 `window.__dshRecover`):
- hook `window.fetch` 与 `XMLHttpRequest.prototype.open/send`(`loadend` 回调)
- 命中条件:**状态 401 且 URL 含 `/api/`** → 弹覆盖层(转圈 + 「会话已过期(实例被回收重建),正在重新连接…」)→ **900ms 后 `location.reload()`**
2. `injectRecovery(html)`:把 `<script>…</script>` 插到 `</body>` 之前(无 `</body>` 则追加)。
3. `proxyHttp` 的响应写出改为:**仅当 `content-type: text/html` 且无 `content-encoding` 且状态非 204/304** 时——
**缓冲正文 → 注入 → 重算 `content-length` → 写出**;其余情况**原样 `pipe`**(零影响)。
若因 `content-encoding` 跳过注入,会往 stderr 打 `[inject-recovery] skip:`(便于发现"没注入"的原因)。
4. **不落盘、不改官方文件**(符合 R2:只改写平台反代自身的响应内容)。
## 验证记录(真实反代链路,R4 模板测试)
| 检查 | 结果 |
|---|---|
| `enter` 起实例 + `curl -L`(跟随 302)+ cookie jar 取 HTML | **24059 字节**(正常) |
| 含 `__dshRecover` | ✅ 已注入 |
| 含 `loadend` 钩子 | ✅ |
| **`</body>` 出现次数** | **1** |
| 注入脚本是否在 `</body>` 之前 | ✅ |
| 提取注入脚本(以 `__dshRecover` 为中心向外取 `<script>` 边界)后 `node --check` | ✅ **语法正确** |
| 关键钩子齐备 | `window.fetch` ✅ / `XMLHttpRequest` ✅ / `loadend` ✅ / `s!==401` ✅ / `location.reload` ✅ |
| 清理 | 测试实例已 stop、临时会话已删(`user_agent=poc-inject3`) |
> 首轮验证曾误报"脚本语法错误"——原因是**我的提取正则跨了两个 `<script>` 标签**(dsh 把 boot 脚本放在 `<head>`,内含 `</script>`),换成"以 `__dshRecover` 为中心向外扩张"后通过。**注入本身一直是对的。**
## 仍未做
- **B(透明重放)**:终态方案 —— XHR 401 时自动换新 token 重放,用户连 reload 都不需要。属后续增强。
- **WS(`app.server.on upgrade`)**:实例未运行时仍 `socket.destroy()`;dsh 前端会自行重连。若要"等就绪再建隧道"需在 upgrade 回调做同样等待(未做)。
- **真实浏览器端的行为验证**:本次只验证了"注入正确 + 语法正确 + 结构正确";覆盖层与自动 reload 的**交互效果需在浏览器里实测**(起实例 → 停实例 → 在旧页面发消息 → 期望看到覆盖层并自动恢复)。
## 回滚
```
cp src/supervisor/proxy.ts.bak-<ts> src/supervisor/proxy.ts && npm run build && systemctl restart dshs
```
---
## 事后修正(2026-09-11 18:4x):注入对**浏览器无效**——上游 gzip 导致被跳过
**用户实测反馈**:「并没有效果,还是报刚才截图的错误」。排查后确认**是我的 bug**,且我此前的验证是**假阳性**。
### 根因
dsh 实例(上游)对**带 `Accept-Encoding` 的请求**会 **gzip 压缩 HTML**:
| 请求 | 上游响应头 | 注入 |
|---|---|---|
| 不带 `Accept-Encoding`(**我最初的 curl 验证方式**) | `content-encoding: (无)` | ✅ 已注入 |
| **带浏览器式 `Accept-Encoding: gzip, deflate, br`** | **`content-encoding: gzip`** | **❌ 被跳过** ← 浏览器就是这种 |
我的代码在 `content-encoding` 存在时**主动跳过**注入(为避免破坏已编码内容)→ 浏览器拿到的页面**没有自愈脚本**
→ C 对真实用户完全无效。代理 stderr 里正好有 `[inject-recovery] skip` 记录,与推断一致。
### 修法
`proxyHttp` 里:**只要客户端接受 HTML,就把转发给上游的 `accept-encoding` 覆盖为 `identity`**
(预先算好 `upHeaders` 再传入 `httpRequest`)→ 上游回未压缩 HTML → 注入成功。
体积影响可忽略(HTML ~24KB);**边缘 nginx 仍会对浏览器 gzip**,用户侧无感知;静态资源/SSE 的压缩行为不受影响。
### 验证(浏览器等价:`curl --compressed`)
| 检查 | 结果 |
|---|---|
| 响应 `content-encoding` | `gzip`(**由边缘 nginx 压缩**,非上游) |
| 解压后正文 | **24059 字节** |
| 含 `__dshRecover` / `loadend` | **✅ / ✅** |
| **代理 `[inject-recovery] skip` 记录(本次)** | **0 条** ✅(修正生效,上游不再压缩) |
| 注入脚本 `node --check` | ✅ 通过 |
| 注入位置 | ✅ 在 `</body>` 之前 |
### 第二个教训:**测试方法的假阳性 → 假阴性**
- **假阳性**(第一版验证):curl **不带** `Accept-Encoding` → 拿到未压缩 HTML → 通过 → 误以为"浏览器也行"。
- **假阴性**(复验时):`curl -H 'Accept-Encoding: gzip'` 时 **curl 默认不解压** → 我读到的是压缩字节 →
`includes('__dshRecover')` 必然为 false → 误判"修复无效"。
- **正确姿势**:模拟浏览器必须用 **`curl --compressed`**(既发 `Accept-Encoding`,**又自动解压**)。
→ **凡"注入/改写响应"类改动,验证必须覆盖"浏览器式请求头 + 解压后正文"两个条件**。