dsh-pocket 2.1.0 → 2.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/index.js +25 -6
- package/lib/proxy.mjs +45 -10
- package/lib/service.mjs +4 -0
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -20,7 +20,7 @@ import { randomBytes } from 'node:crypto';
|
|
|
20
20
|
import { createPocketService } from './service.mjs';
|
|
21
21
|
import { installPocketRpc } from './web-rpc.js';
|
|
22
22
|
import { restartHost } from './restart.js';
|
|
23
|
-
import {
|
|
23
|
+
import { advancedNoticeScript, DEFAULT_INJECT, classifyHost } from './proxy.mjs';
|
|
24
24
|
import { lanEnabled, setLanEnabled, lanAuthEnabled, setLanAuthEnabled, lanIpOverride, setLanIpOverride, pinCustom, setPinCustom, tunnelMode, setTunnelMode, tunnelToken, setTunnelToken, tunnelHostname, setTunnelHostname, resetSettings } from './settings.mjs';
|
|
25
25
|
|
|
26
26
|
const name = 'dsh-pocket';
|
|
@@ -251,12 +251,18 @@ export function apply(ctx, config = {}, internals = {}) {
|
|
|
251
251
|
internals,
|
|
252
252
|
getLanIpOverride: () => lanIpOverride(),
|
|
253
253
|
getLanEnabled: () => lanEnabled(),
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
254
|
+
// 桌面端**不再**注入 dsh-desktop-* 标记(issue #76):
|
|
255
|
+
// - 旧版 dsh-plugin-desktop 缺 mode/platform 会抛错,当初正是为此加了补丁(issue #3/#4);
|
|
256
|
+
// - 但 2.0.3 起,mode 与 platform **同时缺失**时 parseDesktopClientEnvironment 直接返回
|
|
257
|
+
// undefined(视作非桌面外壳,跳过全部桌面逻辑),正是手机页需要的效果;
|
|
258
|
+
// - 反之只要 URL 上出现任一 dsh-desktop-* 标记,客户端就会强制校验整组
|
|
259
|
+
// (material + semver version + mica),只补两个必然抛 "invalid or missing
|
|
260
|
+
// dsh-desktop-material" → 插件树加载失败 → 页面变成「打开恢复模式」;
|
|
261
|
+
// - 更糟的是 decideDesktopBrowserAccess 见到 dsh-desktop-* 前缀就把没有渲染器 token 的
|
|
262
|
+
// 普通浏览器判为 denied(403),刷新后直接打不开。
|
|
263
|
+
// 结论:手机/浏览器访问一律不带这些标记,advanced 模式另加警告覆盖层(issue #19)。
|
|
258
264
|
injectHtml: isDesktop
|
|
259
|
-
? DEFAULT_INJECT +
|
|
265
|
+
? DEFAULT_INJECT + (desktopAdvanced ? advancedNoticeScript() : '')
|
|
260
266
|
: undefined,
|
|
261
267
|
// 访问密码(issue #13 + #18 + #24 + #33 + #66):公网永远要密码(默认每次开启变新,
|
|
262
268
|
// 用户自定义后不再轮换);局域网按开关(默认开启;关闭后局域网扫码直连)。
|
|
@@ -269,6 +275,19 @@ export function apply(ctx, config = {}, internals = {}) {
|
|
|
269
275
|
getToken: (host) => tokenForHost(host),
|
|
270
276
|
isProtected: (host) => (classifyHost(host) === 'public' ? true : lanAuthEnabled()),
|
|
271
277
|
},
|
|
278
|
+
// dsh web 浏览器会话启动 token(issue #77):新版 dsh(>= 0.1.2-alpha.1)要求根路径
|
|
279
|
+
// 带一次 `?token=` 换 cookie,否则 /api 与 WebSocket 全 401。token 每次进程启动都变,
|
|
280
|
+
// 所以每次请求实时从 connection 服务取;老版本没有这个方法 → 返回空,行为不变。
|
|
281
|
+
launchToken: () => {
|
|
282
|
+
try {
|
|
283
|
+
const fn = ctx.connection?.authenticatedUrl;
|
|
284
|
+
if (typeof fn !== 'function') return '';
|
|
285
|
+
const url = new URL(fn.call(ctx.connection, `http://127.0.0.1:${dshPort}`));
|
|
286
|
+
return url.searchParams.get('token') ?? '';
|
|
287
|
+
} catch {
|
|
288
|
+
return '';
|
|
289
|
+
}
|
|
290
|
+
},
|
|
272
291
|
// 隧道模式(issue #66):'quick'(默认,随机 trycloudflare.com)| 'named'(固定域名)
|
|
273
292
|
getTunnelConfig: () => ({ mode: tunnelMode(), token: tunnelToken(), hostname: tunnelHostname() }),
|
|
274
293
|
// 隧道就绪回调:快速模式每次开启轮换 8 位密码(旧链接作废);命名模式地址固定,
|
package/lib/proxy.mjs
CHANGED
|
@@ -38,15 +38,23 @@ export const RANDOM_UUID_POLYFILL = `<script data-dsh-pocket-polyfill="1">!funct
|
|
|
38
38
|
const INJECT_MARK = 'data-dsh-pocket-polyfill="1"';
|
|
39
39
|
|
|
40
40
|
/**
|
|
41
|
-
* DSH Desktop
|
|
41
|
+
* DSH Desktop(桌面版)渲染进程兼容补丁(issue #3/#4,已于 issue #76 停用)。
|
|
42
42
|
*
|
|
43
|
-
*
|
|
43
|
+
* 历史:旧版 dsh-plugin-desktop 的 client 在页面加载时从 URL query 读
|
|
44
44
|
* `dsh-desktop-mode` 与 `dsh-desktop-platform`,缺失即抛
|
|
45
|
-
* "invalid or missing dsh-desktop-mode null" →
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
45
|
+
* "invalid or missing dsh-desktop-mode null" → 页面崩(手机扫码访问桌面版时正是如此)。
|
|
46
|
+
* 本脚本用 history.replaceState 补上这两个参数(无跳转、不重载),取最轻的
|
|
47
|
+
* `compatibility` 模式——不激活桌面布局,避免与移动端适配叠加。
|
|
48
|
+
*
|
|
49
|
+
* @deprecated 不要再注入(issue #76,DSH Desktop 2.0.3 起):
|
|
50
|
+
* ① mode 与 platform **同时缺失**时,parseDesktopClientEnvironment 直接返回 undefined
|
|
51
|
+
* (视作非桌面外壳,跳过全部桌面逻辑),正是手机/浏览器页面需要的效果;
|
|
52
|
+
* ② 只要 URL 上出现任一 dsh-desktop-* 标记,客户端就强制校验整组(material +
|
|
53
|
+
* semver version + mica),只补两个必然抛 "invalid or missing
|
|
54
|
+
* dsh-desktop-material" → 插件树加载失败 → 页面变成「打开恢复模式」;
|
|
55
|
+
* ③ 更糟:decideDesktopBrowserAccess 见到 dsh-desktop-* 前缀就把没有渲染器 token 的
|
|
56
|
+
* 普通浏览器判为 denied(403),刷新后直接打不开。
|
|
57
|
+
* lib/index.js 已不再注入本脚本;保留导出仅为兼容旧版本桌面端与既有测试。
|
|
50
58
|
*/
|
|
51
59
|
export function desktopEnvPatchScript(platform) {
|
|
52
60
|
const p = ['darwin', 'win32', 'linux'].includes(platform) ? platform : 'linux';
|
|
@@ -279,6 +287,28 @@ function loopbackAuthority(headers, upstream) {
|
|
|
279
287
|
return headers;
|
|
280
288
|
}
|
|
281
289
|
|
|
290
|
+
// ---------- dsh web 浏览器会话 token(issue #77) ----------
|
|
291
|
+
// 新版 dsh web(>= 0.1.2-alpha.1)给浏览器会话加了启动 token:根路径 `GET /` 必须带一次
|
|
292
|
+
// `?token=<启动 token>` 换一个绑定 authority 的 cookie,之后 /api 与 WebSocket 才放行;
|
|
293
|
+
// 否则一律 401("dsh web authentication required")。手机扫码进来的 URL 天然没有这个
|
|
294
|
+
// token,所以代理要在转发时补一次。
|
|
295
|
+
//
|
|
296
|
+
// 只在 `GET /` 且请求还没带 dsh-auth-* cookie 时注入:上游拿到 token 会 303 回干净的根
|
|
297
|
+
// 路径,若每次都注入就会 303 循环(浏览器很快报"重定向次数过多")。
|
|
298
|
+
const DSH_AUTH_COOKIE = 'dsh-auth-';
|
|
299
|
+
export function upstreamPathWithLaunchToken(reqUrl, method, cookieHeader, launchToken) {
|
|
300
|
+
if (method !== 'GET') return reqUrl;
|
|
301
|
+
let u;
|
|
302
|
+
try { u = new URL(reqUrl ?? '/', 'http://dsh.invalid'); } catch { return reqUrl; }
|
|
303
|
+
if (u.pathname !== '/') return reqUrl;
|
|
304
|
+
// 登录成功后跳回的 `/?dsh-pocket-auth=1`:强制重做一次握手(旧 cookie 可能已过期/被撤销)
|
|
305
|
+
const force = u.searchParams.has('dsh-pocket-auth');
|
|
306
|
+
if (!force && String(cookieHeader ?? '').includes(DSH_AUTH_COOKIE)) return reqUrl;
|
|
307
|
+
if (!launchToken) return reqUrl;
|
|
308
|
+
u.searchParams.set('token', launchToken);
|
|
309
|
+
return `${u.pathname}${u.search}`;
|
|
310
|
+
}
|
|
311
|
+
|
|
282
312
|
// ---------- WebSocket 心跳注入(PR #41,issue #29) ----------
|
|
283
313
|
// DSH 客户端与宿主的 WebSocket downlink 都不发 ping/pong(客户端只读流、
|
|
284
314
|
// 宿主只推帧),空闲连接会被路由器 NAT 空闲超时或手机系统省电机制**静默**
|
|
@@ -348,7 +378,7 @@ function attachWebSocketHeartbeat(socket, { intervalMs = 30_000, missLimit = 2 }
|
|
|
348
378
|
* @param {() => boolean} [opts.lanAccessEnabled] 局域网访问是否开启(默认开启)。关闭时拦截经局域网 Host 的请求(公网/loopback 不受影响)。
|
|
349
379
|
* @returns {Promise<{server:import('node:http').Server, close:()=>Promise<void>}>}
|
|
350
380
|
*/
|
|
351
|
-
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true } = {}) {
|
|
381
|
+
export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DEFAULT_UPSTREAM, log = null, injectHtml = DEFAULT_INJECT, auth = null, rateLimit = null, heartbeat = {}, lanAccessEnabled = () => true, launchToken = () => '' } = {}) {
|
|
352
382
|
const limiter = auth ? createRateLimiter(rateLimit ?? {}) : null;
|
|
353
383
|
const server = createServer((req, res) => {
|
|
354
384
|
const host = String(req.headers.host ?? '');
|
|
@@ -392,7 +422,9 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
392
422
|
if (submitted === token) {
|
|
393
423
|
limiter?.clear(ip);
|
|
394
424
|
res.writeHead(302, {
|
|
395
|
-
|
|
425
|
+
// 带上 dsh-pocket-auth=1:登录成功后强制重做一次浏览器会话握手,
|
|
426
|
+
// 换掉可能已过期/被撤销的 dsh web 会话 cookie(issue #77)
|
|
427
|
+
location: '/?dsh-pocket-auth=1',
|
|
396
428
|
'set-cookie': `${TOKEN_COOKIE}=${cookieFor(token, sessionKey)}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${COOKIE_MAX_AGE}`,
|
|
397
429
|
'cache-control': 'no-store',
|
|
398
430
|
});
|
|
@@ -421,8 +453,11 @@ export function createPocketProxy({ port = 3081, host = '0.0.0.0', upstream = DE
|
|
|
421
453
|
}
|
|
422
454
|
}
|
|
423
455
|
const headers = loopbackAuthority({ ...req.headers }, upstream);
|
|
456
|
+
// dsh web 浏览器会话 token(issue #77):首屏根路径补一次,换回绑定 authority 的 cookie
|
|
457
|
+
const launchTok = (typeof launchToken === 'function' ? launchToken() : '') || '';
|
|
458
|
+
const upstreamPath = upstreamPathWithLaunchToken(req.url, req.method, req.headers.cookie, launchTok);
|
|
424
459
|
const proxyReq = httpRequest(
|
|
425
|
-
{ host: upstream.host, port: upstream.port, method: req.method, path:
|
|
460
|
+
{ host: upstream.host, port: upstream.port, method: req.method, path: upstreamPath, headers, agent: false },
|
|
426
461
|
(proxyRes) => {
|
|
427
462
|
log?.(`${req.method} ${req.url} -> ${proxyRes.statusCode}`);
|
|
428
463
|
const contentType = String(proxyRes.headers['content-type'] ?? '');
|
package/lib/service.mjs
CHANGED
|
@@ -180,6 +180,8 @@ export function createPocketService({
|
|
|
180
180
|
injectHtml,
|
|
181
181
|
/** 访问令牌认证配置(issue #13):{ getToken, isProtected },传给代理 */
|
|
182
182
|
auth,
|
|
183
|
+
/** @type {() => string} dsh web 浏览器会话启动 token(issue #77;老版本返回空字符串) */
|
|
184
|
+
launchToken = () => '',
|
|
183
185
|
/** 隧道配置(issue #66):() => ({ mode:'quick'|'named', token, hostname });named 用固定域名 */
|
|
184
186
|
getTunnelConfig,
|
|
185
187
|
/** 隧道就绪回调(lib/index.js 用它轮换公网密码;参数为 'quick'|'named') */
|
|
@@ -257,6 +259,8 @@ export function createPocketService({
|
|
|
257
259
|
...(auth ? { auth } : {}),
|
|
258
260
|
// 每次请求实时读开关:设置页切换后立即生效,无需重启代理
|
|
259
261
|
lanAccessEnabled: () => getLanEnabled(),
|
|
262
|
+
// dsh web 浏览器会话启动 token(issue #77):实时取,新版 dsh 才有
|
|
263
|
+
...(launchToken ? { launchToken } : {}),
|
|
260
264
|
});
|
|
261
265
|
if (p !== port) {
|
|
262
266
|
console.log(`dsh-pocket: port ${port} busy, proxy on ${p} | 端口 ${port} 被占用,代理改用 ${p}`);
|
package/package.json
CHANGED