dsh-crossterm 0.8.0 → 0.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # dsh-crossterm 变更记录
2
2
 
3
+ ## 0.8.2 — 2026-10-19
4
+
5
+ ### 复测反馈:F7 只修一半、A6、F14、F16
6
+
7
+ - **F7/F8 只修一半**:跳过判断用文件名(`state.processed[name]`)、投递记录写信封 id(`state.processed[mailId]`),
8
+ 同一个 id 换两个文件名会被投两次;归档失败时下一轮会重复投递。
9
+ 改为 `state.seen[name] = mailId` 记录「文件名 → 信封 id」,跳过判断统一看这张表 + 已投递表;
10
+ 发现同 id 的第二个文件时直接把它归档到 `done/`,不再投第二次。
11
+ - **A6(安全)DNS rebinding**:`GET /crossterm/api/token` 是先取令牌再用的引导接口,同源 GET 不带 `Origin`,
12
+ rebound 页面的 `Host` 是攻击者域名却能拿到令牌,令牌防线被绕过。
13
+ 现在所有 `/crossterm/api/*`(含 token 路由)都要求 `Host` 是 `127.0.0.1` / `localhost` / `[::1]`,否则 403。
14
+ - **F14 收件人解析**:`to` 写设备名(`yoce-c-4fb38e`)或写错名字时,原实现一律「留件」,信在对方 inbox 里每 20 秒重试。
15
+ 现在按 会话名/短号 → `session-<uuid>` → 最近活跃会话 依次回退,并把回退原因写进正文;
16
+ 只有本机一个会话都没有时才留件。
17
+ - **F16 死信**:失败原来每轮都重下重投。现在按 20s / 1m / 5m / 15m / 1h 退避,连续 8 次移进
18
+ `<设备目录>/dead/` 并记 `parked`,不再无限重试;签名无效的信件直接退避 1 小时。
19
+
20
+ - 探针:`probe-relay-082.mjs` 7 项全 PASS(同 id 两文件名只投一次、重复副本归档、失败退避、退避期内不重投、8 次进 dead)。
21
+ - 要不要跟着动手:**要** —— 收信方升级到 0.8.2 才能吃到「设备名/写错名字」的回退与死信退避。
22
+
23
+ ## 0.8.1 — 2026-10-19
24
+
25
+ ### 从旧版升到 0.8.0 时,签名密钥可能一直生成不出来
26
+
27
+ - 症状(发版后复查发现):0.7.6 及更早的布局缓存 `layout.json` 里没有 `secret` 字段,
28
+ `ensureLayout()` 命中缓存就直接返回,`relaySecret` 一直没写进 `_account.json`;
29
+ 中转站 `secretNow()` 拿到空串,按「账号还没有密钥」处理,于是**未签名信封照样被放行** —— 验签等于没开。
30
+ 缓存 10 分钟过期后会自愈,但这段窗口不该存在。
31
+ - 改法:`lib/cloud/layout.js` 的缓存捷径加上 `&& cached.secret`,没有密钥就走完整流程(生成并写回 `_account.json`);
32
+ `lib/cloud/home.js` 的 `getSecret()` 拿不到密钥时再强制重算一次(`getLayout(true)`)。
33
+ - 探针:`probe-security-080.mjs` 21 项仍全 PASS;`probe-cloud-home.mjs` 42 项全 PASS。
34
+ - 要不要跟着动手:**不用** —— 装上即可,只影响「刚从旧版升上来且已有布局缓存」的那一小段时间。
35
+
36
+
3
37
  ## 0.8.0 — 2026-10-19
4
38
 
5
39
  ### 安全加固:堵住路径穿越、接口裸奔、伪造发件人(来源:YOCE_C 复核报告)
package/README.md CHANGED
@@ -28,24 +28,23 @@ A 终端看不到 B 终端里有哪个会话、在干什么,也没法把一条
28
28
  > `locatePkgJson()` 对子路径行返回 undefined),而插件页顶部标题与这一行共用同一份 `locale/zh.json` 的 meta,
29
29
  > 所以它单独做总开关,标题带「(总开关)」;卡片头用 `shortTitle` 显示短名「跨端互通」。
30
30
 
31
- **本版更新(0.7.6)**
32
-
33
- - **跨设备邮件的尾巴不再重复**:0.7.5 里同一封信末尾会出现两条回复提示(一条 `renderMailList()` 给的、
34
- 一条中转站又补的)。现在只留一条,`经由:云端中转站` 照旧在抬头。
35
- - 条件提示照旧追加在最后(附件没下载下来、信封没写收件人时的回落说明)。
36
- - 邮件格式契约补第 4 条硬规则:**尾行只给一次**。
37
- - 要不要跟着动手:**不用** —— 只删一句重复文案,协议与数据格式没变。
38
-
39
- **上一版(0.7.5)**
40
-
41
- - **邮件来源分四档**:同终端同工作区 → 「跨会话邮件」;同终端不同工作区 → **「跨工作区邮件」**;
42
- 同一台机器别的 DSH 进程 → 「跨终端邮件」;云端中转站投进来的(别的设备)→ **「跨设备邮件」**。
43
- 以前只有两档,云端来的信被标成「跨终端」。
44
- - **抬头那行改成「发件人:」**:以前写「会话:xxx」,容易被读成「收件会话」;现在明确是发信的那个会话,
45
- 云端来的信还多一行「经由:云端中转站」。`term_inbox` 清单同步改成「发件人:」。
46
- - **云端邮件与本机邮件同一套渲染**:中转站投递不再手写抬头,正文照样落盘 `mail/`、附件清单照样列、结尾照样给回信提示。
47
- - **邮件格式写进说明**:新增「三、收信长什么样:邮件格式(固定契约)」一节,列清字段顺序、四档标签判定与四个例子。
48
- - 要不要跟着动手:**不用** —— 纯展示层,协议与数据格式没变。
31
+ **本版更新(0.8.2)**
32
+
33
+ - **死信不再无限重试**:投不出去的信按 20s / 1m / 5m / 15m / 1h 退避,连续 8 次移进 `<设备目录>/dead/`。
34
+ - **收件人写设备名也能投**:`to` 写 `yoce-c-4fb38e` 这种设备名、或写错会话名时,自动回退到「最近活跃会话」,
35
+ 并在正文注明回退原因(以前会一直留在对方 inbox 里重试)。
36
+ - **同一个信封不会投两次**:文件名与信封 id 对齐(`state.seen`),同 id 的重复副本直接归档。
37
+ - **补 A6**:`/crossterm/api/*` 全部要求 `Host` 是本机回环地址,挡 DNS rebinding 偷令牌。
38
+
39
+ **上一版(0.8.1)**
40
+
41
+ - 升级边角:旧布局缓存里没有签名密钥时,`relaySecret` 原来会一直不生成(验签等于没开),现在会重算并写回。
42
+
43
+ **更早(0.8.0)— 安全加固**
44
+
45
+ - 附件名只认纯文件名 + 路径越界校验;本地写接口要 `x-crossterm-token`、外站 `Origin` 403;
46
+ 信封带 HMAC-SHA256 签名;入站限额(信封 >512 KB 跳过、附件 ≤20、单个 >64 MB 拒收);按 id 去重、先归档再记状态。
47
+ - **要跟着动手**:账号生成 `relaySecret` 后,<0.8.0 设备发的未签名信封会被拒收,请全部升到 ≥0.8.0。
49
48
 
50
49
  ## 一、安装与卸载
51
50
 
package/lib/bus.js CHANGED
@@ -22,7 +22,7 @@ import os from 'node:os'
22
22
  import path from 'node:path'
23
23
  import crypto from 'node:crypto'
24
24
 
25
- export const VERSION = '0.8.0'
25
+ export const VERSION = '0.8.2'
26
26
 
27
27
  /** DSH 的家目录:与宿主其它状态(sessions / profiles)同一个根,多终端天然共享 */
28
28
  export function dshHome() {
package/lib/cloud/home.js CHANGED
@@ -44,7 +44,10 @@ export function createCloudHome({ cfg, device, deviceInfo, pan, busDir, deliver,
44
44
  getLayout: () => getLayout(false),
45
45
  getSecret: async () => {
46
46
  const l = await getLayout(false)
47
- return (l && l.secret) || ''
47
+ if (l && l.secret) return l.secret
48
+ // 布局是旧缓存(没有 relaySecret)时强制重算一次,把账号密钥补出来
49
+ const fresh = await getLayout(true)
50
+ return (fresh && fresh.secret) || ''
48
51
  },
49
52
  localDir: relayDir,
50
53
  stateFile: relayState,
@@ -268,7 +268,9 @@ export async function ensureRules(pan, paths, info = {}) {
268
268
  export async function ensureLayout({ pan, cfg = {}, device = {}, cacheFile, force = false, log = () => {} }) {
269
269
  const nodeId = sanitize(device.nodeId || 'device')
270
270
  const cached = readCache(cacheFile)
271
- if (!force && cached && cached.deviceId === nodeId && Date.now() - Number(cached.at || 0) < CACHE_MS && cached.paths) {
271
+ // 缓存里没有 secret(0.7.6 之前的缓存、或刚升级上来)时不能走捷径:
272
+ // 走一遍完整流程才能生成 relaySecret,否则验签会以为「账号还没有密钥」而放过未签名信封。
273
+ if (!force && cached && cached.secret && cached.deviceId === nodeId && Date.now() - Number(cached.at || 0) < CACHE_MS && cached.paths) {
272
274
  // 路径是「算出来的」而不是状态:升级后新增的路径(规则文档、收件箱说明)必须重新算,
273
275
  // 直接沿用旧缓存的 paths 会让 ensureRules 拿到 undefined、规则永远写不出去。
274
276
  const paths = buildPaths({
@@ -13,9 +13,9 @@ const nowIso = () => new Date().toISOString()
13
13
  function loadState(file) {
14
14
  try {
15
15
  const j = JSON.parse(fs.readFileSync(file, 'utf8'))
16
- return { at: j.at || 0, processed: j.processed || {}, failed: j.failed || {}, sent: j.sent || [] }
16
+ return { at: j.at || 0, processed: j.processed || {}, seen: j.seen || {}, failed: j.failed || {}, sent: j.sent || [] }
17
17
  } catch {
18
- return { at: 0, processed: {}, failed: {}, sent: [] }
18
+ return { at: 0, processed: {}, seen: {}, failed: {}, sent: [] }
19
19
  }
20
20
  }
21
21
 
@@ -169,7 +169,14 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
169
169
  log(stats.lastError)
170
170
  continue
171
171
  }
172
- if (state.processed[name]) continue
172
+ // 0.8.2 F7:跳过判断统一用「文件名 → 信封 id」表(seen),投递记录统一用信封 id。
173
+ if (state.seen[name] || state.processed[name]) continue
174
+ const prevFail = state.failed[name]
175
+ if (prevFail && Number(prevFail.nextAt || 0) > Date.now()) {
176
+ // 0.8.2 F16:退避期内不再下载/重投
177
+ stats.pending += 1
178
+ continue
179
+ }
173
180
  const localJson = path.join(incomingDir, name)
174
181
  let envelope
175
182
  try {
@@ -182,6 +189,21 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
182
189
  continue
183
190
  }
184
191
  const mailId = envelope.id || path.basename(name, '.json')
192
+ if (state.processed[mailId]) {
193
+ // 0.8.2 F7:同一个信封换了文件名又出现 —— 已经投过,把这个副本归档掉,不再投第二次
194
+ try {
195
+ await pan.mkdir(layout.paths.doneDir)
196
+ await pan.put(localJson, `${layout.paths.doneDir}/${name}`)
197
+ await pan.rm(`${layout.paths.inboxDir}/${name}`)
198
+ state.seen[name] = mailId
199
+ delete state.failed[name]
200
+ saveState(stateFile, { ...state, at: Date.now() })
201
+ log(`中转站:${name} 与已投递的 ${mailId} 同一个信封,已归档不重投`)
202
+ } catch (e) {
203
+ log(`中转站:${name} 重复信封归档失败:${(e && e.message) || String(e)}`)
204
+ }
205
+ continue
206
+ }
185
207
  // 0.8.0 F3:有账号密钥就要求签名对得上,否则拒收(防伪造发件人 / 提示注入)
186
208
  const secret = await secretNow()
187
209
  if (secret) {
@@ -189,7 +211,9 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
189
211
  stats.failed += 1
190
212
  stats.lastError = `信封签名无效,已拒收:${name}`
191
213
  log(stats.lastError)
192
- state.failed[mailId] = { at: nowIso(), reason: '签名无效', tries: Number(state.failed?.[mailId]?.tries || 0) + 1 }
214
+ const prevSig = state.failed[name] || {}
215
+ const sigTries = Number(prevSig.tries || 0) + 1
216
+ state.failed[name] = { at: nowIso(), mailId, reason: '签名无效', tries: sigTries, nextAt: Date.now() + 3600000 }
193
217
  saveState(stateFile, { ...state, at: Date.now() })
194
218
  continue
195
219
  }
@@ -223,12 +247,35 @@ export function createRelay({ pan, getLayout, localDir, stateFile, deliver, getS
223
247
  log(`归档 ${name} 失败:${(e && e.message) || String(e)}`)
224
248
  }
225
249
  state.processed[mailId] = { at: nowIso(), name, mailId, archived, sessionId: result.sessionId || '', to: String(envelope.to) }
250
+ state.seen[name] = mailId
251
+ delete state.failed[name]
226
252
  log(`中转站投递 ${mailId} → ${result.sessionId || String(envelope.to)}`)
227
253
  } else {
228
254
  stats.pending += 1
229
- const key = `${name}`
230
- state.failed[key] = { at: nowIso(), reason: (result && result.reason) || '投递失败', tries: Number(state.failed?.[key]?.tries || 0) + 1 }
231
- log(`中转站留件 ${name}:${(result && result.reason) || '投递失败'}`)
255
+ // 0.8.2 F16:失败按 20s/1m/5m/15m/1h 退避,连续 8 次移进 dead/,不再每 20 秒重下重投
256
+ const prev = state.failed[name] || {}
257
+ const tries = Number(prev.tries || 0) + 1
258
+ const delays = [20000, 60000, 300000, 900000, 3600000]
259
+ state.failed[name] = {
260
+ at: nowIso(),
261
+ mailId,
262
+ reason: (result && result.reason) || '投递失败',
263
+ tries,
264
+ nextAt: Date.now() + delays[Math.min(tries - 1, delays.length - 1)],
265
+ }
266
+ log(`中转站留件 ${name}(第 ${tries} 次):${(result && result.reason) || '投递失败'}`)
267
+ if (tries >= 8) {
268
+ try {
269
+ const deadDir = `${layout.paths.deviceRoot}/dead`
270
+ await pan.mkdir(deadDir)
271
+ await pan.put(localJson, `${deadDir}/${name}`)
272
+ await pan.rm(`${layout.paths.inboxDir}/${name}`)
273
+ state.failed[name].parked = true
274
+ log(`中转站:${name} 连续失败 ${tries} 次,已移入 dead/`)
275
+ } catch (e) {
276
+ log(`中转站:${name} 移入 dead 失败:${(e && e.message) || String(e)}`)
277
+ }
278
+ }
232
279
  }
233
280
  saveState(stateFile, { ...state, at: Date.now() })
234
281
  }
package/lib/index.js CHANGED
@@ -830,17 +830,29 @@ class CrossTerm {
830
830
 
831
831
  /** 中转站投递:把云端收件箱里的一封信投进本机对应会话(按收件人会话名) */
832
832
  async relayDeliver({ to, envelope = {}, files = [] }) {
833
- let targets = (Array.isArray(to) ? to : String(to || '').split(/[,,;;\s]+/))
833
+ const rawTargets = (Array.isArray(to) ? to : String(to || '').split(/[,,;;\s]+/))
834
834
  .map((s) => String(s || '').trim())
835
835
  .filter(Boolean)
836
+ // 0.8.2 F14:收件人可能是会话名、短号、session-<uuid>,也可能是「设备名」(别的设备只写设备不写会话)。
837
+ // 能解析的照常投;一个都解析不出来就退回「最近活跃会话」,别让信在 inbox 里每 20 秒重试到天荒地老。
838
+ const resolvable = (raw) => {
839
+ if (/^session-[0-9a-z-]{8,}$/i.test(raw)) return true
840
+ return Boolean(this.resolveByName(raw))
841
+ }
842
+ const unknown = rawTargets.filter((t) => !resolvable(t))
843
+ let targets = rawTargets.filter(resolvable)
836
844
  let fallbackNote = ''
837
845
  if (!targets.length) {
838
- // 信封里没写收件人(例如别的设备直接 term_send 到本机、没指会话)→ 投给最近活跃会话
839
846
  const list = this.sessions()
840
847
  const latest = list.find((s) => s && s.active) || list[0]
841
- if (!latest) return { ok: false, reason: '信封里没有收件人,本机也没有可投递的会话' }
848
+ if (!latest) return { ok: false, reason: `收件人「${rawTargets.join('、') || '空'}」在本机找不到会话,本机也没有可投递的会话` }
842
849
  targets = [latest.name || latest.short]
843
- fallbackNote = `(信封没写收件人,按「最近活跃会话」投给「${targets[0]}」)`
850
+ const what = rawTargets.length ? `收件人「${rawTargets.join('、')}」不是本机会话名` : '信封没写收件人'
851
+ const selfNames = [this.nodeId, this.name, os.hostname()].map((v) => String(v || '').trim().toLowerCase())
852
+ const deviceHint = rawTargets.some((t) => selfNames.includes(String(t).trim().toLowerCase())) ? '(写的是本机设备名)' : ''
853
+ fallbackNote = `(${what}${deviceHint},按「最近活跃会话」投给「${targets[0]}」)`
854
+ } else if (unknown.length) {
855
+ fallbackNote = `(收件人「${unknown.join('、')}」没匹配到会话,已投给能匹配的 ${targets.length} 个)`
844
856
  }
845
857
  const controller = this.sessionController()
846
858
  if (!controller || typeof controller.prompt !== 'function') {
package/lib/web.js CHANGED
@@ -62,7 +62,19 @@ function ensureHttpToken(cfg, log = () => {}) {
62
62
  }
63
63
 
64
64
  /** 跨站 Origin 一律拒;令牌对得上才放行(同源页面可先 GET /crossterm/api/token 取令牌) */
65
+ const LOOPBACK_HOST = /^(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/i
66
+
67
+ /** A6:Host 必须是本机回环地址,挡住 DNS rebinding(rebound 页面的 Host 会是攻击者域名) */
68
+ function isLoopbackHost(host) {
69
+ return LOOPBACK_HOST.test(String(host || '').trim())
70
+ }
71
+
65
72
  function checkHttp(req, res, cfg) {
73
+ if (!isLoopbackHost(req.headers && req.headers.host)) {
74
+ // 0.8.2 A6:只接受本机 Host
75
+ json(res, 403, { ok: false, error: '只接受本机访问(Host 必须是 127.0.0.1 / localhost)' })
76
+ return false
77
+ }
66
78
  const origin = String((req.headers && req.headers.origin) || '')
67
79
  if (origin && !LOOPBACK_ORIGIN.test(origin)) {
68
80
  json(res, 403, { ok: false, message: '拒绝跨站请求' })
@@ -510,8 +522,9 @@ export function registerWeb(ctx, term, log) {
510
522
  path: '/crossterm/api/token',
511
523
  handler: (req, res) => {
512
524
  const origin = String((req.headers && req.headers.origin) || '')
513
- if (origin && !LOOPBACK_ORIGIN.test(origin)) {
514
- json(res, 403, { ok: false, message: '拒绝跨站请求' })
525
+ // 0.8.2 A6:同源 GET 不带 Origin,DNS rebinding 时 Host 会是攻击者域名 —— 必须挡 Host
526
+ if (!isLoopbackHost(req.headers && req.headers.host) || (origin && !LOOPBACK_ORIGIN.test(origin))) {
527
+ json(res, 403, { ok: false, message: '只接受本机同源请求' })
515
528
  return
516
529
  }
517
530
  json(res, 200, { ok: true, token: ensureHttpToken(term.cfg, log) })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crossterm",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "跨端互通:同一 DSH 账号下的会话、终端、设备之间互通 —— 特征码链、内联邮件、云端中转站与自动备份",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",