dsh-mobile-plugin 0.1.92 → 0.1.94
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/README.md +3 -1
- package/config/agent-presets/standard/agent.cordis.yml +7 -4
- package/lib/desktop-view.cjs +20 -0
- package/lib/impl.js +7 -2
- package/lib/mirror/pusher.cjs +40 -0
- package/lib/mobile/archive-clean.js +17 -18
- package/lib/mobile/attachments.js +23 -6
- package/lib/mobile/events.js +28 -3
- package/lib/mobile/history-derive.js +53 -7
- package/lib/mobile/host-bridge/endpoints.js +0 -1
- package/lib/mobile/host-bridge/event-synthesis.js +33 -3
- package/lib/mobile/host-bridge/shims.js +28 -4
- package/lib/mobile/monitor.js +135 -0
- package/lib/mobile/session-flags.js +63 -0
- package/lib/mobile/session-models.js +6 -0
- package/lib/mobile/session-preview.js +70 -0
- package/lib/mobile/session-send.js +8 -2
- package/lib/mobile/session-snapshot.js +13 -19
- package/lib/mobile/sessions.js +48 -91
- package/lib/mobile/stream.js +113 -16
- package/lib/mobile/uploads.js +14 -7
- package/lib/mobile/workspaces.js +17 -3
- package/lib/panel/index.html +7 -7
- package/lib/reconnect-scheduler.cjs +31 -4
- package/lib/relay/lan-network.cjs +156 -12
- package/lib/relay/lan-proxy.js +85 -7
- package/lib/relay/protocol.js +3 -3
- package/lib/relay/tunnel.js +17 -4
- package/lib/routes/control-plane.js +11 -5
- package/lib/routes/mobile-api.js +22 -1
- package/lib/session-cleanup.cjs +160 -27
- package/lib/shared/desktop-view-signal.js +66 -0
- package/lib/shared/http-util.js +50 -4
- package/lib/shared/list-revision.js +20 -1
- package/lib/shared/sse-write.js +52 -2
- package/lib/sidecar-facade-worker.cjs +16 -2
- package/lib/update-install.cjs +71 -13
- package/package.json +2 -2
|
@@ -9,20 +9,47 @@
|
|
|
9
9
|
*
|
|
10
10
|
* 这里把「同一个待触发窗口内只保留一个定时器」这一不变量收敛成一个模块,
|
|
11
11
|
* 以便用毫秒级延迟做真实的行为测试。端到端数连接次数是分不出来的:
|
|
12
|
-
*
|
|
13
|
-
*
|
|
12
|
+
* startTunnel 开头的 stopTunnel 会吸收重复定时器造成的大部分可见差异。
|
|
13
|
+
*
|
|
14
|
+
* 延迟本身带**抖动**与**退避**:
|
|
15
|
+
* - 抖动:中转重启(或宿主机休眠醒来)之后,所有宿主都会在同一刻掉线,固定 5s 会让
|
|
16
|
+
* 它们踩着同一个点回来;上下抖两千毫秒就把这一波摊开。
|
|
17
|
+
* - 退避:连续失败(中转没起来、注册密钥不对)时不该每 5 秒敲一次,逐次翻倍到上限;
|
|
18
|
+
* 一次成功的注册由调用方 `reset()` 清账。
|
|
14
19
|
*/
|
|
15
|
-
|
|
20
|
+
|
|
21
|
+
/** 连续失败最多翻到 delayMs 的 2^BACKOFF_EXP_CAP 倍(再被 maxDelayMs 压住)。 */
|
|
22
|
+
const BACKOFF_EXP_CAP = 5
|
|
23
|
+
|
|
24
|
+
function createReconnectScheduler({
|
|
25
|
+
delayMs,
|
|
26
|
+
jitterMs = 0,
|
|
27
|
+
maxDelayMs = Number.POSITIVE_INFINITY,
|
|
28
|
+
random = Math.random,
|
|
29
|
+
onFire,
|
|
30
|
+
setTimer = setTimeout,
|
|
31
|
+
clearTimer = clearTimeout,
|
|
32
|
+
} = {}) {
|
|
16
33
|
if (typeof delayMs !== 'number' || !(delayMs >= 0)) throw new Error('invalid delayMs')
|
|
34
|
+
if (typeof jitterMs !== 'number' || !(jitterMs >= 0)) throw new Error('invalid jitterMs')
|
|
35
|
+
if (typeof maxDelayMs !== 'number' || !(maxDelayMs > 0)) throw new Error('invalid maxDelayMs')
|
|
36
|
+
if (typeof random !== 'function') throw new Error('invalid random')
|
|
17
37
|
if (typeof onFire !== 'function') throw new Error('invalid onFire')
|
|
18
38
|
let timer = null
|
|
39
|
+
let misses = 0
|
|
19
40
|
return {
|
|
20
41
|
/** 排程一次重连;已有待触发的定时器时返回 false,表示被幂等吸收。 */
|
|
21
42
|
schedule() {
|
|
22
43
|
if (timer !== null) return false
|
|
23
|
-
|
|
44
|
+
const backoff = misses === 0 ? 1 : 2 ** Math.min(misses, BACKOFF_EXP_CAP)
|
|
45
|
+
const base = Math.min(delayMs * backoff, maxDelayMs)
|
|
46
|
+
const wait = base + (jitterMs > 0 ? Math.floor(random() * jitterMs) : 0)
|
|
47
|
+
misses += 1
|
|
48
|
+
timer = setTimer(() => { timer = null; onFire() }, wait)
|
|
24
49
|
return true
|
|
25
50
|
},
|
|
51
|
+
/** 连上之后清掉连续失败计数(下一次掉线重新从 delayMs 起算)。 */
|
|
52
|
+
reset() { misses = 0 },
|
|
26
53
|
/** 取消待触发的重连(显式停止隧道时调用)。 */
|
|
27
54
|
cancel() {
|
|
28
55
|
if (timer === null) return
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 网卡枚举与候选地址:私网 /
|
|
2
|
+
* 网卡枚举与候选地址:私网 / 公网 / IPv6 全球单播三份清单,外加「用户选中的那条排最前」。
|
|
3
3
|
*
|
|
4
4
|
* 收口在这一个文件里,是因为 App 只认「按顺序挨个试」这一种语义 —— 顺序就是优先级。
|
|
5
|
+
*
|
|
6
|
+
* 三份的区别在**手机在哪**:私网那份是「同一个 Wi-Fi 里能直连」,公网那份是「这台机器
|
|
7
|
+
* 自己就挂在公网上」,IPv6 那份是「手机在别的网络里、从公网拨过来」——最后这份是 2026-10
|
|
8
|
+
* 新加的,它**不并进前两份**,因为 App 对它的用法完全不同(要和公网中转比速度,见
|
|
9
|
+
* `ipv6Candidates` 的注释)。
|
|
5
10
|
*/
|
|
6
11
|
'use strict'
|
|
7
12
|
|
|
@@ -94,13 +99,19 @@ function interfaceRank(name) {
|
|
|
94
99
|
}
|
|
95
100
|
|
|
96
101
|
/**
|
|
97
|
-
* 扫一遍网卡,把 `accept`
|
|
98
|
-
*
|
|
102
|
+
* 扫一遍网卡,把 `accept` 认的、属于 `family` 的地址收成候选(剔除虚拟/VPN 网卡、按物理
|
|
103
|
+
* 网卡优先级排序、去重、最多 16 条)。
|
|
104
|
+
*
|
|
105
|
+
* 三份清单共用这一段:差别**只在 accept 与 family**——一个挑私网 v4、一个挑公网 v4、
|
|
106
|
+
* 一个挑全球单播 v6。网卡黑名单与排序规则必须一模一样,否则同一台机器上几份候选会给出
|
|
107
|
+
* 互相矛盾的顺序。
|
|
99
108
|
*
|
|
100
|
-
* `
|
|
101
|
-
*
|
|
109
|
+
* 去重按 `key`:IPv4 是地址本身,IPv6 是解出来的数值 —— 同一个地址的压缩写法、展开写法
|
|
110
|
+
* 与大小写(`240e::1` / `240E:0:0:0:0:0:0:1`)必须算成一条,否则 App 会为同一个地址
|
|
111
|
+
* 各探一次。
|
|
102
112
|
*/
|
|
103
|
-
function collectEndpoints(interfaces, accept) {
|
|
113
|
+
function collectEndpoints(interfaces, accept, family = 'IPv4') {
|
|
114
|
+
const wantV6 = family === 'IPv6'
|
|
104
115
|
const found = []
|
|
105
116
|
let order = 0
|
|
106
117
|
for (const [name, entries] of Object.entries(interfaces || {})) {
|
|
@@ -108,24 +119,46 @@ function collectEndpoints(interfaces, accept) {
|
|
|
108
119
|
if (isVpnInterface(name) || isVirtualInterface(name)) continue
|
|
109
120
|
for (const entry of entries || []) {
|
|
110
121
|
if (!entry || entry.internal) continue
|
|
111
|
-
|
|
122
|
+
// family 必须**显式**对上:缺字段的畸形条目不参与任何一份清单
|
|
123
|
+
// (`test/lan-network.test.cjs` 的「空 / 异常入参不抛错」钉着这一条)。
|
|
124
|
+
const entryV6 = entry.family === 'IPv6' || entry.family === 6
|
|
125
|
+
const entryV4 = entry.family === 'IPv4' || entry.family === 4
|
|
126
|
+
if (wantV6 ? !entryV6 : !entryV4) continue
|
|
112
127
|
if (!accept(entry.address)) continue
|
|
113
|
-
found.push({
|
|
128
|
+
found.push({
|
|
129
|
+
name,
|
|
130
|
+
address: entry.address,
|
|
131
|
+
order: current,
|
|
132
|
+
key: wantV6 ? String(ipv6Number(entry.address)) : entry.address,
|
|
133
|
+
})
|
|
114
134
|
}
|
|
115
135
|
}
|
|
116
136
|
const seen = new Set()
|
|
117
137
|
return found
|
|
118
138
|
.sort((a, b) => interfaceRank(a.name) - interfaceRank(b.name) || a.order - b.order)
|
|
119
139
|
.filter((item) => {
|
|
120
|
-
if (seen.has(item.
|
|
121
|
-
seen.add(item.
|
|
140
|
+
if (seen.has(item.key)) return false
|
|
141
|
+
seen.add(item.key)
|
|
122
142
|
return true
|
|
123
143
|
})
|
|
124
144
|
.slice(0, 16)
|
|
125
145
|
}
|
|
126
146
|
|
|
127
|
-
|
|
128
|
-
|
|
147
|
+
/**
|
|
148
|
+
* 产候选。IPv6 在 URL 里**必须带方括号**(`http://[240e::1]:18787`),裸着写出来是
|
|
149
|
+
* `http://240e::1:18787` —— 一个解析不出来的坏地址。
|
|
150
|
+
*
|
|
151
|
+
* `address` 字段刻意**不带**方括号:面板的「手机优先用这条」是按地址匹配的
|
|
152
|
+
* (`orderCandidates`),用户看到与填进去的都是裸地址。
|
|
153
|
+
*/
|
|
154
|
+
function toCandidates(list, port, family = 'IPv4') {
|
|
155
|
+
const v6 = family === 'IPv6'
|
|
156
|
+
return list.map((item) => ({
|
|
157
|
+
url: `http://${v6 ? `[${item.address}]` : item.address}:${port}`,
|
|
158
|
+
interface: item.name,
|
|
159
|
+
family,
|
|
160
|
+
address: item.address,
|
|
161
|
+
}))
|
|
129
162
|
}
|
|
130
163
|
|
|
131
164
|
// Structured view of the usable LAN endpoints. The relay's `normalizeLanMetadata`
|
|
@@ -185,6 +218,112 @@ function publicCandidates(port, interfaces = os.networkInterfaces()) {
|
|
|
185
218
|
return publicInterfaces(port, interfaces)
|
|
186
219
|
}
|
|
187
220
|
|
|
221
|
+
/**
|
|
222
|
+
* 把 IPv6 文本解成 8 段 16 位十六进制,处理内嵌 IPv4 尾巴、压缩写法与 zone id。
|
|
223
|
+
* 解不出来一律回 null —— 判据函数宁可不认,也不能把一串看不懂的东西当地址发出去。
|
|
224
|
+
*/
|
|
225
|
+
function ipv6Groups(text) {
|
|
226
|
+
const [head, tail, ...rest] = text.split('::')
|
|
227
|
+
if (rest.length) return null
|
|
228
|
+
const toGroups = (part) => (part === '' ? [] : part.split(':'))
|
|
229
|
+
const headGroups = toGroups(head)
|
|
230
|
+
const tailGroups = tail === undefined ? [] : toGroups(tail)
|
|
231
|
+
// 内嵌 IPv4 尾巴(`::ffff:192.168.1.1`、`64:ff9b::1.2.3.4`)折成两段。
|
|
232
|
+
const last = tailGroups.length ? tailGroups : headGroups
|
|
233
|
+
const embedded = last.length ? last[last.length - 1] : ''
|
|
234
|
+
if (embedded.includes('.')) {
|
|
235
|
+
const value = ipv4Number(embedded)
|
|
236
|
+
if (value === null) return null
|
|
237
|
+
last.splice(last.length - 1, 1, ((value >>> 16) & 0xffff).toString(16), (value & 0xffff).toString(16))
|
|
238
|
+
}
|
|
239
|
+
const isHexGroup = (group) => /^[0-9a-f]{1,4}$/.test(group)
|
|
240
|
+
if (![...headGroups, ...tailGroups].every(isHexGroup)) return null
|
|
241
|
+
if (tail === undefined) return headGroups.length === 8 ? headGroups : null
|
|
242
|
+
const fill = 8 - headGroups.length - tailGroups.length
|
|
243
|
+
if (fill < 1) return null
|
|
244
|
+
return [...headGroups, ...Array(fill).fill('0'), ...tailGroups]
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** IPv6 文本 → 128 位数值;非法回 null。方括号与 `%en0` 这类 zone id 都先剥掉。 */
|
|
248
|
+
function ipv6Number(address) {
|
|
249
|
+
const text = String(address === null || address === undefined ? '' : address)
|
|
250
|
+
.trim()
|
|
251
|
+
.toLowerCase()
|
|
252
|
+
.replace(/^\[/, '')
|
|
253
|
+
.replace(/\]$/, '')
|
|
254
|
+
.split('%')[0]
|
|
255
|
+
if (!text.includes(':')) return null
|
|
256
|
+
const groups = ipv6Groups(text)
|
|
257
|
+
if (!groups) return null
|
|
258
|
+
let value = 0n
|
|
259
|
+
for (const group of groups) value = (value << 16n) | BigInt(parseInt(group, 16))
|
|
260
|
+
return value
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function inV6Prefix(value, prefix, length) {
|
|
264
|
+
const shift = BigInt(128 - length)
|
|
265
|
+
return value >> shift === prefix >> shift
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* 值得上报的 IPv6 地址:**全球单播**(`2000::/3`)且不是下面这三种假公网。
|
|
270
|
+
*
|
|
271
|
+
* - `2002::/16`(6to4):地址里的那截 IPv4 要靠一台公网中继转,今天基本没人还在跑中继,
|
|
272
|
+
* 报出去就是一个拨不通的地址;
|
|
273
|
+
* - `2001::/32`(Teredo):隧道端点,本身不是能直接连的服务地址;
|
|
274
|
+
* - `2001:db8::/32`:文档示例段。
|
|
275
|
+
*
|
|
276
|
+
* 其余非全球单播的一律不收:链路本地(`fe80::/10`)只在本链路内通,手机拿不到;
|
|
277
|
+
* ULA(`fc00::/7`)要同一个内网前缀,手机同样拿不到 —— 报了都是死地址;已废弃的站点本地
|
|
278
|
+
* `fec0::/10`、组播、回环、未指定、IPv4-mapped 同理。
|
|
279
|
+
*
|
|
280
|
+
* **不做**的事:不猜哪条是 RFC 4941 临时地址(`os.networkInterfaces()` 根本不暴露这个标志,
|
|
281
|
+
* 硬造的判据只会误伤稳定地址)。同一块网卡上并存的一条稳定地址与两条临时地址会一起报出去
|
|
282
|
+
* ——临时地址也是全球单播、当下也真的能连,等它轮换掉时 App 早就在下一轮发现里拿到新清单了。
|
|
283
|
+
*/
|
|
284
|
+
const V6_GLOBAL_UNICAST = [ipv6Number('2000::'), 3]
|
|
285
|
+
const V6_NOT_GLOBAL = [
|
|
286
|
+
['2002::', 16],
|
|
287
|
+
['2001::', 32],
|
|
288
|
+
['2001:db8::', 32],
|
|
289
|
+
].map(([spec, length]) => [ipv6Number(spec), length])
|
|
290
|
+
|
|
291
|
+
function isGlobalUnicastV6(address) {
|
|
292
|
+
const value = ipv6Number(address)
|
|
293
|
+
if (value === null) return false
|
|
294
|
+
if (!inV6Prefix(value, V6_GLOBAL_UNICAST[0], V6_GLOBAL_UNICAST[1])) return false
|
|
295
|
+
return !V6_NOT_GLOBAL.some(([prefix, length]) => inV6Prefix(value, prefix, length))
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* IPv6 全球单播候选:手机**不在同一个 Wi-Fi 里**时唯一有可能直连进来的地址。
|
|
300
|
+
*
|
|
301
|
+
* 为什么单独一份、不并进 `lanInterfaces`:那份的语义是「手机在同一 Wi-Fi 里能直连」,
|
|
302
|
+
* App 对它的处理是「在网就在、不在就连不上」——共用退避窗口的话,手机刚离开家里 Wi-Fi
|
|
303
|
+
* 的那一刻(正是最该试 IPv6 的时刻)局域网一进退避就把 IPv6 一起压住了。App 侧对应一条
|
|
304
|
+
* 独立通道,还要拿它和公网中转**比速度**,所以这里只负责如实产出、不去猜哪条更快。
|
|
305
|
+
*
|
|
306
|
+
* 最多 4 条:这台机器上通常是一到三条(一条稳定地址 + 一两条临时地址)。上限是为了二维码
|
|
307
|
+
* ——候选是随二维码一起带走的,条数一多二维码就密到扫不动。
|
|
308
|
+
*/
|
|
309
|
+
const IPV6_CANDIDATE_MAX = 4
|
|
310
|
+
function ipv6Interfaces(port, interfaces = os.networkInterfaces()) {
|
|
311
|
+
return toCandidates(collectEndpoints(interfaces, isGlobalUnicastV6, 'IPv6'), port, 'IPv6')
|
|
312
|
+
.slice(0, IPV6_CANDIDATE_MAX)
|
|
313
|
+
}
|
|
314
|
+
function ipv6Urls(port, interfaces = os.networkInterfaces()) {
|
|
315
|
+
return ipv6Interfaces(port, interfaces).map((candidate) => candidate.url)
|
|
316
|
+
}
|
|
317
|
+
function ipv6Candidates(port, interfaces = os.networkInterfaces()) {
|
|
318
|
+
// 与 `advertisedCandidates` 同一条口径:取不到网卡(异常)时回空清单、绝不抛 —— 它喂的
|
|
319
|
+
// 也是二维码与 `/connection-info`,把它整条控制面拖下水不值得。
|
|
320
|
+
try {
|
|
321
|
+
return ipv6Interfaces(port, interfaces)
|
|
322
|
+
} catch {
|
|
323
|
+
return []
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
188
327
|
/**
|
|
189
328
|
* 对外公布的候选地址:**先私网,一个都没有时才退到公网**。
|
|
190
329
|
*
|
|
@@ -234,9 +373,11 @@ function orderCandidates(list, preferred) {
|
|
|
234
373
|
|
|
235
374
|
module.exports = {
|
|
236
375
|
ipv4Number,
|
|
376
|
+
ipv6Number,
|
|
237
377
|
isPrivateLanAddress,
|
|
238
378
|
isLanCandidateAddress,
|
|
239
379
|
isPublicAddress,
|
|
380
|
+
isGlobalUnicastV6,
|
|
240
381
|
isVpnInterface,
|
|
241
382
|
isVirtualInterface,
|
|
242
383
|
interfaceRank,
|
|
@@ -246,6 +387,9 @@ module.exports = {
|
|
|
246
387
|
publicInterfaces,
|
|
247
388
|
publicUrls,
|
|
248
389
|
publicCandidates,
|
|
390
|
+
ipv6Interfaces,
|
|
391
|
+
ipv6Urls,
|
|
392
|
+
ipv6Candidates,
|
|
249
393
|
advertisedCandidates,
|
|
250
394
|
orderCandidates,
|
|
251
395
|
}
|
package/lib/relay/lan-proxy.js
CHANGED
|
@@ -14,8 +14,10 @@
|
|
|
14
14
|
const http = require('node:http');
|
|
15
15
|
const { WebSocketServer, WebSocket } = require('ws');
|
|
16
16
|
const core = require('./core.js');
|
|
17
|
+
const { isGlobalUnicastV6 } = require('./lan-network.cjs');
|
|
17
18
|
const { safeEqual } = require('../shared/dshtoken');
|
|
18
19
|
const { isFreshRequest } = require('../shared/fresh-request');
|
|
20
|
+
const { encodeJsonBody } = require('../shared/http-util');
|
|
19
21
|
const credentials = require('../shared/credentials-entry.js').credentialsEntry();
|
|
20
22
|
|
|
21
23
|
const HOP_BY_HOP = new Set([
|
|
@@ -62,6 +64,8 @@ function createLanProxy(opts) {
|
|
|
62
64
|
} = opts;
|
|
63
65
|
|
|
64
66
|
let server = null;
|
|
67
|
+
// IPv6 那条监听(`::`,ipv6Only)。它可能压根没建立(机器没有 IPv6),所以单独存一份。
|
|
68
|
+
let v6Server = null;
|
|
65
69
|
let wss = null;
|
|
66
70
|
// 活着的上游请求计数(自检台账与失控闸消费):见 proxyToUpstream 的拆除注释。
|
|
67
71
|
let upstreamOpen = 0;
|
|
@@ -76,6 +80,11 @@ function createLanProxy(opts) {
|
|
|
76
80
|
function log(...args) {
|
|
77
81
|
onLog('[lan-proxy]', ...args);
|
|
78
82
|
}
|
|
83
|
+
/** 日志里的监听地址:IPv6 按 URL 习惯套方括号(`[::]`),IPv4 原样。 */
|
|
84
|
+
function formatHost(host) {
|
|
85
|
+
const text = String(host);
|
|
86
|
+
return text.includes(':') ? `[${text}]` : text;
|
|
87
|
+
}
|
|
79
88
|
|
|
80
89
|
function isPairLocked(ip) {
|
|
81
90
|
const l = pairLocks.get(ip);
|
|
@@ -192,8 +201,15 @@ function createLanProxy(opts) {
|
|
|
192
201
|
const body = isFreshRequest(req.url) ? null : readSnapshot({ method: req.method, path: req.url });
|
|
193
202
|
if (body && body.ok) {
|
|
194
203
|
try {
|
|
195
|
-
|
|
196
|
-
|
|
204
|
+
// 与中转那条同一条捷径:列表 / 工作区 / 消息 / 统计都在这里回,压缩才省得下来。
|
|
205
|
+
const encoded = encodeJsonBody(JSON.stringify(body), req.headers['accept-encoding']);
|
|
206
|
+
res.writeHead(200, {
|
|
207
|
+
'content-type': 'application/json; charset=utf-8',
|
|
208
|
+
'cache-control': 'no-store',
|
|
209
|
+
vary: 'accept-encoding',
|
|
210
|
+
...(encoded.gzip ? { 'content-encoding': 'gzip' } : {}),
|
|
211
|
+
});
|
|
212
|
+
res.end(encoded.body);
|
|
197
213
|
} catch { /* 已关闭 */ }
|
|
198
214
|
return;
|
|
199
215
|
}
|
|
@@ -425,11 +441,31 @@ function createLanProxy(opts) {
|
|
|
425
441
|
|
|
426
442
|
function start() {
|
|
427
443
|
return new Promise((resolve, reject) => {
|
|
428
|
-
|
|
444
|
+
/**
|
|
445
|
+
* v4 / v6 两条监听共用的请求处理器。协议族只影响 `req.socket.remoteAddress` 的
|
|
446
|
+
* 形状(IPv4 是 `::ffff:a.b.c.d` 或 `a.b.c.d`,IPv6 是原生地址),路由与鉴权完全一致。
|
|
447
|
+
*/
|
|
448
|
+
const handleRequest = (req, res) => {
|
|
429
449
|
const url = new URL(req.url, 'http://lan.local');
|
|
430
450
|
try {
|
|
431
451
|
if (url.pathname === '/__lan_pair' && req.method === 'POST') {
|
|
432
452
|
const ip = req.socket.remoteAddress || '';
|
|
453
|
+
// 双栈之后这个**不需要凭据**的接口在公网 IPv6 上也够得着,而它本来是「同一个
|
|
454
|
+
// Wi-Fi 内的手机直接配对」用的。原生全球单播 IPv6 来源一律按「配对码错误」回
|
|
455
|
+
// (`pairDenied`:JSON、401,与码错完全同形,不承认这个接口为谁而开)——刚好
|
|
456
|
+
// 也是地图 A3-12 那条刻意设计要的形状:未认证请求绝不能回 2xx,否则 App 会把
|
|
457
|
+
// 连接通道钉在直连并重置熔断,随后 json 解析失败,既不回退中转也不提示重配。
|
|
458
|
+
//
|
|
459
|
+
// 不记限流计数:按源地址计数在 IPv6 上等于按 /128 计数,换个源地址就是新桶,
|
|
460
|
+
// 记了没用、白留痕迹。
|
|
461
|
+
//
|
|
462
|
+
// 只收这一格:只有公网 IPv4 的机器上,手机经公网 IPv4 打进来的配对是**现有受
|
|
463
|
+
// 支持流程**(配对二维码里就带公网 IPv4 候选),判据必须是「原生全球 IPv6」
|
|
464
|
+
// 而不是「公网地址」——后者会把那条正常用法一起拦掉。判据在 lan-network.cjs。
|
|
465
|
+
if (isGlobalUnicastV6(ip)) {
|
|
466
|
+
pairDenied(res);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
433
469
|
let b = '';
|
|
434
470
|
req.on('data', (c) => (b += c));
|
|
435
471
|
req.on('end', () => {
|
|
@@ -541,7 +577,9 @@ function createLanProxy(opts) {
|
|
|
541
577
|
res.end('internal error');
|
|
542
578
|
} catch { /* ignore */ }
|
|
543
579
|
}
|
|
544
|
-
}
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
server = http.createServer(handleRequest);
|
|
545
583
|
|
|
546
584
|
wss = new WebSocketServer({ noServer: true });
|
|
547
585
|
server.on('upgrade', handleUpgrade);
|
|
@@ -565,7 +603,8 @@ function createLanProxy(opts) {
|
|
|
565
603
|
server.on('error', (err) => log('server error:', err.message))
|
|
566
604
|
const address = server.address()
|
|
567
605
|
actualPort = address && typeof address.port === 'number' ? address.port : port
|
|
568
|
-
|
|
606
|
+
const boundHost = address && address.address ? address.address : '0.0.0.0'
|
|
607
|
+
log(`listening on ${formatHost(boundHost)}:${actualPort}`)
|
|
569
608
|
onBound()
|
|
570
609
|
}
|
|
571
610
|
server.once('error', onError)
|
|
@@ -573,6 +612,40 @@ function createLanProxy(opts) {
|
|
|
573
612
|
server.listen(port, '0.0.0.0')
|
|
574
613
|
})
|
|
575
614
|
|
|
615
|
+
/**
|
|
616
|
+
* IPv6 那条监听:拿 IPv4 实绑的端口,`::` + **显式 `ipv6Only: true`**。
|
|
617
|
+
*
|
|
618
|
+
* 为什么不合成一条双栈监听:`impl.js` 的端口占用探测真去 `listen(port, '0.0.0.0')`
|
|
619
|
+
* 试探,一条双栈的 `::` 会把首选端口一律探成「被占」,于是落到内核随机端口并写盘,
|
|
620
|
+
* 端口每次启动都可能漂;反过来把探测也改成 `::`,关掉 IPv6 的机器上探测会因
|
|
621
|
+
* EAFNOSUPPORT 永远判「被占」,同样漂。各管一个协议族就没这个问题:`ipv6Only: true`
|
|
622
|
+
* 之后 v6 那个 socket 不碰 IPv4(不设这个标志在 Linux 上才会撞),同端口可以共存。
|
|
623
|
+
*
|
|
624
|
+
* 它是**软的**:只有「手机不在同一 Wi-Fi、只有 IPv6」时才用得上,绑不上就记一行、
|
|
625
|
+
* 只留 IPv4 直连——绝不影响 IPv4,也绝不往上抛。
|
|
626
|
+
*/
|
|
627
|
+
const startV6 = () => new Promise((onDone) => {
|
|
628
|
+
const v6 = http.createServer(handleRequest);
|
|
629
|
+
v6.on('upgrade', handleUpgrade);
|
|
630
|
+
v6.once('error', (err) => {
|
|
631
|
+
log(`IPv6 监听未建立(${err.code || err.message}),只保留 IPv4 直连`);
|
|
632
|
+
onDone();
|
|
633
|
+
});
|
|
634
|
+
v6.once('listening', () => {
|
|
635
|
+
v6.on('error', (err) => log('IPv6 server error:', err.message));
|
|
636
|
+
v6Server = v6;
|
|
637
|
+
const address = v6.address();
|
|
638
|
+
log(`listening on ${formatHost(address.address)}:${address.port}`);
|
|
639
|
+
onDone();
|
|
640
|
+
});
|
|
641
|
+
try {
|
|
642
|
+
v6.listen(actualPort, '::', { ipv6Only: true });
|
|
643
|
+
} catch (err) {
|
|
644
|
+
log(`IPv6 监听未建立(${err.code || err.message}),只保留 IPv4 直连`);
|
|
645
|
+
onDone();
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
576
649
|
// 端口自适应:首选端口在探测之后、真正 listen 之前被别的进程抢走时(另一个
|
|
577
650
|
// 实例刚起、或者上一轮 stop() 还没释放干净),退到内核分配的空闲端口,而不是
|
|
578
651
|
// 让一次 EADDRINUSE 把整条 LAN 直连带走。
|
|
@@ -580,10 +653,10 @@ function createLanProxy(opts) {
|
|
|
580
653
|
// catch 分支根本不会执行,外层那个 Promise 就永远 pending——服务器确实在监听,
|
|
581
654
|
// 但调用方拿到的 start() 永不落定,lan.state 永远停在 connecting、error 还是
|
|
582
655
|
// null。端口回退是兜底,不是主路径。
|
|
583
|
-
bind(lanPort).then(() => resolve(), async (error) => {
|
|
656
|
+
bind(lanPort).then(async () => { await startV6(); resolve() }, async (error) => {
|
|
584
657
|
if (!error || error.code !== 'EADDRINUSE') return reject(error);
|
|
585
658
|
log(`port ${lanPort} busy (${error.code}), falling back to an OS-assigned port`);
|
|
586
|
-
try { await bind(0); resolve() } catch (retryError) { reject(retryError) }
|
|
659
|
+
try { await bind(0); await startV6(); resolve() } catch (retryError) { reject(retryError) }
|
|
587
660
|
});
|
|
588
661
|
});
|
|
589
662
|
}
|
|
@@ -604,6 +677,11 @@ function createLanProxy(opts) {
|
|
|
604
677
|
try { server.closeAllConnections?.(); } catch { /* ignore */ }
|
|
605
678
|
server = null;
|
|
606
679
|
}
|
|
680
|
+
if (v6Server) {
|
|
681
|
+
try { v6Server.close(); } catch { /* ignore */ }
|
|
682
|
+
try { v6Server.closeAllConnections?.(); } catch { /* ignore */ }
|
|
683
|
+
v6Server = null;
|
|
684
|
+
}
|
|
607
685
|
}
|
|
608
686
|
|
|
609
687
|
// 对外只暴露「实际在监听的端口」。配置端口仍是 `lanPort`(那只是首选值)。
|
package/lib/relay/protocol.js
CHANGED
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
* - 控制消息(register / httpHead 等)payload 为 UTF-8 JSON;
|
|
12
12
|
* - ping/pong 为应用层心跳(经代理/负载均衡时比 WS 原生 ping 更可靠)。
|
|
13
13
|
*
|
|
14
|
-
* 协议语义见 ../../../server/dsh-mobile-server/PROTOCOL.md
|
|
14
|
+
* 协议语义见 ../../../server/dsh-mobile-server/server/relay-core/PROTOCOL.md。
|
|
15
15
|
*
|
|
16
16
|
* 本文件是自包含副本(插件包不能跨目录引用中转仓库的文件),完整内联了共用类型表
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* 与编解码实现。类型表的事实源在独立中转仓库的 server/relay-core/protocol-types.js;
|
|
18
|
+
* 本仓的 protocol-parity 测试钉住线格式,中转仓库另做 Rust 与 JS 字节对撞;
|
|
19
19
|
* 改动帧编号后必须同步修改本文件。
|
|
20
20
|
*/
|
|
21
21
|
|
package/lib/relay/tunnel.js
CHANGED
|
@@ -14,6 +14,7 @@ const http = require('node:http');
|
|
|
14
14
|
const { WebSocket } = require('ws');
|
|
15
15
|
const { T, jsonFrame, encodeFrame, createFrameDecoder, parseJson, encodeClosePayload, decodeClosePayload } = require('./protocol.js');
|
|
16
16
|
const { isFreshRequest } = require('../shared/fresh-request');
|
|
17
|
+
const { encodeJsonBody } = require('../shared/http-util');
|
|
17
18
|
|
|
18
19
|
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
19
20
|
// Relay currently expires an idle tunnel after 60s. Keep a wider client-side
|
|
@@ -464,7 +465,10 @@ class RelayTunnel {
|
|
|
464
465
|
fn(arg);
|
|
465
466
|
}
|
|
466
467
|
};
|
|
467
|
-
|
|
468
|
+
// 中转侧是 tokio-tungstenite 手写的 101,从不回 `sec-websocket-extensions`,
|
|
469
|
+
// 所以 permessage-deflate 一次都没协商成功过;开着只会让人以为隧道压过。
|
|
470
|
+
// 真省字节靠的是响应体的 gzip(见 lib/shared/http-util.js)。
|
|
471
|
+
const ws = new WebSocket(this.relayUrl);
|
|
468
472
|
this.ws = ws;
|
|
469
473
|
const timer = setTimeout(() => {
|
|
470
474
|
done(reject, new Error('注册超时'));
|
|
@@ -572,9 +576,18 @@ class RelayTunnel {
|
|
|
572
576
|
if (this.readSnapshot && !isFreshRequest(msg.path) && (msg.method === 'GET' || msg.method === undefined)) {
|
|
573
577
|
const body = this.readSnapshot({ method: msg.method, path: msg.path });
|
|
574
578
|
if (body && body.ok) {
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
this.queueHttpResponseFrame(id, entry, T.
|
|
579
|
+
// 这条捷径回的是列表 / 工作区 / 消息 / 统计这几条最重的读取,压缩的收益全在这儿。
|
|
580
|
+
const encoded = encodeJsonBody(JSON.stringify(body), msg.headers?.['accept-encoding']);
|
|
581
|
+
this.queueHttpResponseFrame(id, entry, T.httpHead, {
|
|
582
|
+
status: 200,
|
|
583
|
+
headers: {
|
|
584
|
+
'content-type': 'application/json; charset=utf-8',
|
|
585
|
+
'cache-control': 'no-store',
|
|
586
|
+
vary: 'accept-encoding',
|
|
587
|
+
...(encoded.gzip ? { 'content-encoding': 'gzip' } : {}),
|
|
588
|
+
},
|
|
589
|
+
}, true);
|
|
590
|
+
this.queueHttpResponseFrame(id, entry, T.httpChunk, encoded.body);
|
|
578
591
|
this.queueHttpResponseFrame(id, entry, T.httpEnd, Buffer.alloc(0));
|
|
579
592
|
return;
|
|
580
593
|
}
|
|
@@ -28,9 +28,13 @@ const pairingApproval = require('../relay/pairing-policy.js');
|
|
|
28
28
|
// 端口自检用的是局域网代理**同一把探针**(见 `../relay/port-allocator.cjs` 的文件头):
|
|
29
29
|
// 这里说「可用」,真正 listen 时就不会 EADDRINUSE,两边不会各说各话。
|
|
30
30
|
const { canListen } = require('../relay/port-allocator.cjs');
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
|
|
31
|
+
// 面板的「手机该填哪个地址」要三份:私网(lanCandidates)、公网(publicCandidates)与
|
|
32
|
+
// IPv6 全球单播(ipv6Candidates)。三者刻意分开,理由写在 lan-network.cjs 里那三个函数头上。
|
|
33
|
+
//
|
|
34
|
+
// ⚠️ 面板里**能被选中的**那份仍然只能是 `lan`(没有私网时才退 `public`)——它与
|
|
35
|
+
// `advertisedCandidates` 同口径。IPv6 那份只**显示**、不进那个选择:它由手机侧实测择优
|
|
36
|
+
// (和公网中转比速度),做成可选中等于骗人(同 A3-40 的刻意设计)。
|
|
37
|
+
const { lanCandidates, publicCandidates, ipv6Candidates } = require('../relay/lan-network.cjs');
|
|
34
38
|
const desktopView = require('../desktop-view.cjs');
|
|
35
39
|
const readReceipt = require('../read-receipt.cjs');
|
|
36
40
|
// 客户端半边的活体台账:两条绿点同步通道都是「单向轮询 + 无回执」,不记账就永远分不开
|
|
@@ -289,7 +293,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
289
293
|
// 控制面状态里额外带上面板页的绝对地址:客户端半边(lib/client.js)那一项现在只是
|
|
290
294
|
// 一个「打开配置页面」的入口,链接必须是绝对的(原因见 panelUrlOf)。地址只在这里
|
|
291
295
|
// 补,不动 createStatusView —— 那份 JSON 手机端也在读,127.0.0.1 的地址对它没意义。
|
|
292
|
-
// 面板的「手机该填哪个地址」:把检测到的网卡地址按**局域网 /
|
|
296
|
+
// 面板的「手机该填哪个地址」:把检测到的网卡地址按**局域网 / 公网 / IPv6**分成三份报出来,
|
|
293
297
|
// 用户不用自己去 `ipconfig`,界面也能一眼看出该填哪个(引导第三、四页用它)。
|
|
294
298
|
//
|
|
295
299
|
// 只给面板看:移动端那份候选契约(二维码里带的 `lanUrls`)的**取舍**一个字都没动,那边的
|
|
@@ -297,10 +301,12 @@ function registerControlPlaneRoutes(deps) {
|
|
|
297
301
|
//
|
|
298
302
|
// `preferred` 是用户在这两份地址里选中的那条(设置项 `preferredLanAddress`):界面靠它回显
|
|
299
303
|
// 「现在选的是这条」。选中的地址要真生效在二维码与 `/connection-info` 里,不在这一条上。
|
|
304
|
+
// `ipv6` 是 2026-10 加的第三份:它**不参与** `preferred`,只用来显示「这台机器在公网
|
|
305
|
+
// IPv6 上是哪个地址」—— 真机验证时人要拿这个地址去拨,没有它就只能敲命令行。
|
|
300
306
|
registerRoute({ kind: 'exact', path: '/api/relay/addresses', handler: localOnly('GET', async (_req, res) => {
|
|
301
307
|
const port = lanPortInUse()
|
|
302
308
|
const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : ''
|
|
303
|
-
sendJson(res, 200, { ok: true, port, preferred, lan: lanCandidates(port), public: publicCandidates(port) })
|
|
309
|
+
sendJson(res, 200, { ok: true, port, preferred, lan: lanCandidates(port), public: publicCandidates(port), ipv6: ipv6Candidates(port) })
|
|
304
310
|
}) })
|
|
305
311
|
/**
|
|
306
312
|
* 公用中转候选的测速(`POST /api/relay/probe-relays`,body `{ urls: string[] }`)。
|
package/lib/routes/mobile-api.js
CHANGED
|
@@ -36,6 +36,7 @@ const deepSeekAccount = require('../mobile/deepseek-account.js');
|
|
|
36
36
|
const skills = require('../mobile/skills.js');
|
|
37
37
|
const mobileCredentials = require('../mobile/credentials.js');
|
|
38
38
|
const subagents = require('../mobile/subagents.js');
|
|
39
|
+
const monitor = require('../mobile/monitor.js');
|
|
39
40
|
const references = require('../mobile/references.js');
|
|
40
41
|
const plugins = require('../mobile/plugins.js');
|
|
41
42
|
const uploads = require('../mobile/uploads.js');
|
|
@@ -58,6 +59,9 @@ const API_VERSION = '1';
|
|
|
58
59
|
* let 变量,闭包捕获对象会让 handler 永远读到启动那一刻的旧快照(lan.updatedAt 停在
|
|
59
60
|
* 进程启动时间,手机端看连接信息像失联了一样)。
|
|
60
61
|
* @param {(port: number) => Array<object>} deps.safeLanCandidates 局域网候选地址
|
|
62
|
+
* @param {(port: number) => Array<object>} deps.safeIpv6Candidates 公网 IPv6 直连候选。
|
|
63
|
+
* 与上面那份并列、不并进它:手机拿这份和中转实测比快慢,掺进局域网那份就等于让它
|
|
64
|
+
* 跟「同一个 Wi-Fi」共用一套失败退避(手机刚离开家里 Wi-Fi 时正是最该试它的时候)。
|
|
61
65
|
* @param {() => number} deps.lanPortInUse 手机该拨的局域网端口。**必须用取值器**:
|
|
62
66
|
* sidecar 自己 bind 到的端口可能与本进程解析出的不同(两条端口自适应),只认
|
|
63
67
|
* `state.lanPort` 会把这个接口变成「报一个没人监听的端口」——真机实测过 50933
|
|
@@ -71,7 +75,7 @@ const API_VERSION = '1';
|
|
|
71
75
|
* 一次拒绝或一次握手。
|
|
72
76
|
*/
|
|
73
77
|
function registerMobileRoutes(deps) {
|
|
74
|
-
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, lanPortInUse, preview, core, pairingApproval } = deps;
|
|
78
|
+
const { registerRoute, registerUpgradeRoute, guarded, state, hostMetadata, getSidecarSnapshot, safeLanCandidates, safeIpv6Candidates, lanPortInUse, preview, core, pairingApproval } = deps;
|
|
75
79
|
const route = (path, handler) => registerRoute({ kind: 'exact', path, handler: guarded(handler) })
|
|
76
80
|
|
|
77
81
|
registerRoute({ kind: 'exact', path: '/api/mobile/v1/status', handler: guarded(async (_ctx, _req, res) => {
|
|
@@ -135,6 +139,14 @@ function registerMobileRoutes(deps) {
|
|
|
135
139
|
const candidates = state.lanEnabled
|
|
136
140
|
? orderCandidates(safeLanCandidates(port), state.preferredLanAddress)
|
|
137
141
|
: []
|
|
142
|
+
// IPv6 直连候选:单独一份、**不参与**上面的排序 —— 它和中转谁更快只有手机实测得出,
|
|
143
|
+
// 宿主无从判断对端走哪条路由。与局域网那份同一个开关管(那条监听挂在同一个代理上),
|
|
144
|
+
// 关掉局域网直连就没有这条通道。
|
|
145
|
+
//
|
|
146
|
+
// 这份是手机配对之后**唯一**能拿到新 IPv6 地址的地方:二维码里带的候选只在配对那一刻
|
|
147
|
+
// 是真值,而隐私地址按小时轮换、运营商换前缀之后旧的那条就拨不通了。少回这一格,
|
|
148
|
+
// 手机上那条通道就只能靠配对当时的旧地址活着。
|
|
149
|
+
const ipv6 = state.lanEnabled ? safeIpv6Candidates(port) : []
|
|
138
150
|
const lanUpdatedAt = getSidecarSnapshot()?.lan?.updatedAt || Date.now()
|
|
139
151
|
sendJson(res, 200, {
|
|
140
152
|
ok: true,
|
|
@@ -143,16 +155,25 @@ function registerMobileRoutes(deps) {
|
|
|
143
155
|
relayUrl: state.enabled ? (state.relayUrl || null) : null,
|
|
144
156
|
lanUrls: candidates,
|
|
145
157
|
lanCandidates: candidates,
|
|
158
|
+
ipv6Candidates: ipv6,
|
|
146
159
|
updatedAt: lanUpdatedAt,
|
|
147
160
|
lastSeen: lanUpdatedAt,
|
|
148
161
|
})
|
|
149
162
|
})
|
|
150
163
|
|
|
151
164
|
route('/api/mobile/v1/sessions', sessions.handleSessions)
|
|
165
|
+
// 后台提醒服务专用:只回「在跑 / 刚完成 / 待回话 / 上一轮失败」这几格,
|
|
166
|
+
// 带 `?rev=` 条件刷新。它要替掉原生那边每 8 秒一发的完整清单(那份还得为 20 个
|
|
167
|
+
// 会话各读一次 history 补预览),见 mobile/monitor.js。
|
|
168
|
+
route('/api/mobile/v1/monitor', monitor.handleMonitor)
|
|
152
169
|
route('/api/mobile/v1/session', sessions.handleSession)
|
|
153
170
|
route('/api/mobile/v1/session/messages', sessions.handleMessages)
|
|
154
171
|
route('/api/mobile/v1/session/attachment', sessions.handleSessionAttachment)
|
|
155
172
|
route('/api/mobile/v1/session/send', sessions.handleSend)
|
|
173
|
+
// 正文水位探针:App 进对话前先问一句「这条会话的正文到哪个水位了」,跟本地缓存水位
|
|
174
|
+
// 一比就知道要不要重拉全量历史(几十字节换几兆)。**只读信号快照**:信号里没有就如实
|
|
175
|
+
// 回 known:false 让 App 退回全量,绝不现读一次历史——否则这探针就变成了它要省掉的那次请求。
|
|
176
|
+
route('/api/mobile/v1/session/head', sessions.handleSessionHead)
|
|
156
177
|
route('/api/mobile/v1/commands', sessions.handleCommands)
|
|
157
178
|
route('/api/mobile/v1/session/read', sessions.handleSessionRead)
|
|
158
179
|
route('/api/mobile/v1/session/stop', sessions.handleStop)
|