dsh-remote-mobile 1.2.0 → 1.4.0

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/auth/token.js CHANGED
@@ -1,13 +1,13 @@
1
1
  import { randomBytes, randomInt } from 'node:crypto';
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
3
- import { dirname } from 'node:path';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync, chmodSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { EventEmitter } from 'node:events';
6
6
  import { hashSecret, verifySecretHash } from './crypto.js';
7
7
  import { isTailscaleIp, isLanIp } from './tailscale.js';
8
8
  export const AUTH_COOKIE_NAME = 'dsh_mobile_token';
9
- export const DEFAULT_PERSIST_FILE = `${homedir()}/.dsh/remote-mobile/devices.json`;
10
- export const GLOBAL_SETTINGS_FILE = `${homedir()}/.dsh/settings.yaml`;
9
+ export const DEFAULT_PERSIST_FILE = join(homedir(), '.dsh', 'remote-mobile', 'devices.json');
10
+ export const GLOBAL_SETTINGS_FILE = join(homedir(), '.dsh', 'settings.yaml');
11
11
  /**
12
12
  * 从 ~/.dsh/settings.yaml 中安全读取 dsh-remote-mobile 命名空间的最新配置
13
13
  */
@@ -872,6 +872,11 @@ export class SessionStore extends EventEmitter {
872
872
  ipStats: Array.from(this.ipStats.values()),
873
873
  };
874
874
  writeFileSync(this.persistPath, JSON.stringify(payload, null, 2), 'utf8');
875
+ // 会话文件内含长效 Token(即完整凭证),权限与 rsa-keys.json 对齐收紧为仅当前用户可读写
876
+ try {
877
+ chmodSync(this.persistPath, 0o600);
878
+ }
879
+ catch { }
875
880
  }
876
881
  catch { }
877
882
  }
@@ -11,21 +11,38 @@
11
11
  *
12
12
  * 2. 【底层 HTTP 门禁劫持与回环上下文虚拟化 (Loopback Context Virtualizer)】:
13
13
  * - 挂载全链路 HTTP 门禁中间件,对未授权请求实施 302 重定向到 /auth 或 401 拦截;
14
- * - 对通过认证(已扫码配对 / 长期密码 / 免密直连)的合法外部请求,在进入下游各业务插件前,
15
- * 统一将 Host、Origin、Socket RemoteAddress 及 Sec-Fetch-Site 规范化虚拟为 127.0.0.1 回环特征;
16
- * - 彻底解除底层 WebServer 的 DNS-Rebinding 阻断,以及第三方插件写死的 loopback-only 403 限制。
14
+ * - 对通过认证(已扫码配对 / 长期密码 / 免密直连)的外部请求,在进入下游各业务插件前,
15
+ * 统一将 Host、Origin、Socket RemoteAddress 及 Sec-Fetch-Site 规范化虚拟为 127.0.0.1 回环特征,
16
+ * 解除底层 WebServer 的 DNS-Rebinding 阻断以及第三方插件写死的 loopback-only 403 限制;
17
+ * - 本机回环 socket(127.0.0.1 / ::1)不做上述洗白:保留原始 Host / Origin / Sec-Fetch-Site,
18
+ * 让下游自身的同源与 DNS-Rebinding 校验继续生效,阻断恶意网页借道本机发起的跨站写攻击。
17
19
  *
18
- * 3. 【Cordis 服务桥接 (remoteWebUiPairing Bridge)】:
19
- * - 在用户关闭旧版 @linxin666/dsh-remote-web-ui 的情况下,以标准 Cordis Service 模式向全局暴露 remoteWebUiPairing 服务;
20
- * - 实现宠物插件 (dsh-pet)、任务看板 (task-board) 等第三方生态组件与本插件授权状态的 100% 互通。
20
+ * 3. 【移动端样式片段注入 (Mobile Style Snippets Injection)】:
21
+ * - 在网关响应层按 User-Agent 判定移动端,向主工作区 index.html 与 /auth 登录页
22
+ * 注入可拼装去重的 CSS 片段(内置预设 + 用户自定义,见 styles/style-snippets.ts),
23
+ * 并给 <html> 打上 data-dsh-mobile 标记供样式选择器使用。
24
+ *
25
+ * 4. 【Cordis 服务桥接 (remoteWebUiPairing Bridge)】:
26
+ * - 以标准 Cordis Service 模式向全局暴露 remoteWebUiPairing 服务;
27
+ * - 该名称是远程/Web 接入类插件的通用共享服务名,可能与其他同类插件冲突,
28
+ * 采用「延迟裁决」策略:等待激活窗口结束后检测服务名归属,
29
+ * 已被占用则主动让出并置冲突标记(设置页展示警示横幅),无人注册才接管;
30
+ * - 判定基于 Cordis 公开运行时结构(服务占用 + fiber 运行态),不针对特定插件;
31
+ * - 保证与其他远程接入类插件共存时必定可以正常启动(不再触发整树回滚致命错误)。
21
32
  */
22
33
  import type { IncomingMessage, ServerResponse } from 'node:http';
23
34
  import type { SessionStore } from '../auth/token.js';
35
+ import { type StyleSnippetStore } from '../styles/style-snippets.js';
24
36
  /**
25
37
  * 注入到 index.html <head> 最前列的 Polyfill 脚本代码片段
26
38
  * 针对非安全上下文(HTTP + 外部 IP)提供轻量高精度的 UUID v4 生成能力
27
39
  */
28
40
  export declare const CRYPTO_POLYFILL_SNIPPET = "\n(function() {\n if (typeof window !== \"undefined\") {\n if (!window.crypto) window.crypto = {};\n if (typeof window.crypto.randomUUID !== \"function\") {\n window.crypto.randomUUID = function() {\n return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {\n var r = Math.random() * 16 | 0;\n var v = c === 'x' ? r : (r & 0x3 | 0x8);\n return v.toString(16);\n });\n };\n }\n }\n})();\n";
41
+ /**
42
+ * 注入到 index.html 的移动端悬浮入口拖拽与位置记忆脚本
43
+ * 允许用户在移动端上下随心拖动左上角入口把手,避免遮挡聊天内容,并自动保存位置
44
+ */
45
+ export declare const DRAGGABLE_NAV_SNIPPET = "\n(function() {\n if (typeof window === \"undefined\" || typeof document === \"undefined\") return;\n\n /* ===== \u9632\u8BEF\u6536\u8D77\u5B88\u536B =====\n * \u7B2C\u4E09\u65B9\u751F\u6001\u63D2\u4EF6\uFF08\u5982 @linxin666/dsh-web-ui-all \u7684\u79FB\u52A8\u7AEF\u62BD\u5C49\u589E\u5F3A\uFF09\u4F1A\u5728\u7528\u6237\u70B9\u51FB\n * \u4FA7\u8FB9\u680F\u6761\u76EE\uFF08role=treeitem\uFF09\u540E\uFF0C\u4E8E\u4E0B\u4E00\u5E27\u7A0B\u5E8F\u5316\u70B9\u51FB\u300C\u6536\u8D77\u4FA7\u8FB9\u680F\u300D\u6309\u94AE\u81EA\u52A8\u6536\u56DE\u62BD\u5C49\u3002\n * \u8BE5\u5224\u5B9A\u65E0\u6CD5\u533A\u5206\u300C\u9009\u4E2D\u6761\u76EE\u56DE\u4E3B\u533A\u300D\u4E0E\u300C\u70B9\u51FB\u6761\u76EE\u5185\u7684\u6B21\u7EA7\u64CD\u4F5C\u56FE\u6807\u300D\uFF08\u66F4\u591A\u64CD\u4F5C \u22EF /\n * \u5C55\u5F00\u7BAD\u5934\u7B49\uFF09\uFF0C\u5BFC\u81F4\u540E\u8005\u62BD\u5C49\u88AB\u8BEF\u5173\u3002\n * \u8FD9\u91CC\u8BB0\u5F55\u6700\u8FD1\u4E00\u6B21\u5BF9\u6761\u76EE\u5185\u6B21\u7EA7\u63A7\u4EF6\uFF08\u6309\u94AE / \u7BAD\u5934\u56FE\u6807\uFF09\u7684\u89E6\u6478\u65F6\u95F4\uFF0C\u5E76\u5305\u88C5\n * HTMLButtonElement.prototype.click\uFF1A\u77ED\u65F6\u95F4\u7A97\u53E3\u5185\u7684\u7A0B\u5E8F\u5316\u6536\u8D77\u8C03\u7528\u76F4\u63A5\u5FFD\u7565\uFF1B\n * \u7528\u6237\u771F\u5B9E\u70B9\u51FB\u6536\u8D77\u6309\u94AE\uFF08\u6D4F\u89C8\u5668\u5408\u6210\u4E8B\u4EF6\u4E0D\u8D70 prototype.click\uFF09\u4E0E\u70B9\u51FB\u6761\u76EE\u4E3B\u4F53\n * \uFF08\u975E\u6309\u94AE/\u7BAD\u5934\uFF09\u7684\u5B98\u65B9\u884C\u4E3A\u5747\u4E0D\u53D7\u5F71\u54CD\u3002 */\n if (!window.__dshRmAntiCollapseInstalled__) {\n window.__dshRmAntiCollapseInstalled__ = true;\n var lastSecondaryTapAt = 0;\n document.addEventListener('pointerdown', function (e) {\n try {\n var t = e.target;\n if (!t || !t.closest) return;\n if (!t.closest('[data-pane=\"sidebar\"]')) return;\n var entry = t.closest('[role=\"treeitem\"], [data-dsh-part=\"sidebar-entry\"]');\n if (!entry) return;\n var isSecondaryControl =\n !!t.closest('button, [role=\"button\"]') ||\n !!t.closest('[class*=\"_arrow\"]');\n if (isSecondaryControl) lastSecondaryTapAt = Date.now();\n } catch (err) {}\n }, { capture: true, passive: true });\n\n var protoClick = HTMLButtonElement.prototype.click;\n HTMLButtonElement.prototype.click = function () {\n try {\n var isSidebarToggle =\n this.matches('[data-dsh-responsive-part=\"sidebar-toggle\"]') ||\n /\u6536\u8D77\u4FA7\u8FB9\u680F|Collapse sidebar|Open sidebar/i.test(this.getAttribute('aria-label') || '');\n if (isSidebarToggle && Date.now() - lastSecondaryTapAt < 900) {\n return; // \u541E\u6389\u7D27\u968F\u6B21\u7EA7\u64CD\u4F5C\u56FE\u6807\u70B9\u51FB\u4E4B\u540E\u7684\u7A0B\u5E8F\u5316\u6536\u8D77\n }\n } catch (err) {}\n return protoClick.apply(this, arguments);\n };\n }\n\n function initDraggableNav() {\n var logoRow = document.querySelector('[data-pane=\"sidebar\"] [class*=\"_logoRow\"]');\n if (!logoRow || logoRow.__dsh_drag_init__) return;\n logoRow.__dsh_drag_init__ = true;\n\n var savedTop = localStorage.getItem('dsh_mobile_nav_top');\n if (savedTop && !isNaN(Number(savedTop))) {\n var topVal = Math.max(10, Math.min(window.innerHeight - 60, Number(savedTop)));\n logoRow.style.setProperty('top', topVal + 'px', 'important');\n }\n\n var startY = 0;\n var initialTop = 0;\n var isDragging = false;\n var hasMoved = false;\n\n function onTouchStart(e) {\n var sidebar = document.querySelector('[data-pane=\"sidebar\"]');\n if (sidebar && !sidebar.querySelector('[class*=\"_collapsed\"]')) return;\n\n var touch = e.touches ? e.touches[0] : e;\n startY = touch.clientY;\n var rect = logoRow.getBoundingClientRect();\n initialTop = rect.top;\n isDragging = true;\n hasMoved = false;\n }\n\n function onTouchMove(e) {\n if (!isDragging) return;\n var touch = e.touches ? e.touches[0] : e;\n var deltaY = touch.clientY - startY;\n\n if (!hasMoved && Math.abs(deltaY) > 5) {\n hasMoved = true;\n }\n\n if (hasMoved) {\n if (e.cancelable) e.preventDefault();\n var newTop = initialTop + deltaY;\n var minTop = 10;\n var maxTop = window.innerHeight - 56;\n newTop = Math.max(minTop, Math.min(maxTop, newTop));\n logoRow.style.setProperty('top', newTop + 'px', 'important');\n }\n }\n\n function onTouchEnd(e) {\n if (!isDragging) return;\n isDragging = false;\n if (hasMoved) {\n e.preventDefault();\n e.stopPropagation();\n var currentRect = logoRow.getBoundingClientRect();\n try {\n localStorage.setItem('dsh_mobile_nav_top', String(Math.round(currentRect.top)));\n } catch (err) {}\n }\n }\n\n logoRow.addEventListener('touchstart', onTouchStart, { passive: true });\n window.addEventListener('touchmove', onTouchMove, { passive: false });\n window.addEventListener('touchend', onTouchEnd, { capture: true });\n\n logoRow.addEventListener('mousedown', onTouchStart);\n window.addEventListener('mousemove', onTouchMove);\n window.addEventListener('mouseup', onTouchEnd, { capture: true });\n }\n\n if (document.readyState === 'loading') {\n document.addEventListener('DOMContentLoaded', initDraggableNav);\n } else {\n initDraggableNav();\n }\n setInterval(initDraggableNav, 800);\n})();\n";
29
46
  /**
30
47
  * 挂载底层 http.Server 门禁劫持与回环上下文虚拟化代理
31
48
  *
@@ -33,7 +50,7 @@ export declare const CRYPTO_POLYFILL_SNIPPET = "\n(function() {\n if (typeof wi
33
50
  * @param gateMiddleware - 全局安全门禁拦截器函数
34
51
  * @param store - 会话与配置管理器实例
35
52
  */
36
- export declare function patchHttpServerWithVirtualizer(server: any, gateMiddleware: (req: IncomingMessage, res: ServerResponse, next?: () => void) => boolean, _store: SessionStore): void;
53
+ export declare function patchHttpServerWithVirtualizer(server: any, gateMiddleware: (req: IncomingMessage, res: ServerResponse, next?: () => void) => boolean, _store: SessionStore, styleStore?: StyleSnippetStore): void;
37
54
  /**
38
55
  * 注册 WebServer HTML 模板层的 Polyfill 注入钩子
39
56
  *
@@ -41,8 +58,40 @@ export declare function patchHttpServerWithVirtualizer(server: any, gateMiddlewa
41
58
  */
42
59
  export declare function mountIndexInjections(ctx: any): void;
43
60
  /**
44
- * 向 Cordis 容器桥接注册 remoteWebUiPairing 服务
45
- * 完美兼容 @linxin666 生态插件(如 dsh-pet 宠物、面板插件等),实现免登信任自动共享
61
+ * 配对桥接服务的运行时裁决状态(供 /status 接口与设置页横幅读取)
62
+ *
63
+ * - pending:激活窗口裁决尚未完成(启动后极短时间内的过渡态)
64
+ * - active :本插件已成功提供 remoteWebUiPairing 服务
65
+ * - yielded:检测到其他插件已注册同名服务,本插件主动让出
66
+ */
67
+ export type PairingBridgeMode = 'pending' | 'active' | 'yielded';
68
+ export interface PairingBridgeState {
69
+ mode: PairingBridgeMode;
70
+ /** 占用服务的插件包名(可识别时);无法识别提供方时为 null,前端回退为通用文案 */
71
+ conflictWith: string | null;
72
+ /** 占用方的 loader entry id(可识别时),用于生成精确的 disabled: true 修复配置 */
73
+ conflictEntryId: string | null;
74
+ }
75
+ /** 读取配对桥接当前裁决状态(快照) */
76
+ export declare function getPairingBridgeState(): PairingBridgeState;
77
+ /** 本插件在 loader 树中的模块名(从“兄弟 entry 尚未落定”判定中排除自身) */
78
+ export declare const PLUGIN_MODULE_NAME = "dsh-remote-mobile";
79
+ /**
80
+ * 向 Cordis 容器桥接注册 remoteWebUiPairing 服务(延迟裁决版,通用共存保护)
81
+ *
82
+ * 背景:remoteWebUiPairing 是远程/Web 接入类插件的通用共享服务名(生态插件经
83
+ * inject 消费)。任何其他插件先注册同名服务后,本插件再
84
+ * 注册必然抛错;而 loader 以 Promise.allSettled 并发激活全部 entry,任一失败会
85
+ * 回滚整棵插件树并使进程退出(致命)。
86
+ *
87
+ * 因此这里不「立即抢注」,而是轮询裁决:
88
+ * 1. 服务名已被占用 → 让出(mode: yielded),并尽力定位提供方包名供界面提示;
89
+ * 2. 兄弟 entry 全部落定且无人注册(未安装 / 被禁用 / 自身激活失败)→ 接管;
90
+ * 3. 最长等待 BRIDGE_MAX_WAIT_MS 兜底;极端竞速下若注册瞬间输给对方,
91
+ * 捕获异常同样按让出处理,绝不向外抛错。
92
+ *
93
+ * 判定完全基于 Cordis 公开运行时结构(服务名占用 + fiber 运行态),不针对
94
+ * 任何特定第三方插件。
46
95
  *
47
96
  * @param ctx - Cordis 上下文对象
48
97
  * @param store - 会话管理器实例