dsh-mobile-plugin 0.5.10 → 0.5.12

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/lib/relay/core.js CHANGED
@@ -49,6 +49,9 @@ function defaultState() {
49
49
  // 用户手填的直连地址(空 = 没填)。与上面那条同一条口径:也是 schema 里的一项、
50
50
  // 也不进 sidecar 配置 —— 它只影响「报给手机的候选」与二维码,不影响隧道怎么连。
51
51
  manualLanUrl: '',
52
+ // 用户手填的 IPv6 直连地址(空 = 没填)。与上一条同一条口径:schema 里的一项、不进
53
+ // sidecar 配置,只影响「报给手机的 IPv6 候选」与二维码。
54
+ manualIpv6Url: '',
52
55
  // 首次接入引导走完没有。**刻意不进 RemoteControlSettingsSchema**:它不是一项
53
56
  // 「远程控制设置」,改它不该触发 sidecar 重配(那条链上每加一个字段都要同步改
54
57
  // schema、worker 的 allowed 数组与 buildConfig,漏一处就抛 unknown field)。
@@ -73,6 +73,12 @@ function asArray(value) {
73
73
  return value === null || value === undefined ? [] : [value];
74
74
  }
75
75
 
76
+ /** PowerShell 报上来的条数:读不到是 `null`,不是 0 —— 「一条都没有」和「没读到」是两件事。 */
77
+ function countOf(value) {
78
+ const number = Number(value);
79
+ return Number.isFinite(number) && number >= 0 ? Math.floor(number) : null;
80
+ }
81
+
76
82
  /**
77
83
  * 解析 Windows 那份报告(脚本见 `WINDOWS_SCRIPT`)。
78
84
  *
@@ -95,6 +101,9 @@ function parseWindowsReport(text) {
95
101
  profile: String(item && item.profile ? item.profile : ''),
96
102
  })),
97
103
  activeProfiles: asArray(profileObject.activeProfiles).map((item) => String(item)),
104
+ // 这两个计数只用来区分「一条放行规则都没有」和「规则压根没读出来」,见 `decideWindowsAllows`。
105
+ filters: countOf(profileObject.filters),
106
+ rules: countOf(profileObject.rules),
98
107
  };
99
108
  }
100
109
 
@@ -125,11 +134,16 @@ function windowsRuleApplies(profileField, activeProfiles) {
125
134
  *
126
135
  * 顺序:显式 Block 优先 → 有生效中的 Allow → 防火墙开着却没有规则 = 默认拒绝(这是
127
136
  * 第一次弹框点了取消之后的样子)→ 防火墙关着 = 不挡。
137
+ *
138
+ * 只有「确实一条这个程序的规则都没有」才走默认拒绝。查到了过滤器却一条规则都没关联上,
139
+ * 是查询出了问题,回 `null`(查不到)—— 报「挡住了」会把人引去关防火墙、加重复规则,
140
+ * 真正的原因反而被盖住。
128
141
  */
129
142
  function decideWindowsAllows(report) {
130
143
  if (!report || !Array.isArray(report.apps)) return null;
131
144
  const enabled = report.profiles.some((item) => item.enabled === true);
132
145
  if (!enabled) return true;
146
+ if (countOf(report.filters) > 0 && countOf(report.rules) === 0) return null;
133
147
  const hits = report.apps.filter((item) => item.program && sameWindowsPath(item.program, report.appPath));
134
148
  const live = hits.filter((item) => item.enabled !== false && windowsRuleApplies(item.profile, report.activeProfiles));
135
149
  if (live.some((item) => /block/i.test(item.action))) return false;
@@ -150,17 +164,32 @@ function decideWindowsAllows(report) {
150
164
  * 机器上,系统界面显示的防火墙开着,按本地库读出来三个 profile 可能全是「关」
151
165
  * —— 用户看到的「实际防火墙开着、自检说关着」就是这一条;反过来,组策略下发的
152
166
  * 放行规则也会被漏掉、报成「挡住」。
167
+ * 4. 从**应用过滤器**反查规则:过滤器对象上的 `Name` 恒为空(规则 ID 不在那儿),拿它当
168
+ * 规则名去查,一条都查不到 —— 于是「明明放行了」被报成「挡住了」。正确的是问「这个
169
+ * 过滤器属于哪几条规则」(`-AssociatedNetFirewallApplicationFilter`),见下面注释。
170
+ * 5. `$ErrorActionPreference = "Stop"`:查询本身失败(权限、服务没起)时必须**整个失败**。
171
+ * PowerShell 默认只是往错误流写一行接着往下跑,那样循环一条不转,结果跟「这台没有
172
+ * 放行规则」一模一样 —— 正是误报的来源。
153
173
  */
154
174
  const WINDOWS_SCRIPT = [
175
+ '$ErrorActionPreference = "Stop";',
155
176
  '$path = $env:DSH_FIREWALL_PROGRAM;',
156
177
  '$profiles = @(Get-NetFirewallProfile -PolicyStore ActiveStore | ForEach-Object { @{ name = "$($_.Name)"; enabled = [bool]$_.Enabled } });',
157
- '$active = @(Get-NetConnectionProfile | ForEach-Object { $c = "$($_.NetworkCategory)"; if ($c -eq "DomainAuthenticated") { "Domain" } else { $c } });',
178
+ // 读不到当前接入的网络类别不算失败:`windowsRuleApplies` 会按「管」算,没必要因此整条查不到。
179
+ '$active = @();',
180
+ 'try { $active = @(Get-NetConnectionProfile | ForEach-Object { $c = "$($_.NetworkCategory)"; if ($c -eq "DomainAuthenticated") { "Domain" } else { $c } }) } catch {}',
158
181
  '$apps = @();',
159
- 'foreach ($f in @(Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program $path)) {',
160
- ' $r = Get-NetFirewallRule -Name $f.Name -PolicyStore ActiveStore;',
161
- ' if ($r) { $apps += @{ program = "$($f.Program)"; action = "$($r.Action)"; enabled = ("$($r.Enabled)" -eq "True"); profile = "$($r.Profile)" } }',
182
+ '$filters = @(Get-NetFirewallApplicationFilter -PolicyStore ActiveStore -Program $path);',
183
+ '$rules = 0;',
184
+ 'foreach ($f in $filters) {',
185
+ ' foreach ($r in @($f | Get-NetFirewallRule -PolicyStore ActiveStore)) {',
186
+ ' $rules = $rules + 1;',
187
+ // 出站规则跟「手机连进来」无关;混进来会把「没有入站放行」看成「有规则」。
188
+ ' if ("$($r.Direction)" -ne "Inbound") { continue; }',
189
+ ' $apps += @{ program = "$($f.Program)"; action = "$($r.Action)"; enabled = ("$($r.Enabled)" -eq "True"); profile = "$($r.Profile)" }',
190
+ ' }',
162
191
  '}',
163
- '@{ profiles = $profiles; apps = $apps; activeProfiles = $active } | ConvertTo-Json -Depth 6 -Compress',
192
+ '@{ profiles = $profiles; apps = $apps; filters = $filters.Count; rules = $rules; activeProfiles = $active } | ConvertTo-Json -Depth 6 -Compress',
164
193
  ].join(' ');
165
194
 
166
195
  /** 默认执行器:只跑写死的可执行文件与参数,超时杀进程,输出按 UTF-8 收。 */
@@ -220,6 +249,8 @@ async function probeFirewall(options = {}) {
220
249
  });
221
250
  if (result.error === 'NO_COMMAND') return unknownFirewall(execPath, 'no-command');
222
251
  if (result.error === 'TIMEOUT') return unknownFirewall(execPath, 'timeout');
252
+ // 脚本是 `$ErrorActionPreference = "Stop"`:查询本身失败就没有 JSON,如实报查不到。
253
+ if (result.error) return unknownFirewall(execPath, 'parse-failed');
223
254
  let report = null;
224
255
  try {
225
256
  report = parseWindowsReport(result.stdout);
@@ -227,11 +258,12 @@ async function probeFirewall(options = {}) {
227
258
  return unknownFirewall(execPath, 'parse-failed');
228
259
  }
229
260
  if (report.profiles.length === 0) return unknownFirewall(execPath, 'parse-failed');
230
- const enabled = report.profiles.some((item) => item.enabled === true);
261
+ const allowsApp = decideWindowsAllows({ ...report, appPath: execPath });
262
+ if (allowsApp === null) return unknownFirewall(execPath, 'parse-failed');
231
263
  return {
232
264
  supported: true,
233
- enabled,
234
- allowsApp: decideWindowsAllows({ ...report, appPath: execPath }),
265
+ enabled: report.profiles.some((item) => item.enabled === true),
266
+ allowsApp,
235
267
  appPath: execPath,
236
268
  reason: null,
237
269
  };
@@ -369,6 +369,51 @@ function withManualCandidate(list, value) {
369
369
  return [...rest.filter(item => item && item.url !== manual.url), manual]
370
370
  }
371
371
 
372
+ /**
373
+ * 用户**自己填的** IPv6 直连地址(设置项 `manualIpv6Url`)→ 候选。
374
+ *
375
+ * 与上面那条同一件事,只是拨的是 IPv6:网卡枚举只报这台机器此刻真实挂着的全球单播地址,
376
+ * 人可能想固定成「运营商分给我、但网卡上暂时没挂」的那一条(临时地址轮换、前缀换了、网卡
377
+ * 拔了),它只能由人手填。值形如 `http://[240e:338::998]:18787`,与 `ipv6Candidates` 产出的
378
+ * `url` 同形 —— 地址在 URL 里必须带方括号。
379
+ *
380
+ * 校验口径与 `manualCandidate` 一致:只挡明显不能用的(空、非 http(s)、解析不出主机),
381
+ * 另外要求主机确实是一个 IPv6 字面量:这一份只报 IPv6,混进域名 / v4 会让 App 那条独立
382
+ * 通道拿到一条不属于它的地址。**不校验是不是全球单播**:填内网 / ULA 地址也是人自己的
383
+ * 选择,能不能通由手机实测给出,宿主只负责如实上报。
384
+ */
385
+ function manualIpv6Candidate(value) {
386
+ const url = typeof value === 'string' ? value.trim().replace(/\/+$/, '') : ''
387
+ if (!url || !/^https?:\/\/[^\s/]+/i.test(url)) return null
388
+ let host = ''
389
+ try { host = new URL(url).hostname } catch { return null }
390
+ // URL.hostname 对 IPv6 是带方括号的,而候选的 `address` 与网卡那份同形:裸地址。
391
+ const address = String(host).replace(/^\[/, '').replace(/\]$/, '')
392
+ if (ipv6Number(address) === null) return null
393
+ return { url, address, interface: 'manual', family: 'IPv6' }
394
+ }
395
+
396
+ /**
397
+ * 候选 = 网卡那几条照旧 + 用户填的那条**排在最后**当兜底。**只追加、不替换**:手填的值
398
+ * 再准也只是多一条,网卡枚举出来的候选一条都不删 —— 它写错了、或者那条地址暂时不可达时,
399
+ * 手机还得能顺着网卡地址摸回这台机器。值不合法时原样退回网卡候选,不让一条坏配置把整份
400
+ * 候选清空。
401
+ *
402
+ * 去重按 `ipv6Number` 解出来的数值,与 `ipv6Candidates` / `collectEndpoints` 同一条口径:
403
+ * `[240E::1]` 与 `[240e:0:0:0:0:0:0:1]` 是同一个地址的两种写法,按 URL 逐字比会留下两条、
404
+ * 让手机对同一个地址各探一次。
405
+ *
406
+ * 排在最后与 `withManualCandidate` 同一个理由:手填的是兜底,同一个 Wi-Fi 下网卡地址快
407
+ * 一个数量级,排最前就会按「先回话的赢」永远选中手填那条。
408
+ */
409
+ function withManualIpv6Candidate(list, value) {
410
+ const manual = manualIpv6Candidate(value)
411
+ const rest = Array.isArray(list) ? list : []
412
+ if (!manual) return rest
413
+ const key = String(ipv6Number(manual.address))
414
+ return [...rest.filter(item => item && String(ipv6Number(item.address)) !== key), manual]
415
+ }
416
+
372
417
  /**
373
418
  * 把用户选中的那条排到最前面(面板上的「手机优先用这条」,存在设置项 `preferredLanAddress`)。
374
419
  *
@@ -429,6 +474,8 @@ module.exports = {
429
474
  advertisedCandidates,
430
475
  manualCandidate,
431
476
  withManualCandidate,
477
+ manualIpv6Candidate,
478
+ withManualIpv6Candidate,
432
479
  orderCandidates,
433
480
  phoneCandidates,
434
481
  }
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * 配对二维码的载荷与原文。
5
+ *
6
+ * 面板(`/api/relay/pairing`)与终端命令(`lib/cli/pair.cjs`)都从这里取,**只此一份**:
7
+ * 两处各拼一遍的话,迟早出现「面板里的码能配上、终端里打出来的配不上」这种最难查的分叉。
8
+ *
9
+ * 只做「把 state 折成一张卡里要说的东西」,不认识 HTTP、不认识渲染:二维码图片怎么画由
10
+ * 调用方决定(面板要 SVG、终端要字符画)。
11
+ */
12
+
13
+ const os = require('node:os');
14
+ const {
15
+ advertisedCandidates,
16
+ phoneCandidates,
17
+ ipv6Candidates,
18
+ withManualIpv6Candidate,
19
+ } = require('./lan-network.cjs');
20
+
21
+ /**
22
+ * @param {object} state relay.json 那份状态(至少要有 name/instanceId/pairingCode/pairingTicket/relayUrl/lanEnabled)
23
+ * @param {number} [lanPort] 实绑的局域网端口(首选端口被占时会与配置里那个不同)
24
+ * @returns {{payload: object, text: string}} `text` 就是二维码里那串 `dshpair://`
25
+ */
26
+ function pairingPayload(state, lanPort) {
27
+ const port = Number.isInteger(lanPort) ? lanPort : state.lanPort;
28
+ // 面板上选过的「手机优先用这条」在这里兑现:只挪到最前,其余继续当兜底(App 按顺序挨个试)。
29
+ const candidates = state.lanEnabled
30
+ ? phoneCandidates(advertisedCandidates(port), { preferred: state.preferredLanAddress, manual: state.manualLanUrl })
31
+ : [];
32
+ const urls = candidates.map(candidate => candidate.url).filter(Boolean);
33
+ const relayUrl = state.relayUrl || '';
34
+ const payload = {
35
+ type: 'dsh-pair',
36
+ version: 1,
37
+ name: state.name || os.hostname(),
38
+ instanceId: state.instanceId,
39
+ code: state.pairingCode,
40
+ // 协议版本与一次性票据(§8.3(1))。
41
+ //
42
+ // `proto: 2` 由**宿主**写、也由宿主生成,中转没有任何机会改写它 —— 所以「新 App 被
43
+ // 中转骗回明文」这个问题从一开始就不存在。`ticket` 是 32 字节随机数,手机拿它派生
44
+ // 配对密钥;二维码是屏幕对摄像头,这条通道完全绕开中转。
45
+ //
46
+ // 老 App 只取自己认识的那几个字段,多出来的两个会被忽略 —— 一张二维码同时服务新旧两种。
47
+ proto: 2,
48
+ ticket: state.pairingTicket || '',
49
+ relayUrl,
50
+ lanUrls: urls,
51
+ // 候选列表随二维码一起带走:App 配对后即使换了网段,也能按这份列表
52
+ // 依次重试而不用先回到公网中转去问。
53
+ lanCandidates: urls,
54
+ // IPv6 直连候选(理想情况下是手机**不在同一个 Wi-Fi 里**时唯一能拨到的地址):不参与上面
55
+ // 那份的排序,顺序由 App 侧拿它和公网中转实测比出来;直连监听没开就没有这条通道。
56
+ // 用户手填的那条缀在最后当兜底(只追加、不删网卡候选),见 `withManualIpv6Candidate`。
57
+ ipv6Candidates: state.lanEnabled
58
+ ? withManualIpv6Candidate(ipv6Candidates(port), state.manualIpv6Url).map(candidate => candidate.url).filter(Boolean)
59
+ : [],
60
+ };
61
+ return {
62
+ payload,
63
+ text: `dshpair://v1?data=${encodeURIComponent(JSON.stringify(payload))}`,
64
+ };
65
+ }
66
+
67
+ module.exports = { pairingPayload };
@@ -17,6 +17,7 @@
17
17
  */
18
18
 
19
19
  const fs = require('node:fs');
20
+ const net = require('node:net');
20
21
  const path = require('node:path');
21
22
  const QRCode = require('qrcode');
22
23
  const { randomBytes } = require('node:crypto');
@@ -32,7 +33,7 @@ const { canListen } = require('../relay/port-allocator.cjs');
32
33
  // ⚠️ 面板里**能被选中的**那份仍然只能是 `lan`(没有私网时才退 `public`)——它与
33
34
  // `advertisedCandidates` 同口径。IPv6 那份只**显示**、不进那个选择:它由手机侧实测择优
34
35
  // (和公网中转比速度),做成可选中等于骗人(同 A3-40 的刻意设计)。
35
- const { lanCandidates, publicCandidates, ipv6Candidates } = require('../relay/lan-network.cjs');
36
+ const { lanCandidates, publicCandidates, ipv6Candidates, withManualIpv6Candidate, isPrivateLanAddress } = require('../relay/lan-network.cjs');
36
37
  // 「手机连不上时哪一环断了」这份连接自检:本机只读、不改任何东西(口径见
37
38
  // connection-check.js 与 firewall-probe.js 的文件头)。
38
39
  const { createConnectionCheck } = require('../relay/connection-check.js');
@@ -204,6 +205,42 @@ function panelUrlOf(req) {
204
205
  return `${forwarded || 'http'}://${host}/api/relay/panel`;
205
206
  }
206
207
 
208
+ /**
209
+ * 面板地址落在哪一类网络上:`loopback` / `private` / `public`(判不出来时算 `public`)。
210
+ *
211
+ * 侧边栏那颗手机图标(现在它是进入网页的唯一入口)据此决定点下去要不要先开两小时窗口:
212
+ *
213
+ * - `loopback`:人就在这台机器的 DSH 设置页里,控制面本来就认它,直接跳转;
214
+ * - `private`:同一个局域网里的另一台设备 —— 控制面不认它,得先开窗口,但用户点的就是
215
+ * 自己眼前的界面,不再多弹一次确认;
216
+ * - `public`:公网部署,地址谁都能拨,所以先弹一句确认再开。
217
+ *
218
+ * **域名一律按 `public` 算**:内网域名、内网穿透域名长得跟公网域名一样,这里分不出来。
219
+ * 猜错的代价是「多弹一次确认」,反过来猜错就是「不知不觉把公网管理口开了两小时」。
220
+ */
221
+ function panelScopeOf(req) {
222
+ const host = typeof req?.headers?.host === 'string' ? req.headers.host : '';
223
+ if (!host) return 'public';
224
+ let hostname = '';
225
+ try {
226
+ hostname = new URL(`http://${host}`).hostname.toLowerCase().replace(/^\[|\]$/g, '');
227
+ } catch {
228
+ return 'public';
229
+ }
230
+ if (hostname === 'localhost') return 'loopback';
231
+ if (net.isIP(hostname) === 4) {
232
+ if (/^127\./.test(hostname)) return 'loopback';
233
+ return isPrivateLanAddress(hostname) ? 'private' : 'public';
234
+ }
235
+ if (net.isIP(hostname) === 6) {
236
+ if (hostname === '::1') return 'loopback';
237
+ // fc00::/7 是唯一本地地址(私网),fe80::/10 是链路本地:都只在一个局域网里有意义。
238
+ if (/^f[cd][0-9a-f]{2}:/.test(hostname) || /^fe[89ab][0-9a-f]:/.test(hostname)) return 'private';
239
+ return 'public';
240
+ }
241
+ return 'public';
242
+ }
243
+
207
244
  /**
208
245
  * @param {object} deps 见 `lib/impl.js` 里 registerControlPlaneRoutes({...}) 的调用点
209
246
  */
@@ -373,7 +410,19 @@ function registerControlPlaneRoutes(deps) {
373
410
  const preferred = typeof state.preferredLanAddress === 'string' ? state.preferredLanAddress : ''
374
411
  // `manual` 是用户手填的直连地址(内网穿透域名):网卡扫不出来,所以单列一格让面板回显与修改。
375
412
  const manual = typeof state.manualLanUrl === 'string' ? state.manualLanUrl : ''
376
- sendJson(res, 200, { ok: true, port, preferred, manual, lan: lanCandidates(port), public: publicCandidates(port), ipv6: ipv6Candidates(port) })
413
+ // `manualIpv6` 是同一件事的 IPv6 版:面板回显它,同时把它缀进 `ipv6` 候选最后
414
+ // (只追加、不删网卡候选;排最后的取舍见 lan-network.cjs 的 `withManualIpv6Candidate`)。
415
+ const manualIpv6 = typeof state.manualIpv6Url === 'string' ? state.manualIpv6Url : ''
416
+ sendJson(res, 200, {
417
+ ok: true,
418
+ port,
419
+ preferred,
420
+ manual,
421
+ manualIpv6,
422
+ lan: lanCandidates(port),
423
+ public: publicCandidates(port),
424
+ ipv6: withManualIpv6Candidate(ipv6Candidates(port), manualIpv6),
425
+ })
377
426
  }) })
378
427
  /**
379
428
  * 公用中转候选的测速(`POST /api/relay/probe-relays`,body `{ urls: string[] }`)。
@@ -419,7 +468,13 @@ function registerControlPlaneRoutes(deps) {
419
468
  }) })
420
469
  // 只有可信本机设置入口可以开 / 关窗口;窗口状态仅驻留进程内,DSH 重启后必定关闭。
421
470
  registerRoute({ kind: 'exact', path: '/api/relay/web-access', handler: localOnly('POST', async (req, res) => {
422
- if (typeof trustedLocalUi !== 'function' || !trustedLocalUi(req)) return sendJson(res, 403, { ok: false, error: { code: 'local_ui_only', message: '请从 DSH 本机设置页管理网页访问' } });
471
+ // 谁来按这个开关(2026-11-02 用户拍板放宽,取代原先「只认可信本机设置页」)。
472
+ //
473
+ // 侧边栏那颗手机图标成为进网页的唯一入口之后,公网 / 局域网部署的人就是在自己的浏览器里
474
+ // 点它 —— 那时 socket 对端与 Host 都不是回环,按老判据会 403,于是谁也没法把窗口打开,
475
+ // 面板永远回 423。现在只要求 `localOnly` 已经保证的那一层:同源、不带中转凭据、不经本机
476
+ // 代理转发。能发出这条请求的只有 DSH 自己页面里的代码,而「人在 DSH 界面里按下了这个
477
+ // 按钮」正是这个窗口存在的理由。跨站页面、中转与局域网代理转进来的请求仍在 localOnly 挡掉。
423
478
  const body = await readJson(req);
424
479
  if (typeof body?.enabled !== 'boolean') {
425
480
  return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'enabled 必须是布尔值' } });
@@ -427,7 +482,7 @@ function registerControlPlaneRoutes(deps) {
427
482
  const webAccess = body.enabled ? webAccessWindow.enable() : webAccessWindow.disable();
428
483
  return sendJson(res, 200, { ok: true, webAccess });
429
484
  }) });
430
- registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req), webAccess: webAccessWindow.status(), ...mobileActivity(), selfCheck: selfCheck.snapshot() })) })
485
+ registerRoute({ kind: 'exact', path: '/api/relay/status', handler: localOnly('GET', async (req, res) => sendJson(res, 200, { ...status(), panelUrl: panelUrlOf(req), panelScope: panelScopeOf(req), webAccess: webAccessWindow.status(), ...mobileActivity(), selfCheck: selfCheck.snapshot() })) })
431
486
  // 面板页本体:设置界面从客户端半边搬到这里,浏览器(或桌面端里的「打开配置页面」)
432
487
  // 直接打开它。三条地址吐的是同一份 HTML,分流在 React 侧(见 React/src/Root.tsx):
433
488
  // /api/relay/panel 看宿主记的「配没配过」,自己决定先走引导还是直接进主页
@@ -528,22 +583,14 @@ function registerControlPlaneRoutes(deps) {
528
583
  return sendJson(res, 200, { ok: true, installed: true, faults: guard.faults(), stats: guard.stats() })
529
584
  }) })
530
585
  registerRoute({ kind: 'exact', path: '/api/relay/pairing', handler: localOnly('GET', async (req, res) => {
531
- // **二维码只在本机屏幕上出现**。
532
- //
533
- // 二维码里现在装的是一次性票据 —— 用它能派生出整条链路的密钥。而「经中转/局域网
534
- // 代理转发来的请求在判据眼里长得像本机」这件事已经被实测证实过,所以这条不能只靠
535
- // `localOnly`;它要的是**可信本机界面**这个更强的判据(socket 回环 + Host 回环 +
536
- // 没有任何代理来源头)。网页管理窗口(两小时窗口那套)打开时能看到设备列表,但看
537
- // 不到这张码 —— 窗口本身是从浏览器发起的,`trustedLocalUi` 对它不成立。
586
+ // 二维码里装的是一次性票据 —— 用它能派生出整条链路的密钥,所以它本来只该在本机屏幕上
587
+ // 出现。但「本机屏幕」这个判据在**服务器部署**上是空的:那台机器只有 SSH,人永远够
588
+ // 不到它,于是这台机器上的用户连一次都配不上(2026-11-01 现场)。
538
589
  //
539
- // 拿不到就回 423 而不是 403:面板那边据此显示「请在这台电脑上打开面板」,
540
- // 也**不给**「点这里强制显示」——那等于把刚堵上的口子又开一遍。
541
- if (typeof trustedLocalUi !== 'function' || !trustedLocalUi(req)) {
542
- return sendJson(res, 423, {
543
- ok: false,
544
- error: { code: 'pairing_local_only', message: '配对二维码只在本机屏幕显示,请在这台电脑上打开面板' },
545
- })
546
- }
590
+ // 所以这条跟别的管理动作走同一套规矩:外面那层两小时窗口(`WEB_ACCESS_ROUTES`)说了算。
591
+ // 窗口由本机 DSH 设置页亲手开,开了之后远程本来就能踢掉全部设备、改中转地址、重启
592
+ // sidecar —— 单独把二维码挡在窗口外面既不自洽,也让那颗按钮名不副实(用户拍板)。
593
+ // 窗口没开时远程照旧拿不到码;判据不再自己造第二套。
547
594
  if (!state.enabled) {
548
595
  sendJson(res, 409, { ok: false, error: '请先启用远程控制并保存配置' })
549
596
  return
@@ -616,8 +663,8 @@ function registerControlPlaneRoutes(deps) {
616
663
  * 只回「面板画一行需要的东西」,**不回 `hash` / `hostToken`** —— 那些是凭据材料,
617
664
  * 面板只是显示谁连着、什么时候来的。
618
665
  *
619
- * 与二维码那条不一样,这条**进** `WEB_ACCESS_ROUTES`:设备列表里没有密钥材料,
620
- * 两小时窗口打开时能看、能踢是它的用途;而二维码里是整条链路的密钥,绝不能。
666
+ * 与二维码那条同一条规矩:都在 `WEB_ACCESS_ROUTES` 里,两小时窗口打开时远程能用是它的
667
+ * 用途。设备列表里没有密钥材料;二维码那边多一层考虑,见 `/api/relay/pairing` 的注释。
621
668
  *
622
669
  * `serverNow` 是给面板算「几分钟前」用的:手机与宿主的时钟可能差得多,但面板只需要
623
670
  * 一个同一台机器上的基准。
@@ -683,6 +730,10 @@ function registerControlPlaneRoutes(deps) {
683
730
  if (typeof body?.manualLanUrl === 'string' && body.manualLanUrl.length <= 200) {
684
731
  patch.manualLanUrl = body.manualLanUrl.trim().replace(/\/+$/, '')
685
732
  }
733
+ // 用户手填的 IPv6 直连地址(`http://[240e::1]:18787`)。空串合法 = 清掉,口径同上一条。
734
+ if (typeof body?.manualIpv6Url === 'string' && body.manualIpv6Url.length <= 200) {
735
+ patch.manualIpv6Url = body.manualIpv6Url.trim().replace(/\/+$/, '')
736
+ }
686
737
  await settings.update(patch)
687
738
  // 换中转地址时宿主会**先在旧隧道上登记迁移、再重建**(顺序在 worker 的 configure 里,
688
739
  // 见 lib/relay/migrate.js)—— 面板与手机走的是同一条下发路径,不需要各自实现一遍。
@@ -986,6 +1037,9 @@ const WEB_ACCESS_ROUTES = new Set([
986
1037
  '/api/relay/panel/setup',
987
1038
  '/api/relay/panel/settings',
988
1039
  '/api/relay/faults',
1040
+ // 配对二维码:里面是能派生整条链路密钥的一次性票据,所以它只在两小时窗口开着时给网页看
1041
+ // ——窗口由本机设置页亲手开,服务器部署没有「本机屏幕」可看,这条是它唯一的出口。
1042
+ '/api/relay/pairing',
989
1043
  '/api/relay/toggle',
990
1044
  '/api/relay/regenerate',
991
1045
  '/api/relay/onboarded',
@@ -1017,6 +1071,8 @@ const WEB_ACCESS_ROUTES = new Set([
1017
1071
  module.exports = {
1018
1072
  registerControlPlaneRoutes,
1019
1073
  panelDiagnosis,
1074
+ panelUrlOf,
1075
+ panelScopeOf,
1020
1076
  WEB_ACCESS_ROUTES,
1021
1077
  WEB_ACCESS_WINDOW_MS,
1022
1078
  createWebAccessWindow,
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  const { sendJson } = require('../http/routing.js');
15
- const { orderCandidates, withManualCandidate } = require('../relay/lan-network.cjs');
15
+ const { orderCandidates, withManualCandidate, withManualIpv6Candidate } = require('../relay/lan-network.cjs');
16
16
 
17
17
  const sessions = require('../mobile/sessions.js');
18
18
  const sessionModelOptions = require('../mobile/session-model-options.js');
@@ -32,6 +32,7 @@ const archiveClean = require('../mobile/archive-clean.js');
32
32
  const sessionLog = require('../mobile/session-log.js');
33
33
  const hostSettings = require('../mobile/host-settings.js');
34
34
  const channels = require('../mobile/channels.js');
35
+ const modelCaps = require('../mobile/model-caps.js');
35
36
  const deepSeekAccount = require('../mobile/deepseek-account.js');
36
37
  const skills = require('../mobile/skills.js');
37
38
  const mobileCredentials = require('../mobile/credentials.js');
@@ -51,6 +52,7 @@ const pairing = require('../mobile/pairing.js');
51
52
  const { credentialsEntry } = require('../shared/credentials-entry.js');
52
53
  const { handleCredentialUpgrade } = require('../mobile/credential-upgrade.js');
53
54
  const extensions = require('../mobile/extensions.js');
55
+ const extensionDev = require('../mobile/extension-dev.js');
54
56
  const sessionDelivery = require('../mobile/session-delivery.js');
55
57
  // 体检接口要自报身份:版本号只有一个来源,就是包自己的 package.json(与 relay/status.js 同)。
56
58
  const pkg = require('../../package.json');
@@ -200,8 +202,9 @@ function registerMobileRoutes(deps) {
200
202
  //
201
203
  // 这份是手机配对之后**唯一**能拿到新 IPv6 地址的地方:二维码里带的候选只在配对那一刻
202
204
  // 是真值,而隐私地址按小时轮换、运营商换前缀之后旧的那条就拨不通了。少回这一格,
203
- // 手机上那条通道就只能靠配对当时的旧地址活着。
204
- const ipv6 = state.lanEnabled ? safeIpv6Candidates(port) : []
205
+ // 手机上那条通道就只能靠配对当时的旧地址活着。手填的 IPv6 那条缀在**最后**当兜底
206
+ // (只追加、不删网卡候选),理由与上面手填的直连地址同一条。
207
+ const ipv6 = state.lanEnabled ? withManualIpv6Candidate(safeIpv6Candidates(port), state.manualIpv6Url) : []
205
208
  const lanUpdatedAt = getSidecarSnapshot()?.lan?.updatedAt || Date.now()
206
209
  sendJson(res, 200, {
207
210
  ok: true,
@@ -348,6 +351,11 @@ function registerMobileRoutes(deps) {
348
351
  // 渠道管理(见 mobile/channels.js):只对「本机自建」那一类(`llm-pi-ai` 里的路由)
349
352
  // 提供增删;插件 / 宿主注册的提供方只列出来,一个写操作都不做。获取模型由宿主代问
350
353
  // (端点常在内网,手机直连未必通),与桌面面板的 `discoverModels` 同一条路。
354
+ // 模型能力配置(见 mobile/model-caps.js):公开参数快照(App 拉一次、缓存后在 App 端搜索)
355
+ // 与「把选中的那份写回这条自建渠道」。写只到 `providers.<id>`,与上面的渠道保存同一条
356
+ // 边界 —— 内置渠道不在这份清单里,apply 一律 409。
357
+ route('/api/mobile/v1/model-meta', modelCaps.handleModelMeta)
358
+ route('/api/mobile/v1/model-meta/apply', modelCaps.handleModelMetaApply)
351
359
  route('/api/mobile/v1/channels', channels.handleChannels)
352
360
  route('/api/mobile/v1/channels/discover', channels.handleChannelDiscover)
353
361
  route('/api/mobile/v1/channels/save', channels.handleChannelSave)
@@ -457,6 +465,14 @@ function registerMobileRoutes(deps) {
457
465
  route('/api/mobile/v1/extension/card-state', extensions.handleExtensionCardState)
458
466
  route('/api/mobile/v1/extension/action', extensions.handleExtensionAction)
459
467
 
468
+ // 扩展**开发包**:作者放在电脑上的本地扩展目录(`$DSH_HOME/extension-dev`),手机上直接
469
+ // 列包、取文件,不用每改一行都发一次分发系统(见 mobile/extension-dev.js)。
470
+ //
471
+ // 能力面只多「读这一个固定目录」:slug 与包内相对路径拼出来的路径,取文件前先 resolve
472
+ // 再判前缀。仍然不暴露 shell、系统设置、任意文件读写 —— 不接受任意路径,也不给写。
473
+ route('/api/mobile/v1/extension/dev/packages', extensionDev.handleDevPackages)
474
+ route('/api/mobile/v1/extension/dev/file', extensionDev.handleDevFile)
475
+
460
476
  // 分享文件的落地通道(App 把手机分享进来的文件分片传上来,见 mobile/uploads.js)。
461
477
  // 手机端没有「把文件交给宿主」的路:图片能进 session.send 的 base64 内容块,其它格式
462
478
  // 一律进不去。四条端点只做「收字节 → 落盘」:默认落到 `~/.dsh/mobile-inbox/<日期>/<文件名>`,
@@ -36,6 +36,9 @@
36
36
  * 现场不修的话,这台机器之后每一次更新都会撞同一堵墙。
37
37
  *
38
38
  * 只动 profile 的依赖树;找不到包管理器就如实失败;装完以盘上版本号为准,退出码 0 不算装上。
39
+ *
40
+ * Windows 上包管理器是 `.cmd`(pnpm 装出来的就是个批处理外壳),不是可执行文件:直接
41
+ * `spawn` 必然 `EINVAL`,得让 cmd 去跑它,见 `cmdInvocation`。
39
42
  */
40
43
 
41
44
  const fs = require('node:fs');
@@ -184,6 +187,7 @@ async function profileRegistry({
184
187
  cwd: profileDir,
185
188
  env: { ...childEnv, ...candidate.extraEnv },
186
189
  timeoutMs,
190
+ platform,
187
191
  });
188
192
  if (!result.ok) continue;
189
193
  registry = parseRegistryOutput(result.output);
@@ -456,12 +460,70 @@ function withReason(message, output) {
456
460
  return reason === '' ? message : `${message}\n${reason}`;
457
461
  }
458
462
 
463
+ /**
464
+ * Windows 上「跑一个 `.cmd` / `.bat`」得先翻译成「让 cmd 去跑它」。
465
+ *
466
+ * 那不是可执行文件,是给 cmd 解释的批处理:`spawn('…\\pnpm.cmd', …)` 在 Windows 上必然失败
467
+ * (`EINVAL`,路径形态再不对一点是 `EFTYPE`)—— 包管理器一次都没跑起来,而用户只看到一句
468
+ * 「更新失败」。所以这里翻成 `cmd /d /s /c "<整行>"`。
469
+ *
470
+ * 引号是这个坑里唯一的细活:`/s /c` 会剥掉命令行的**最外一层**引号,所以整行外面包一层、
471
+ * 每个参数各自再包一层 —— 剥掉外层之后,带空格的路径(`C:\Program Files\…`)才认得出来。
472
+ * 参数仍然是数组、由这里拼,不经过 shell 的变量展开;`windowsVerbatimArguments` 让 Node
473
+ * 原样把这一行递过去,别再替我们补一层引号。
474
+ */
475
+ function cmdInvocation(command, args, { env = process.env } = {}) {
476
+ const comspec = typeof env.ComSpec === 'string' && env.ComSpec.trim() !== '' ? env.ComSpec.trim() : 'cmd.exe';
477
+ const quote = (value) => `"${String(value).replace(/"/g, '""')}"`;
478
+ const line = [command, ...args].map(quote).join(' ');
479
+ return {
480
+ command: comspec,
481
+ args: ['/d', '/s', '/c', `"${line}"`],
482
+ // 这些值只有路径、包名和版本号,没有 `%`,不用担心 cmd 把它当变量展开。
483
+ options: { windowsVerbatimArguments: true },
484
+ };
485
+ }
486
+
487
+ /**
488
+ * 超时杀进程树。
489
+ *
490
+ * Windows 上 `child.kill()` 只杀我们自己起的 cmd.exe,包管理器还活着继续改 profile 的依赖
491
+ * 树 —— 界面说「超时失败」,盘还在被人写。所以那边按整棵树杀。
492
+ */
493
+ function killTree(child, platform) {
494
+ if (platform === 'win32' && typeof child.pid === 'number') {
495
+ try {
496
+ // 挂一个空的 error 监听:taskkill 不在 PATH 上时,没人收的 error 事件会把宿主进程带崩。
497
+ const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true });
498
+ killer.on('error', () => {});
499
+ return;
500
+ } catch {
501
+ // taskkill 起不来就退化成杀自己那一层。
502
+ }
503
+ }
504
+ try {
505
+ child.kill('SIGKILL');
506
+ } catch {
507
+ // 已经退出了。
508
+ }
509
+ }
510
+
459
511
  /** 跑一条命令,收 stdout+stderr 的尾巴。超时或非零退出码都如实带出来。 */
460
- function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
512
+ function run(command, args, { cwd, env, timeoutMs, platform = process.platform, spawnImpl = spawn }) {
513
+ // `.cmd` / `.bat` 交给 cmd 解释;`.exe` 与别的平台照旧直接起。
514
+ const invocation = platform === 'win32' && /\.(?:cmd|bat)$/i.test(command)
515
+ ? cmdInvocation(command, args, { env })
516
+ : { command, args, options: {} };
461
517
  return new Promise(resolve => {
462
518
  let child;
463
519
  try {
464
- child = spawnImpl(command, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
520
+ child = spawnImpl(invocation.command, invocation.args, {
521
+ cwd,
522
+ env,
523
+ windowsHide: true,
524
+ stdio: ['ignore', 'pipe', 'pipe'],
525
+ ...invocation.options,
526
+ });
465
527
  } catch (error) {
466
528
  resolve({ ok: false, code: null, output: '', error: String(error?.message || error) });
467
529
  return;
@@ -473,13 +535,7 @@ function run(command, args, { cwd, env, timeoutMs, spawnImpl = spawn }) {
473
535
  };
474
536
  child.stdout?.on('data', collect);
475
537
  child.stderr?.on('data', collect);
476
- const timer = setTimeout(() => {
477
- try {
478
- child.kill('SIGKILL');
479
- } catch {
480
- // 已经退出了。
481
- }
482
- }, timeoutMs);
538
+ const timer = setTimeout(() => killTree(child, platform), timeoutMs);
483
539
  child.on('error', error => {
484
540
  clearTimeout(timer);
485
541
  resolve({ ok: false, code: null, output, error: String(error?.message || error) });
@@ -552,6 +608,7 @@ async function installLatest({
552
608
  cwd: profileDir,
553
609
  env: { ...childEnv, ...hostInvocation.env },
554
610
  timeoutMs: 20000,
611
+ platform,
555
612
  });
556
613
  if (probe.ok) {
557
614
  return { command: hostInvocation.command, prefix: hostInvocation.args, manager: 'pnpm', kind: 'host', env: hostInvocation.env };
@@ -597,6 +654,7 @@ async function installLatest({
597
654
  cwd: profileDir,
598
655
  env: { ...childEnv, ...picked.env, ...(picked.manager === 'pnpm' ? POLICY_ENV : {}) },
599
656
  timeoutMs,
657
+ platform,
600
658
  });
601
659
 
602
660
  let result = await runManager([]);
@@ -788,6 +846,9 @@ module.exports = {
788
846
  installLatestIfNeeded,
789
847
  installedVersion,
790
848
  declaredSource,
849
+ // 「Windows 上怎么起包管理器」在本机跑不起来,守卫只能验拼好的那一行,所以这两样露出来。
850
+ cmdInvocation,
851
+ runCommand: run,
791
852
  toolSearchDirs,
792
853
  resolveExecutable,
793
854
  normalizeInvocation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-mobile-plugin",
3
- "version": "0.5.10",
3
+ "version": "0.5.12",
4
4
  "description": "Mobile API (/api/mobile/v1) and relay client for the DSH mobile app — desktop profile build (dsh 0.2.x, peers resolved by the host)",
5
5
  "license": "MIT",
6
6
  "icon": "./icon.png",
@@ -10,7 +10,8 @@
10
10
  "type": "module",
11
11
  "main": "lib/index.js",
12
12
  "bin": {
13
- "dsh-mobile-plugin": "host.js"
13
+ "dsh-mobile-plugin": "host.js",
14
+ "dsh-mobile-plugin-pair": "lib/cli/pair.cjs"
14
15
  },
15
16
  "exports": {
16
17
  ".": "./lib/index.js",