dsh-mobile-plugin 0.5.11 → 0.5.13
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/client.js +825 -303
- package/lib/impl.js +37 -6
- package/lib/mobile/model-vendors.js +16 -1
- package/lib/mobile/session-models.js +5 -2
- package/lib/mobile/uploads.js +234 -10
- package/lib/panel/index.html +13 -11
- package/lib/routes/control-plane.js +48 -3
- package/lib/shared/e2ee-request.js +39 -0
- package/lib/shared/http-util.js +56 -1
- package/package.json +1 -1
|
@@ -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, withManualIpv6Candidate } = 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
|
*/
|
|
@@ -431,7 +468,13 @@ function registerControlPlaneRoutes(deps) {
|
|
|
431
468
|
}) })
|
|
432
469
|
// 只有可信本机设置入口可以开 / 关窗口;窗口状态仅驻留进程内,DSH 重启后必定关闭。
|
|
433
470
|
registerRoute({ kind: 'exact', path: '/api/relay/web-access', handler: localOnly('POST', async (req, res) => {
|
|
434
|
-
|
|
471
|
+
// 谁来按这个开关(2026-11-02 用户拍板放宽,取代原先「只认可信本机设置页」)。
|
|
472
|
+
//
|
|
473
|
+
// 侧边栏那颗手机图标成为进网页的唯一入口之后,公网 / 局域网部署的人就是在自己的浏览器里
|
|
474
|
+
// 点它 —— 那时 socket 对端与 Host 都不是回环,按老判据会 403,于是谁也没法把窗口打开,
|
|
475
|
+
// 面板永远回 423。现在只要求 `localOnly` 已经保证的那一层:同源、不带中转凭据、不经本机
|
|
476
|
+
// 代理转发。能发出这条请求的只有 DSH 自己页面里的代码,而「人在 DSH 界面里按下了这个
|
|
477
|
+
// 按钮」正是这个窗口存在的理由。跨站页面、中转与局域网代理转进来的请求仍在 localOnly 挡掉。
|
|
435
478
|
const body = await readJson(req);
|
|
436
479
|
if (typeof body?.enabled !== 'boolean') {
|
|
437
480
|
return sendJson(res, 400, { ok: false, error: { code: 'bad-request', message: 'enabled 必须是布尔值' } });
|
|
@@ -439,7 +482,7 @@ function registerControlPlaneRoutes(deps) {
|
|
|
439
482
|
const webAccess = body.enabled ? webAccessWindow.enable() : webAccessWindow.disable();
|
|
440
483
|
return sendJson(res, 200, { ok: true, webAccess });
|
|
441
484
|
}) });
|
|
442
|
-
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() })) })
|
|
443
486
|
// 面板页本体:设置界面从客户端半边搬到这里,浏览器(或桌面端里的「打开配置页面」)
|
|
444
487
|
// 直接打开它。三条地址吐的是同一份 HTML,分流在 React 侧(见 React/src/Root.tsx):
|
|
445
488
|
// /api/relay/panel 看宿主记的「配没配过」,自己决定先走引导还是直接进主页
|
|
@@ -1028,6 +1071,8 @@ const WEB_ACCESS_ROUTES = new Set([
|
|
|
1028
1071
|
module.exports = {
|
|
1029
1072
|
registerControlPlaneRoutes,
|
|
1030
1073
|
panelDiagnosis,
|
|
1074
|
+
panelUrlOf,
|
|
1075
|
+
panelScopeOf,
|
|
1031
1076
|
WEB_ACCESS_ROUTES,
|
|
1032
1077
|
WEB_ACCESS_WINDOW_MS,
|
|
1033
1078
|
createWebAccessWindow,
|
|
@@ -65,6 +65,17 @@ function signedPath(req) {
|
|
|
65
65
|
return hit >= 0 ? pathname.slice(hit) : pathname;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* v3 二进制分片的 AAD:`v2|up|METHOD|PATH|<uploadId>|<offset>`。
|
|
70
|
+
*
|
|
71
|
+
* 在 v2 的四段后面绑上这两格,是为了让「把一片搬到别的上传 / 搬到同一上传的别的偏移」
|
|
72
|
+
* 在 AEAD 这一层就不成立 —— 签名只覆盖路径与字节,绑不到上传身份上。
|
|
73
|
+
*
|
|
74
|
+
* 字符串口径写在 `docs/native-upload.md` 的 v3 章,与 App 侧下一阶段的实现对齐;这里只
|
|
75
|
+
* 负责把它拼出来,不额外规范化 offset(`"042"` 与 `"42"` 拼出来不同,客户端必须原样发)。
|
|
76
|
+
*/
|
|
77
|
+
const binChunkAad = (method, path, uploadId, offset) => `v2|up|${method}|${path}|${uploadId}|${offset}`;
|
|
78
|
+
|
|
68
79
|
/** 查询串按线上原样(含 `?`),不排序:它必须与 App 真正发出去的那串一致。 */
|
|
69
80
|
function signedQuery(req) {
|
|
70
81
|
const raw = String(req?.url || '');
|
|
@@ -81,6 +92,8 @@ function signedQuery(req) {
|
|
|
81
92
|
* @param {Buffer} [deps.rawBody] 已读到的请求体原文(没读过就给 undefined,这里会自己读)
|
|
82
93
|
* @returns {{ ok: true, body: object|undefined, keys: object, device: object }
|
|
83
94
|
* | { ok: false, status: number, code: string, extra?: object }}
|
|
95
|
+
*
|
|
96
|
+
* 带 `X-Dsh-Wire: bin` 的请求没有 JSON body,解出来的明文放在 `req.rawBody`(Buffer)上。
|
|
84
97
|
*/
|
|
85
98
|
function openRequest(req, deps, rawBody) {
|
|
86
99
|
const keyId = headerOf(req, 'x-dsh-key-id').trim();
|
|
@@ -133,6 +146,32 @@ function openRequest(req, deps, rawBody) {
|
|
|
133
146
|
nonces.set(keyId, table);
|
|
134
147
|
}
|
|
135
148
|
|
|
149
|
+
// ── 二进制 wire(v3 的上行分片体,见 docs/native-upload.md 的 v3 章)──
|
|
150
|
+
//
|
|
151
|
+
// 只对**明确带头**的请求生效:老 App 不会发 `X-Dsh-Wire`,所以它走的仍是下面那条
|
|
152
|
+
// JSON 信封分支,一个字节都不变。反过来,带了 `bin` 就说明客户端知道自己在发裸密文,
|
|
153
|
+
// 这时候把它当 JSON 解只会得到一个「请求体不是合法 JSON」的 400。
|
|
154
|
+
if (headerOf(req, 'x-dsh-wire').trim().toLowerCase() === 'bin') {
|
|
155
|
+
const uploadId = headerOf(req, 'x-dsh-upload-id').trim();
|
|
156
|
+
const offsetText = headerOf(req, 'x-dsh-upload-offset').trim();
|
|
157
|
+
// 这两格既进 AAD 也进业务判据,缺一格就没法把「这一片」绑到「这个上传的这个偏移」上。
|
|
158
|
+
if (!uploadId || !/^\d+$/.test(offsetText)) {
|
|
159
|
+
return { ok: false, status: 400, code: 'e2ee-bad-envelope', extra: { reason: 'missing wire headers' } };
|
|
160
|
+
}
|
|
161
|
+
let plain;
|
|
162
|
+
try {
|
|
163
|
+
// AAD 比 v2 多两段:先把一片搬到别的上传、或搬到同一上传的别的偏移,认证就不过。
|
|
164
|
+
// 签名口径不变(签的仍是线上这串密文字节),所以上面那步验签一行没动。
|
|
165
|
+
plain = e2ee.open(keys.up, rawBody || Buffer.alloc(0), binChunkAad(method, signedPath(req), uploadId, offsetText));
|
|
166
|
+
} catch {
|
|
167
|
+
return { ok: false, status: 400, code: 'e2ee-decrypt-failed' };
|
|
168
|
+
}
|
|
169
|
+
// 业务 handler 拿不到 JSON(这一片本来就不是 JSON):明文交给 `req.rawBody`,
|
|
170
|
+
// 上传 handler 用 `readRawBody` 取。响应仍由上面的守卫封成普通的 v2 JSON 信封。
|
|
171
|
+
req.rawBody = plain;
|
|
172
|
+
return { ok: true, body: undefined, keys, device: record, req: { ts, nonce } };
|
|
173
|
+
}
|
|
174
|
+
|
|
136
175
|
// ── 解封请求体 ──
|
|
137
176
|
let body;
|
|
138
177
|
if (rawBody && rawBody.length > 0) {
|
package/lib/shared/http-util.js
CHANGED
|
@@ -137,4 +137,59 @@ function readJsonBody(req, maxBytes = JSON_MAX_BODY) {
|
|
|
137
137
|
});
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
/**
|
|
141
|
+
* 读原始请求体,回 Buffer;超限 / 连接出错 → null。
|
|
142
|
+
*
|
|
143
|
+
* 与 `readJsonBody` 的分工:那条路交出去的是**解析好的 JSON**,这条路交出去的是**字节**。
|
|
144
|
+
* v3 的二进制分片(`X-Dsh-Wire: bin`)加密后就是裸字节,中间没有 JSON 这一层;handler 拿
|
|
145
|
+
* 到的明文片(守卫解封之后)同样是字节,所以两边都用这一条。
|
|
146
|
+
*
|
|
147
|
+
* `req.rawBody` 优先:信封守卫(`shared/e2ee-request.js`)已经把密文读走、解封完放在那里,
|
|
148
|
+
* 请求流早就见底了,再 `on('data')` 只会等来一个空 body —— 那会表现为「每片都解出 0 字节」,
|
|
149
|
+
* 而错误信息只会是「分片是空的」。
|
|
150
|
+
*
|
|
151
|
+
* @param {object} req node:http IncomingMessage
|
|
152
|
+
* @param {number} [maxBytes] 请求体上限(字节),缺省按普通 JSON 那一档
|
|
153
|
+
* @returns {Promise<Buffer|null>}
|
|
154
|
+
*/
|
|
155
|
+
function readRawBody(req, maxBytes = JSON_MAX_BODY) {
|
|
156
|
+
const handed = req && req.rawBody;
|
|
157
|
+
if (Buffer.isBuffer(handed)) return Promise.resolve(handed.length > maxBytes ? null : handed);
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
const chunks = [];
|
|
160
|
+
let total = 0;
|
|
161
|
+
let settled = false;
|
|
162
|
+
req.on('data', (chunk) => {
|
|
163
|
+
if (settled) return;
|
|
164
|
+
total += chunk.length;
|
|
165
|
+
if (total > maxBytes) {
|
|
166
|
+
settled = true;
|
|
167
|
+
req.destroy();
|
|
168
|
+
resolve(null);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
chunks.push(Buffer.from(chunk));
|
|
172
|
+
});
|
|
173
|
+
req.on('end', () => {
|
|
174
|
+
if (settled) return;
|
|
175
|
+
settled = true;
|
|
176
|
+
resolve(Buffer.concat(chunks));
|
|
177
|
+
});
|
|
178
|
+
req.on('error', () => {
|
|
179
|
+
if (settled) return;
|
|
180
|
+
settled = true;
|
|
181
|
+
resolve(null);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
sendJson,
|
|
188
|
+
sendHtml,
|
|
189
|
+
readJsonBody,
|
|
190
|
+
readRawBody,
|
|
191
|
+
encodeJsonBody,
|
|
192
|
+
wantsGzip,
|
|
193
|
+
JSON_GZIP_MIN_BYTES,
|
|
194
|
+
JSON_MAX_BODY,
|
|
195
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mobile-plugin",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.13",
|
|
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",
|