dsh-mobile-plugin 0.1.100 → 0.2.1

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.
Files changed (59) hide show
  1. package/README.md +14 -5
  2. package/dsh-mobile-credentials/lib/credentials.js +405 -84
  3. package/lib/client.js +31 -8
  4. package/lib/http/routing.js +165 -9
  5. package/lib/impl.js +88 -9
  6. package/lib/index.js +3 -2
  7. package/lib/mirror/pusher.cjs +35 -4
  8. package/lib/mobile/approvals.js +62 -15
  9. package/lib/mobile/auth.js +46 -26
  10. package/lib/mobile/credential-upgrade.js +137 -0
  11. package/lib/mobile/entry-window.js +32 -0
  12. package/lib/mobile/events.js +14 -4
  13. package/lib/mobile/extensions.js +279 -0
  14. package/lib/mobile/history-derive.js +209 -11
  15. package/lib/mobile/host-bridge/event-synthesis.js +61 -10
  16. package/lib/mobile/pairing.js +150 -64
  17. package/lib/mobile/plugin-self.js +187 -0
  18. package/lib/mobile/push-notify-route.js +37 -0
  19. package/lib/mobile/push-notify.js +84 -0
  20. package/lib/mobile/questions.js +75 -10
  21. package/lib/mobile/relay-control.js +547 -0
  22. package/lib/mobile/session-cache.js +60 -2
  23. package/lib/mobile/session-file.js +133 -34
  24. package/lib/mobile/session-list-shortcut.js +38 -0
  25. package/lib/mobile/session-model-options.js +3 -1
  26. package/lib/mobile/session-models.js +15 -3
  27. package/lib/mobile/session-send.js +30 -27
  28. package/lib/mobile/session-snapshot.js +12 -12
  29. package/lib/mobile/sessions.js +20 -0
  30. package/lib/mobile/stats-collect.cjs +55 -12
  31. package/lib/mobile/stats-ledger.cjs +140 -21
  32. package/lib/mobile/stream-registry.js +73 -0
  33. package/lib/mobile/stream.js +382 -31
  34. package/lib/mobile/subagents.js +11 -2
  35. package/lib/mobile/uploads.js +91 -5
  36. package/lib/mobile/workspaces.js +39 -8
  37. package/lib/panel/index.html +11 -11
  38. package/lib/relay/core.js +28 -2
  39. package/lib/relay/lan-network.cjs +47 -0
  40. package/lib/relay/lan-proxy.js +283 -122
  41. package/lib/relay/protocol.js +4 -2
  42. package/lib/relay/tunnel.js +20 -14
  43. package/lib/routes/control-plane.js +139 -9
  44. package/lib/routes/mobile-api.js +92 -9
  45. package/lib/shared/e2ee-pair.js +91 -0
  46. package/lib/shared/e2ee-plain.js +79 -0
  47. package/lib/shared/e2ee-request.js +331 -0
  48. package/lib/shared/e2ee.js +173 -0
  49. package/lib/shared/http-util.js +27 -4
  50. package/lib/shared/list-signal.js +16 -0
  51. package/lib/shared/pair-throttle.js +150 -0
  52. package/lib/shared/sse-write.js +16 -5
  53. package/lib/shared/via-header.js +65 -0
  54. package/lib/shell/runtime.js +147 -42
  55. package/lib/shell/self-check-after-swap.js +93 -0
  56. package/lib/shell/updater.js +44 -3
  57. package/lib/sidecar-facade-worker.cjs +70 -6
  58. package/lib/update-install.cjs +13 -5
  59. package/package.json +3 -2
@@ -18,6 +18,9 @@ const { advertisedCandidates, ipv6Candidates, isGlobalUnicastV6 } = require('./l
18
18
  const { safeEqual } = require('../shared/dshtoken');
19
19
  const { isFreshRequest } = require('../shared/fresh-request');
20
20
  const { encodeJsonBody } = require('../shared/http-util');
21
+ const { withViaHeader } = require('../shared/via-header');
22
+ const { createPairThrottle } = require('../shared/pair-throttle');
23
+ const { openSealedPairRequest, sealPairResponse } = require('../shared/e2ee-pair');
21
24
  const credentials = require('../shared/credentials-entry.js').credentialsEntry();
22
25
 
23
26
  const HOP_BY_HOP = new Set([
@@ -25,8 +28,6 @@ const HOP_BY_HOP = new Set([
25
28
  'te', 'trailer', 'transfer-encoding', 'upgrade', 'content-length',
26
29
  ]);
27
30
 
28
- const PAIR_MAX_FAILS = 5;
29
- const PAIR_LOCK_MS = 60_000;
30
31
  // 上游 WS 握手的一发超时:连上之后这条连接就是长连接(HMR、SSE 都从它过),
31
32
  // 所以只在「还没握上」这段时间里计时。
32
33
  const WS_UPSTREAM_HANDSHAKE_MS = 10_000;
@@ -46,6 +47,7 @@ function createLanProxy(opts) {
46
47
  const {
47
48
  lanPort,
48
49
  pairingCode,
50
+ pairingTicket = '',
49
51
  instanceId,
50
52
  lanSecret,
51
53
  upstreamPort,
@@ -57,6 +59,11 @@ function createLanProxy(opts) {
57
59
  * 都会退化成「知道 6 位配对码即可直连」的 fail-open——授权判断必须显式给出。
58
60
  */
59
61
  onApprove = async () => ({ approved: false }),
62
+ /**
63
+ * 配对成功之后调一次:换码 + 换票据(§5.1)。来回调交回装配层,因为「换」这件事要
64
+ * 同时写宿主状态并让旁路重读配置,不是这个代理的职责。
65
+ */
66
+ onPaired = null,
60
67
  /** 读取隔离:本地镜像快照读取回调,命中时直接回(不再连主进程)。 */
61
68
  readSnapshot = () => null,
62
69
  /** 上游 SSE 的失控闸,默认见 MAX_UPSTREAM_SSE_CONNECTIONS。 */
@@ -75,7 +82,34 @@ function createLanProxy(opts) {
75
82
  // 配对二维码里带的都是那个根本没在监听的死端口。
76
83
  let actualPort = lanPort;
77
84
  // 配对失败限流:ip -> { fails, lockedUntil }(与 relay 侧策略一致:5 次失败锁 60s)
78
- const pairLocks = new Map();
85
+ /**
86
+ * 配对闸门(§10.4)。局域网这条**不用分来源**:来源就是手机真实的局域网地址,
87
+ * 而按来源分桶只解决「一个人打错把所有人锁死」;这里换成不看来源的全局令牌桶,
88
+ * 与宿主那条完全同口径(`shared/pair-throttle.js`)。
89
+ *
90
+ * 两份状态各自独立:这条跑在旁路子进程里、那条跑在主进程里,跨进程共享要么加锁
91
+ * 要么走 RPC,而两条路的网络含义本来就不同。
92
+ */
93
+ const pairThrottle = createPairThrottle();
94
+
95
+ /**
96
+ * 签发凭据。**在旁路子进程里必须走 IPC**(§3.7):这个进程拿的是凭据库的只读副本,
97
+ * 自己写盘只会和主进程那份互相覆盖(旁路签的吊销会被回滚、主进程签的在旁路查不到,
98
+ * 经中转配上的手机回局域网就是 401)。装配层传进来就走它,测试里不传就用本地库。
99
+ */
100
+ const issueCredential = typeof opts.issueCredential === 'function'
101
+ ? opts.issueCredential
102
+ : (device, extra) => credentials.issue(instanceId, device, '', undefined, extra);
103
+
104
+ /**
105
+ * 当前配对码与票据。
106
+ *
107
+ * **必须是取值而不是快照**:配对成功之后宿主会立刻换码 + 换票据(§5.1),而重建局域网
108
+ * 代理会把刚握上的连接全掐掉。传入的是取值器就只换值、不动监听。
109
+ */
110
+ const currentPairing = typeof opts.currentPairing === 'function'
111
+ ? opts.currentPairing
112
+ : () => ({ code: pairingCode, ticket: pairingTicket });
79
113
 
80
114
  function log(...args) {
81
115
  onLog('[lan-proxy]', ...args);
@@ -86,48 +120,62 @@ function createLanProxy(opts) {
86
120
  return text.includes(':') ? `[${text}]` : text;
87
121
  }
88
122
 
89
- function isPairLocked(ip) {
90
- const l = pairLocks.get(ip);
91
- return !!l && l.lockedUntil > Date.now();
92
- }
93
- function recordPairFail(ip) {
94
- const l = pairLocks.get(ip) || { fails: 0, lockedUntil: 0 };
95
- l.fails += 1;
96
- if (l.fails >= PAIR_MAX_FAILS) {
97
- l.lockedUntil = Date.now() + PAIR_LOCK_MS;
98
- l.fails = 0;
99
- }
100
- pairLocks.set(ip, l);
101
- }
102
- function clearPairFails(ip) {
103
- pairLocks.delete(ip);
123
+ /** 闸门状态重置(测试用)。 */
124
+ function resetPairThrottle() {
125
+ pairThrottle.noteSuccess();
104
126
  }
105
127
 
106
128
  /**
107
- * 请求出示的设备凭据(`dshc_…`),取不到返回 null。
129
+ * 请求出示的凭据原文,取不到返回 null。
108
130
  *
109
- * App 把设备凭据放在独立的 `X-Dsh-Device-Credential` 头里,`Authorization`
110
- * 留给宿主 token(`app/src/services/authHeaders.ts`)。旧实现只从
111
- * `Authorization` 里找 `dshc_`,于是本函数对真实 App 流量恒为 null,
112
- * 「吊销能作用于局域网直连」从未落地。`Authorization` 分支仅用于兼容早期
113
- * 客户端,保留但不作为主路径。
131
+ * 接受**任意非空串**,不再只认 `dshc_` 前缀(§3.2):T1 是 `<实例号>.<exp>.<随机>`,
132
+ * 旧版是 `dshc_…`,升级前的老 App 还有 HMAC Bearer —— 三种都从这里过,由查库结果决定
133
+ * 走哪条路。App 把设备凭据放在独立的 `X-Dsh-Device-Credential` 头里,`Authorization`
134
+ * 留给宿主 token;`Authorization` 分支是给「只有一枚 Bearer 的旧客户端」的兜底。
135
+ *
136
+ * 旧实现只在这两处找 `dshc_`,于是本函数对真实 App 的 T1 流量恒为 null,
137
+ * 「吊销能作用于局域网直连」永远落不了地。
114
138
  */
115
139
  function presentedCredential(req) {
116
140
  const raw = req.headers['x-dsh-device-credential'];
117
141
  const header = Array.isArray(raw) ? raw[0] : raw;
118
- if (typeof header === 'string' && header.startsWith('dshc_')) return header;
142
+ if (typeof header === 'string' && header.trim()) return header.trim();
119
143
  const m = /^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '');
120
- return m && m[1].startsWith('dshc_') ? m[1] : null;
144
+ return m ? m[1] : null;
121
145
  }
122
- /** 当前仍然有效的本机设备凭据;用于把该设备对应的宿主 token 转发给上游。 */
146
+ /**
147
+ * 这条请求对应的凭据记录(可能来自凭据头、Bearer 或 cookie)。
148
+ *
149
+ * 换上游令牌用(§3.3):
150
+ * - 新 T1 记录**不带 `hostToken`**,代理把请求里出示的那一枚原样设成上游 `Authorization`;
151
+ * - 旧的 HMAC 记录仍用存下来的 `hostToken`(它那一枚是宿主的无状态令牌,规整不了)。
152
+ * 只有 cookie、没有 Bearer/头的请求(预览这类)也要能从 cookie 查记录,否则那条路径
153
+ * 换不到上游令牌,只能拿无状态 token 硬顶。
154
+ */
123
155
  function credential(req) {
124
156
  const presented = presentedCredential(req);
125
- return presented ? credentials.find(presented, instanceId) : null;
157
+ if (presented) {
158
+ const found = credentials.lookup(presented, instanceId);
159
+ if (found) return { record: found, presented };
160
+ // 本机不认识这一枚(中转签的 / 升级前的 HMAC),往下按 cookie 再找一次。
161
+ }
162
+ const m = /dsh_lan=([^;]+)/.exec(req.headers.cookie || '');
163
+ const token = m ? m[1] : '';
164
+ if (!token) return null;
165
+ const byCookie = credentials.lookup(token, instanceId);
166
+ return byCookie ? { record: byCookie, presented: token } : null;
167
+ }
168
+ /** 上游要带的 `Authorization` 值:新记录用出示的那一枚,旧记录用存下的宿主令牌。 */
169
+ function upstreamTokenOf(found) {
170
+ if (!found) return null;
171
+ if (found.record && found.record.hostToken) return found.record.hostToken;
172
+ return found.presented || null;
126
173
  }
127
174
  /**
128
175
  * 无状态凭据校验:dsh_lan cookie 或 `Authorization: Bearer <token>`。两者同源
129
176
  * 同格式,均为 lanSecret 签名的 HMAC token;RN 在部分平台会丢弃手动 Cookie
130
- * 头,故 Bearer 兜底。
177
+ * 头,故 Bearer 兜底。**只在凭据库查不到之后才走这里**:新配对发的是 T1,
178
+ * 而 T1 不是 HMAC 签名,验签必然不过。
131
179
  */
132
180
  function cookieValid(req) {
133
181
  const m = /dsh_lan=([^;]+)/.exec(req.headers.cookie || '');
@@ -138,23 +186,45 @@ function createLanProxy(opts) {
138
186
  return !!m && core.verifyLanToken(lanSecret, m[1], instanceId);
139
187
  }
140
188
  /**
141
- * 鉴权。
189
+ * 鉴权结果,**带上被拒原因**(§3.8):被吊销 / 已过期 / 只是没有凭据。
142
190
  *
143
- * 顺序是这里唯一的要点:出示了**本机签发过**的设备凭据时以它为准,一旦被吊销
144
- * 或过期就直接拒绝,不再回退到无状态 token —— HMAC token 有效期一年,允许回退
145
- * 就等于吊销永远不生效。
191
+ * 顺序是这里唯一要紧的东西(§3.3):**凭据头 → Bearer → cookie → 旧 HMAC**。前三种
192
+ * 都按「整串拿去查库」处理 —— 查到就按记录判(吊销/过期立即拒绝、绝不回退),查不到才
193
+ * 往下走。
146
194
  *
147
- * 但「本机不认识这张凭据」必须回退:公网配对时凭据由中转签发并存在中转的凭据
148
- * 库里(`dsh-mobile-server/lib/tunnel-handler.js`),宿主这里查不到。若一律拒绝,
149
- * 「公网配对后回到局域网直连」这个主要用法会直接 401。
195
+ * 「本机不认识这一枚」必须能回退:公网配对时凭据可能是中转签的(那条库在中转那边),
196
+ * 宿主这里查不到;升级前的老 App 手上还有一枚 HMAC token。两种都靠无状态校验兜住,
197
+ * 若一律拒绝,「公网配对后回到局域网直连」这个主要用法会直接 401。
198
+ *
199
+ * 局域网这条也要分原因:被踢的手机回家连局域网,只回一句 401 的话用户看到的还是
200
+ * 「请求失败」,而它其实已经不能做任何事了。
150
201
  */
151
- function authorized(req) {
152
- const presented = presentedCredential(req);
153
- if (presented) {
154
- const known = credentials.lookup(presented, instanceId);
155
- if (known) return credentials.isLive(known);
202
+ function authOutcome(req) {
203
+ const candidates = [];
204
+ const headerRaw = req.headers['x-dsh-device-credential'];
205
+ const header = Array.isArray(headerRaw) ? headerRaw[0] : headerRaw;
206
+ if (typeof header === 'string' && header.trim()) candidates.push(header.trim());
207
+ const bearer = /^Bearer\s+([^\s]+)$/.exec(req.headers.authorization || '');
208
+ if (bearer) candidates.push(bearer[1]);
209
+ const cookie = /dsh_lan=([^;]+)/.exec(req.headers.cookie || '');
210
+ if (cookie) candidates.push(cookie[1]);
211
+ // 候选里有**任何一枚**是本机签发过的,就以它为准:三种出示方式里只要有一枚能查到记录,
212
+ // 「这台设备被吊销了」就是答案,不能靠另外两枚里更宽松的那个绕过它。
213
+ for (const candidate of candidates) {
214
+ const known = credentials.lookup(candidate, instanceId);
215
+ if (!known) continue;
216
+ if (known.revokedAt) return { ok: false, reason: 'revoked' };
217
+ if (known.expiresAt && known.expiresAt <= Date.now()) return { ok: false, reason: 'expired' };
218
+ // 还剩不到一半就顺手续期(§3.9)。
219
+ try { credentials.renew(known); } catch { /* 续期失败不影响这一次 */ }
220
+ return { ok: true, record: known };
156
221
  }
157
- return cookieValid(req) || bearerValid(req);
222
+ if (cookieValid(req) || bearerValid(req)) return { ok: true };
223
+ return { ok: false, reason: 'unauthorized' };
224
+ }
225
+
226
+ function authorized(req) {
227
+ return authOutcome(req).ok;
158
228
  }
159
229
  /**
160
230
  * 配对失败的统一响应。
@@ -170,10 +240,17 @@ function createLanProxy(opts) {
170
240
  } catch { /* 客户端已断开 */ }
171
241
  }
172
242
 
173
- function setCookie(res) {
243
+ /**
244
+ * 下发 cookie。**值必须是请求里要返回的那一枚凭据**(§3.3):新配对发 T1,
245
+ * 一边 T1 一边 HMAC 会让「浏览器侧」与「App 侧」拿到两种不同身份的东西。
246
+ *
247
+ * `Max-Age` 是浏览器侧的事,服务端一律以记录的到期为准,所以这里沿用 30 天;
248
+ * App 不受它影响(它自己带着 Bearer)。
249
+ */
250
+ function setCookie(res, credentialValue) {
174
251
  res.setHeader(
175
252
  'set-cookie',
176
- `dsh_lan=${core.signLanToken(lanSecret, instanceId, core.MOBILE_TOKEN_TTL_SEC)}`
253
+ `dsh_lan=${credentialValue}`
177
254
  + `; Path=/; HttpOnly; SameSite=Lax; Max-Age=${core.MOBILE_TOKEN_TTL_SEC}`,
178
255
  );
179
256
  }
@@ -187,10 +264,24 @@ function createLanProxy(opts) {
187
264
  * 随后 `res.json()` 解析失败,桌面端换过 lanSecret 或凭据被吊销之后就永久卡住 ——
188
265
  * 一个「谁都能打开的页面」换来的这点便利不值这条风险。
189
266
  */
190
- function sendUnauthorized(res) {
267
+ /**
268
+ * 未认证的统一出口。
269
+ *
270
+ * **绝不能回 200 HTML**:App 的 `response.ok` 会为真、把连接通道永久钉在局域网并重置
271
+ * 熔断,随后 `res.json()` 失败报「请求失败 (HTTP 200)」,既不回退中转也不提示重配。
272
+ *
273
+ * 被吊销 / 已过期的要说清是哪一种(§3.8):只回一句「缺少或无效的移动 token」时,
274
+ * 用户看到的还是「请求失败」,而它其实已经不能做任何事了。
275
+ */
276
+ function sendUnauthorized(res, reason = 'unauthorized') {
277
+ const body = reason === 'revoked'
278
+ ? { ok: false, error: { code: 'device-revoked', message: '这台设备已被解除绑定,请重新扫码' } }
279
+ : reason === 'expired'
280
+ ? { ok: false, error: { code: 'device-expired', message: '这台设备的授权已过期,请重新扫码' } }
281
+ : { ok: false, error: { code: 'unauthorized', message: '缺少或无效的移动 token' } };
191
282
  try {
192
283
  res.writeHead(401, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
193
- res.end(JSON.stringify({ ok: false, error: { code: 'unauthorized', message: '缺少或无效的移动 token' } }));
284
+ res.end(JSON.stringify(body));
194
285
  } catch { /* 客户端已断开 */ }
195
286
  }
196
287
 
@@ -198,7 +289,9 @@ function createLanProxy(opts) {
198
289
  // 读取隔离:命中本地镜像快照时直接回,不再连主进程。
199
290
  // 例外:带 ?fresh=1 的写后刷新必须回源——镜像最多旧 500ms,撞上写操作会把刚做完的
200
291
  // 事显示成没生效(判定与实测见 lib/shared/fresh-request.js)。
201
- const body = isFreshRequest(req.url) ? null : readSnapshot({ method: req.method, path: req.url });
292
+ // 带上请求头:工人侧靠 `x-dsh-proto` 判「这条会话是不是加密的」,加密的必须回源主进程
293
+ // (快照那把捷径不封信封,见 sidecar-facade-worker 的 snapshotRead)。
294
+ const body = isFreshRequest(req.url) ? null : readSnapshot({ method: req.method, path: req.url, headers: req.headers });
202
295
  if (body && body.ok) {
203
296
  try {
204
297
  // 与中转那条同一条捷径:列表 / 工作区 / 消息 / 统计都在这里回,压缩才省得下来。
@@ -213,12 +306,11 @@ function createLanProxy(opts) {
213
306
  } catch { /* 已关闭 */ }
214
307
  return;
215
308
  }
216
- const headers = {};
217
- for (const [k, v] of Object.entries(req.headers)) {
218
- if (!HOP_BY_HOP.has(k)) headers[k] = v;
219
- }
220
- // 保留手机端原始 Host(信任围栏按 Host 判定,配合桌面壳 --trusted-host 本机 IP);缺省回退本地上游
221
- if (!headers.host) headers.host = `${upstreamHost}:${upstreamPort}`;
309
+ const headers = withViaHeader(
310
+ Object.fromEntries(Object.entries(req.headers).filter(([k]) => !HOP_BY_HOP.has(k))),
311
+ 'lan',
312
+ `${upstreamHost}:${upstreamPort}`,
313
+ );
222
314
  const isSse = String(req.headers.accept || '').toLowerCase().includes('text/event-stream');
223
315
  if (isSse && upstreamSseOpen >= maxUpstreamSse) {
224
316
  // 留一行日志:否则事后只看到手机端「连不上」,看不出是被这道闸挡的。
@@ -239,6 +331,9 @@ function createLanProxy(opts) {
239
331
  if (!HOP_BY_HOP.has(k)) out[k] = v;
240
332
  }
241
333
  res.writeHead(ures.statusCode, out);
334
+ // SSE 的第一段(`: connected` 与响应头)要立刻到手机,别等内核攒够一个段。
335
+ // 少了这一下,弱网 / 代理缓冲会把「流建好了」拖到十几秒之后。
336
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
242
337
  ures.pipe(res);
243
338
  },
244
339
  );
@@ -270,7 +365,11 @@ function createLanProxy(opts) {
270
365
  if (res.writableEnded) return;
271
366
  try { up.destroy() } catch { /* 已断开 */ }
272
367
  });
273
- req.pipe(up);
368
+ // GET/HEAD 没有请求体:直接收尾发出去,不要依赖 `req` 的 `end` 事件 —— SSE 客户端的
369
+ // 那条连接未必会像普通 fetch 一样把请求流收掉,`pipe` 一直不 end,上游就永远不处理
370
+ // (2026-10-24 现场:设备级 SSE 每 32 秒重试,宿主侧一次都没进处理器)。
371
+ if (req.method === 'GET' || req.method === 'HEAD') up.end();
372
+ else req.pipe(up);
274
373
  }
275
374
 
276
375
  /** 升级还没被 ws 接管时的普通 HTTP 应答(对方看到的是握手失败与状态码)。 */
@@ -314,13 +413,15 @@ function createLanProxy(opts) {
314
413
  return;
315
414
  }
316
415
 
317
- const headers = {};
318
- for (const [key, value] of Object.entries(req.headers)) {
319
- if (!HOP_BY_HOP.has(key) && value !== undefined) headers[key] = value;
320
- }
321
- if (!headers.host) headers.host = `${upstreamHost}:${upstreamPort}`;
322
- const device = credential(req);
323
- if (device?.hostToken) headers.authorization = `Bearer ${device.hostToken}`;
416
+ const headers = withViaHeader(
417
+ Object.fromEntries(Object.entries(req.headers).filter(([key, value]) => !HOP_BY_HOP.has(key) && value !== undefined)),
418
+ 'lan',
419
+ `${upstreamHost}:${upstreamPort}`,
420
+ );
421
+ // 上游要认得这条请求:新 T1 记录没有存下来的宿主令牌,就把请求里出示的那一枚
422
+ // 原样带上去;旧 HMAC 记录仍用它自己的 hostToken。
423
+ const upstreamToken = upstreamTokenOf(credential(req));
424
+ if (upstreamToken) headers.authorization = `Bearer ${upstreamToken}`;
324
425
 
325
426
  let up;
326
427
  try {
@@ -470,43 +571,76 @@ function createLanProxy(opts) {
470
571
  req.on('data', (c) => (b += c));
471
572
  req.on('end', () => {
472
573
  try {
473
- let code = '';
474
- try { code = (JSON.parse(b || '{}').code || '').trim(); } catch { /* ignore */ }
475
- // 长度不符直接判错(timingSafeEqual 长度不同会抛异常,曾导致整个 dsh web 崩溃)
476
- if (!/^\d{6}$/.test(code)) {
477
- res.writeHead(400, { 'content-type': 'application/json' });
478
- res.end(JSON.stringify({ ok: false, error: '配对码需为 6 位数字' }));
574
+ const gate = pairThrottle.take();
575
+ if (!gate.allowed) {
576
+ res.writeHead(429, { 'content-type': 'application/json', 'retry-after': String(gate.retryAfterSec) });
577
+ res.end(JSON.stringify({ ok: false, error: '尝试过于频繁,请稍后再试' }));
479
578
  return;
480
579
  }
481
- if (isPairLocked(ip)) {
482
- res.writeHead(429, { 'content-type': 'application/json' });
483
- res.end(JSON.stringify({ error: '尝试过于频繁,请稍后再试' }));
580
+ const input = JSON.parse(b || '{}')
581
+ // 两种形状(§8.3(2)):带 `sealed` 是协议 v2,带 `code` 是旧 App。
582
+ const sealed = typeof input?.sealed === 'string' ? input.sealed.trim() : '';
583
+ // 与 `/pair` 同一条收口(§10.4(d)):认得协议(请求里带 `proto`)却还带
584
+ // 六位码,只可能是过期的旧二维码 —— 那条形状逐位可试,不值得继续开门。
585
+ if (!sealed && input?.proto !== undefined) {
586
+ res.writeHead(400, { 'content-type': 'application/json' });
587
+ res.end(JSON.stringify({
588
+ ok: false,
589
+ code: 'legacy-pairing-retired',
590
+ error: '这张二维码已过期,请重新扫一下电脑面板上当前的二维码',
591
+ }));
484
592
  return;
485
593
  }
486
- if (!safeEqual(code, String(pairingCode))) {
487
- recordPairFail(ip);
488
- pairDenied(res);
489
- return;
594
+ let suppliedDevice;
595
+ let issuedExtra;
596
+ let pairKey = null;
597
+ if (sealed) {
598
+ const opened = openSealedPairRequest({
599
+ ticket: currentPairing().ticket || '',
600
+ instanceId,
601
+ sealed,
602
+ claimedInstanceId: typeof input.instanceId === 'string' ? input.instanceId : '',
603
+ });
604
+ if (!opened.ok) {
605
+ // 解不开就是「配对失败」,与「配对码错误」同形,不给探测空间。
606
+ pairThrottle.noteFail();
607
+ pairDenied(res);
608
+ return;
609
+ }
610
+ suppliedDevice = opened.device;
611
+ issuedExtra = { deviceKey: opened.deviceKey, keyId: opened.keyId };
612
+ pairKey = opened.pairKey;
613
+ } else {
614
+ const code = typeof input?.code === 'string' ? input.code.trim() : '';
615
+ // 长度不符直接判错(timingSafeEqual 长度不同会抛异常,曾导致整个 dsh web 崩溃)
616
+ if (!/^\d{6}$/.test(code)) {
617
+ res.writeHead(400, { 'content-type': 'application/json' });
618
+ res.end(JSON.stringify({ ok: false, error: '配对码需为 6 位数字' }));
619
+ return;
620
+ }
621
+ if (!safeEqual(code, String(currentPairing().code || ''))) {
622
+ pairThrottle.noteFail();
623
+ pairDenied(res);
624
+ return;
625
+ }
626
+ suppliedDevice = input && typeof input.device === 'object' && input.device !== null
627
+ ? input.device
628
+ : {
629
+ name: input?.name,
630
+ platform: input?.platform,
631
+ osVersion: input?.osVersion,
632
+ version: input?.version,
633
+ appVersion: input?.appVersion,
634
+ id: input?.id,
635
+ deviceId: input?.deviceId,
636
+ }
490
637
  }
491
- // 配对码正确 → 桌面端授权(TeamViewer 模型)。保留旧版
492
- // 仅传 code 的请求,同时透传手机端完整设备元数据。
638
+ // 配对码正确 / 票据解得开 → 桌面端授权(TeamViewer 模型)。透传手机端完整设备元数据。
493
639
  // 注意:这里**不**清限流计数——被拒绝的尝试同样要计数,否则
494
640
  // 猜对配对码但被拒的一方可以无限次重试。
495
- const input = JSON.parse(b || '{}')
496
- const suppliedDevice = input && typeof input.device === 'object' && input.device !== null
497
- ? input.device
498
- : {
499
- name: input?.name,
500
- platform: input?.platform,
501
- osVersion: input?.osVersion,
502
- version: input?.version,
503
- appVersion: input?.appVersion,
504
- id: input?.id,
505
- deviceId: input?.deviceId,
506
- }
507
641
  const device = {
508
- ...Object.fromEntries(Object.entries(suppliedDevice).filter(([, value]) => value !== undefined)),
509
- ip: suppliedDevice.ip || ip,
642
+ ...Object.fromEntries(Object.entries(suppliedDevice || {}).filter(([, value]) => value !== undefined)),
643
+ ip: (suppliedDevice && suppliedDevice.ip) || ip,
510
644
  }
511
645
  onApprove(device)
512
646
  .then(({ approved, token, platform }) => {
@@ -515,33 +649,59 @@ function createLanProxy(opts) {
515
649
  pairDenied(res);
516
650
  return;
517
651
  }
518
- clearPairFails(ip);
519
- const issued = credentials.issue(instanceId, device, token);
520
- const cookieValue = core.signLanToken(lanSecret, instanceId, core.MOBILE_TOKEN_TTL_SEC);
521
- setCookie(res);
522
- // 宿主自己那批直连候选一起回给手机:手输公网地址(域名 / 公网 IP)配对时,
523
- // 二维码那条路没有、也就只有这一处能拿到内网地址。回家后如果那个公网地址
524
- // 打不通(路由器不支持回环 / 域名只在外网解析),手机还能靠这批候选找到这台
525
- // 机器,否则只能删设备重配。App 的 `doPair` 本来就合并响应里的 `lanUrls`
526
- // 与 `ipv6Candidates`,这里不回它就只能记住手输的那一个地址。
527
- const directCandidates = advertisedCandidates(actualPort).map((candidate) => candidate.url).filter(Boolean);
528
- const v6Candidates = ipv6Candidates(actualPort).map((candidate) => candidate.url).filter(Boolean);
529
- res.writeHead(200, { 'content-type': 'application/json' });
530
- // cookie 同时放进 body:RN fetch 读不到 Set-Cookie 响应头,App 需自行携带
531
- res.end(JSON.stringify({
532
- ok: true,
533
- instanceId,
534
- token: token || '',
535
- cookie: `dsh_lan=${cookieValue}`,
536
- ...(directCandidates.length ? { lanUrls: directCandidates } : {}),
537
- ...(v6Candidates.length ? { ipv6Candidates: v6Candidates } : {}),
538
- deviceId: issued.deviceId,
539
- deviceCredential: issued.credential,
540
- credentialExpiresAt: issued.expiresAt,
541
- // 宿主自己的系统标识,不是手机的。手机把它存进设备记录,
542
- // 好让设备行在第一次 status 轮询之前就画得出图标。
543
- ...(platform ? { hostPlatform: platform } : {}),
544
- }));
652
+ pairThrottle.noteSuccess();
653
+ // 新签发的是每设备凭据本身,它同时当 Bearer、cookie 与 deviceCredential
654
+ // (§3.2)。旁路子进程里这一步要打到主进程(唯一写方,§3.7)。
655
+ return Promise.resolve(issueCredential(device, issuedExtra)).then((issued) => {
656
+ const credentialValue = issued.credential;
657
+ setCookie(res, credentialValue);
658
+ // 配对成功即换码 + 换票据(§5.1)。谁截了二维码,那张图从这一刻起就作废。
659
+ if (typeof onPaired === 'function') {
660
+ try { onPaired(); } catch (error) { log('rotate pair failed:', error && error.message); }
661
+ }
662
+ // 宿主自己那批直连候选一起回给手机:手输公网地址(域名 / 公网 IP)配对时,
663
+ // 二维码那条路没有、也就只有这一处能拿到内网地址。回家后如果那个公网地址
664
+ // 打不通(路由器不支持回环 / 域名只在外网解析),手机还能靠这批候选找到这台
665
+ // 机器,否则只能删设备重配。App 的 `doPair` 本来就合并响应里的 `lanUrls`
666
+ // 与 `ipv6Candidates`,这里不回它就只能记住手输的那一个地址。
667
+ const directCandidates = advertisedCandidates(actualPort).map((candidate) => candidate.url).filter(Boolean);
668
+ const v6Candidates = ipv6Candidates(actualPort).map((candidate) => candidate.url).filter(Boolean);
669
+ const payload = {
670
+ ok: true,
671
+ proto: pairKey ? 2 : 1,
672
+ instanceId,
673
+ token: credentialValue,
674
+ cookie: `dsh_lan=${credentialValue}`,
675
+ // 手机拿它对一次时钟(§8.3(7)):时钟偏太多会被当成重放,
676
+ // 而用户完全不知道「这个 App 连不上」的原因是手机时间不准。
677
+ serverTime: Date.now(),
678
+ ...(directCandidates.length ? { lanUrls: directCandidates } : {}),
679
+ ...(v6Candidates.length ? { ipv6Candidates: v6Candidates } : {}),
680
+ deviceId: issued.deviceId,
681
+ deviceCredential: issued.credential,
682
+ credentialExpiresAt: issued.expiresAt,
683
+ // 宿主自己的系统标识,不是手机的。手机把它存进设备记录,
684
+ // 好让设备行在第一次 status 轮询之前就画得出图标。
685
+ ...(platform ? { hostPlatform: platform } : {}),
686
+ };
687
+ // 新协议的回包必须加密:里面有 token / cookie / deviceCredential,
688
+ // 明文回包等于让链路上的任何人顺手拿到这台设备未来一年的通行证。
689
+ if (pairKey) {
690
+ try {
691
+ res.writeHead(200, { 'content-type': 'application/json' });
692
+ res.end(JSON.stringify(sealPairResponse(pairKey, instanceId, payload)));
693
+ return;
694
+ } catch (error) {
695
+ log('pair seal failed:', error && error.message);
696
+ res.writeHead(500, { 'content-type': 'application/json' });
697
+ res.end(JSON.stringify({ ok: false, error: 'internal error' }));
698
+ return;
699
+ }
700
+ }
701
+ res.writeHead(200, { 'content-type': 'application/json' });
702
+ // cookie 同时放进 body:RN fetch 读不到 Set-Cookie 响应头,App 需自行携带
703
+ res.end(JSON.stringify(payload));
704
+ })
545
705
  })
546
706
  .catch((err) => {
547
707
  log('approve error:', err.message);
@@ -572,13 +732,14 @@ function createLanProxy(opts) {
572
732
  proxyToUpstream(req, res);
573
733
  return;
574
734
  }
575
- if (!authorized(req)) {
576
- sendUnauthorized(res);
735
+ const auth = authOutcome(req);
736
+ if (!auth.ok) {
737
+ sendUnauthorized(res, auth.reason);
577
738
  return;
578
739
  }
579
- const c = credential(req);
580
- if (c?.hostToken) req.headers.authorization = `Bearer ${c.hostToken}`;
581
- proxyToUpstream(req, res);
740
+ const upstreamToken = upstreamTokenOf(credential(req));
741
+ if (upstreamToken) req.headers.authorization = `Bearer ${upstreamToken}`;
742
+ proxyToUpstream(req, res);
582
743
  } catch (err) {
583
744
  log('request error:', err.message);
584
745
  try {
@@ -20,9 +20,9 @@
20
20
  */
21
21
 
22
22
  const T = {
23
- register: 0x01, // id=0 JSON {instanceId, pairingCode, name, lanUrls?, lanCandidates?}
23
+ register: 0x01, // id=0 JSON {instanceId, name, lanUrls?, lanCandidates?, secret?}
24
24
  registered: 0x02, // id=0 JSON {}
25
- updatePairing: 0x03, // id=0 JSON {pairingCode}
25
+ updatePairing: 0x03, // id=0 JSON {}(保留编号,宿主已不再发;中转从不读)
26
26
  httpRequest: 0x04, // id=N JSON {method, path, headers};body 随后以 httpBody 流式发送
27
27
  httpBody: 0x05, // id=N raw bytes(空 payload = body 结束)
28
28
  httpHead: 0x06, // id=N JSON {status, headers}
@@ -45,6 +45,8 @@ const T = {
45
45
  sync: 0x17, // id=0 JSON {kind, ...};桌面→中转主动推送(移动端镜像缓存)
46
46
  revokeAll: 0x18, // id=0 JSON {at};桌面请求中转清空本实例全部设备授权
47
47
  revokeAllAck: 0x19, // id=0 JSON {ok, revoked};中转确认已清空
48
+ pushRegister: 0x1A, // id=0 JSON {deviceId, platform, token};宿主代 App 上报 iOS 推送令牌(本实例)
49
+ pushSend: 0x1B, // id=0 JSON {deviceId, title, body, sessionId?, kind};宿主请求中转发一条 APNs 推送
48
50
  };
49
51
 
50
52
  const TYPE_NAME = {};