@yezack/dsh-remote-mobile 1.9.2 → 1.10.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/CHANGELOG.md CHANGED
@@ -5,6 +5,68 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.10.0] - 2026-10-22
9
+
10
+ > 本版本为 **fork 发布(`@yezack/dsh-remote-mobile`)**:合并上游 `feat/gateway` 分支(上游原计划 1.10.0),并保留本 fork 独有的修复。
11
+ > 版本号说明:上游 `dsh-remote-mobile@1.9.1` 与本 fork 历史 `1.9.1` **同号但内容不同**(上游修登录页正则,本 fork 修 `isEn`),两者均已并入本版。
12
+
13
+ ### Fixed(本 fork 独有,上游 master 不含)
14
+
15
+ - **失败分支 `isEn` 未插值**:`data.reason` 缺失时的回退文案引用了仅存在于服务端的 `isEn`,浏览器求值即抛 `ReferenceError` 并被外层 catch 吞掉,把真实失败原因显示成「网络连接异常」。改为 `${JSON.stringify(...)}` 插值。
16
+ - **包身份跟随 `package.json` 的 `name`**(1.9.2 修复延续):`cordis.patch.yml` 条目名与 `lib/client.js` 的 `window.__ModuleLoader__.load({ id })` 必须等于包名,否则宿主侧解析不到模块、客户端启动图 row 等不到自己的 factory,整包无法加载。
17
+ - **登录页正则改用上游的 `new RegExp('/auth/?$')` 写法**,替换本 fork 早期的双反斜杠方案:语义等价,且从根上规避模板字符串转义折叠风险。
18
+ - **测试**:保留 `test/login-page.test.js` 回归套件(内联脚本可解析、不得残留裸服务端标识符、失败文案与网关子路径 URL 推导),并新增包身份一致性断言。
19
+
20
+ ### Added & Fixed (解决 Issue #8 · 适配 DSH 0.2.1+ 核心禁止监听 0.0.0.0,内置智能自适应安全反向代理网关)
21
+
22
+ - **双层体系解耦与网关握手 Secret 闭环(修复真实 IP 丢失与防暴破失效)**:
23
+ - **根本矛盾解耦**:彻底拆分物理 Socket IP(`getPhysicalSocketIp`)与业务有效客户端 IP(网关转发头 / 底座补丁内计算)。网关到底座物理 Socket 恒为 `127.0.0.1`,底座补丁绝不因网关携带外部真实 IP 而误判物理 Socket 逃逸回环、杜绝二次无条件洗白抵消安全策略;
24
+ - **Secret 进程级安全握手**:新增 `src/gateway/secret.ts`,基于 `crypto.randomBytes(32)` 生成高强度内部密钥,通过 `WeakMap<server, secret>` 深度支持 Cordis 插件热重载与多实例隔离;
25
+ - **三头强制净化与同步**:底座补丁在物理 Socket 为回环且 Secret 恒定时间比对(`crypto.timingSafeEqual`)一致时,才采纳有效真实 IP,并同步覆盖 `x-dsh-real-ip`、`x-real-ip` 与 `x-forwarded-for`,随后安全抹除私密 Secret 头;客户端伪造头被一律纠正为物理 Socket IP,防暴破与审计精准绑定外部真实客户端。
26
+ - **模式判定顺序保护与动态 Host 白名单(修复 0.0.0.0 模式误切与 DNS-Rebinding)**:
27
+ - **模式判定顺序严格化**:`detectServerMode` 严格基于**原始 host**进行模式裁决,`0.0.0.0` 在 `auto` 下恒走 `direct` 模式,保障旧版 DSH 向下兼容;仅当确认生效为 `gateway` 模式时,才将内部 proxyTarget 规范化为回环地址;
28
+ - **动态 Host 白名单校验 (`isHostAllowed`)**:网关在接收请求第一步严格校验 Host 主机名(本机网卡 IP 短缓存 + Tailscale CGNAT + 回环 + 静态 trustedHosts),未知域名直接 400 阻断,杜绝 DNS-Rebinding 攻击;外部端口不参与信任判定,以兼容反向代理、TLS 终结与端口映射。
29
+ - **Host 与 Origin 成对虚拟化决策矩阵(解决 /auth 登录错位 403 与生态插件拦截)**:
30
+ - **固定执行顺序**:`Host 白名单校验 -> 门禁鉴权与 gateResult 标记 -> 跨站写拦截 (在虚拟化之前执行) -> 成对虚拟化 -> 注入 Secret 与 real-ip -> 转发`;
31
+ - **跨站写请求全量拦截**:非 GET/HEAD 请求不论回环还是外部,若携带恶意 `Origin`、不透明 `Origin: null` 或无 Origin 但携带 `Sec-Fetch-Site: cross-site`,一律在虚拟化改写前 403 阻断;
32
+ - **成对改写原则**:
33
+ - `public` 路径(如 `/auth`)与外部已认证流量:在确认同源或本地等价后,`Host` 与 `Origin` 成对虚拟化为内部核心权威源,彻底杜绝下游因 Host/Origin 错位导致的 403 拒绝;跨站 Origin 绝不洗白,原样保留供下游 CSRF 拦截;
34
+ - `loopback` 本机流量:成对规范化本地 Host 变体与 Origin,`Origin: null` 严格保持原样防跨站。
35
+ - **WebSocket 代理闭环与超时机制完善**:
36
+ - **握手超时保护**:握手阶段设置超时守护(默认 10s),协议升级为 101 成功后立即清除超时,保障长连接 WebSocket 不被误杀;
37
+ - **非 101 状态透明透传**:捕获底座的 403 / 401 等 HTTP 响应头与 Body 并原样返回给客户端;CSWSH 跨站拦截前置阻断。
38
+ - **状态机与前端卡片联动**:
39
+ - `SessionStore` 提供标准的网关状态机(`stopped | starting | running | error`);
40
+ - `/api/remote-mobile/status` 返回 `gateway.running` 与 `gateway.error` 完整契约;前端卡片在网关未就绪或端口冲突时展示友好警示横幅。
41
+ - **direct 模式与反向代理场景加固(复审补丁)**:
42
+ - **direct 模式 Host/WS 白名单**:非回环请求必须先通过 Host 静态白名单(本机网卡 IP / 回环 / 静态 `trustedHosts`),否则 403;WebSocket Upgrade 增加 Host + Origin 同源校验,阻断 direct 模式下的 DNS-Rebinding 与 CSWSH;
43
+ - **反向代理/端口映射兼容**:Host 白名单只校验主机名,不再强制外部端口等于网关 `boundPort`;`isSameOriginOrLocalEquivalent` 在协议一致的前提下兼容 80/443 默认端口;`X-Forwarded-Proto` 与 302 `Location` 保留外部 HTTPS,避免反代场景降级或 400/403;
44
+ - **反代回环/LAN 免密绕过修复**:同机 127 反代通过外部 Host、LAN/Tailscale 反代通过 `X-Forwarded-*` 识别为代理流量,不再走 `isLoopbackRequest` / `allowLan` / `allowTailscale` 免密;受信 Gateway 通过内部 Secret 打标例外,并优先使用 `X-Forwarded-For` 首个合法 IP 作为真实来源;WebSocket Upgrade 同样覆盖;
45
+ - **配置错误兜底**:`detectServerMode` 遇到 “gateway + 具体 LAN IP” 配置错误时由 `apply` 捕获并回退 direct,同时输出 Banner 警告;`printReadyBanner` 在等待网关后重新检查 `disposed`,避免 dispose 后重新注入 Secret/补丁;
46
+ - **本机 3080 根路径回归修复**:`isLoopbackRequest` 现在只把**外部值**的 `X-Forwarded-For` / `X-Real-IP` / `X-Forwarded-Host` 或 `X-Forwarded-Proto` 当作反向代理信号,插件自身为 loopback 请求注入的 `127.0.0.1` 头不再误伤;修复“3080 根路径被 DSH 官方 401 拦截,但 3081 正常”的回归;
47
+ - **测试隔离修复**:`SessionStore` 在测试环境且未显式指定 `devicesFile` 时自动使用 `/tmp/dsh-rm-test-devices-*.json`,不再写入真实的 `~/.dsh/remote-mobile/devices.json`;修复反复运行 `npm test` 会持续产生 `127.0.0.1 / iPhone` 假设备的问题,并新增隔离回归测试;
48
+ - **测试补充**:新增 direct 模式 DNS-Rebinding/CSWSH、反向代理端口/默认端口/HTTPS Location、网关外部来源真实 IP 注入、内部 127 Forwarded 头回归等用例。`npm test` 会先构建 server/client;在 Node 20.12 / OpenSSL 3 下,全量 155 项中 154 项通过,1 项(PKCS#1 v1.5 legacy)因 CVE-2023-46809 自动跳过,OAEP 主路径正常通过。
49
+ - **反代部署要求**:反向代理必须保留外部 Host,并透传 `X-Forwarded-For` / `X-Forwarded-Host` / `X-Forwarded-Proto`。若 Host 被改写为 `127.0.0.1` 且没有任何 Forwarded 头,插件无法区分本机浏览器与同机反代;LAN/Tailscale 反代若缺少 Forwarded 头也可能被误判为真实局域网客户端。
50
+ - **测试覆盖与基础设施**:
51
+ - 新增专用安全测试套件 `test/gateway-security.test.js`,包含模式判定、防暴破锁定真实 IP、Secret 伪造防御与 WeakMap 热重载、Drive-by 跨站拦截、本地变体等价放行、换 Wi-Fi 动态 IP 注入测试钩子(`setMockNetworkIps` / `clearNetworkIpCache`)、真实 DSH 0.2.1 风格 `/auth` + `/verify` 登录链路、WebSocket 代理闭环、direct 模式 DNS-Rebinding/CSWSH、反向代理端口与外部来源真实 IP 注入等核心用例。
52
+ - **文档更新**:
53
+ - 更新中英文 README,提供老用户升级至 DSH 0.2.1+ 的 YAML 配置对比示例,新增 `trustedHosts` / 反向代理 Host、XFF、XFP 配置说明,并明确网关默认绑定 `0.0.0.0:3081` 的安全权衡与直连模式注意事项。
54
+
55
+ ---
56
+
57
+ ## [1.9.1] - 2026-10-17
58
+
59
+ ### Fixed (解决 Issue #7 · 修复 /auth 登录页内联脚本语法错误导致局域网密码登录完全失效)
60
+
61
+ - **修复登录页内联脚本模板转义缺陷**:在 1.8.0 引入子路径/网关前缀自适应时,`src/routes/login-page.ts` 内使用 ES6 模板字符串拼接登录页内联 `<script>`,其中第 344/356/365 行的正则字面量写为 `window.location.pathname.replace(/\/auth\/?$/, ...)`。外层模板字符串反引号在求值时将 `\/` 折叠为 `/`,导致下发给客户端浏览器的内联脚本退化为 `replace(//auth/?$, ...)`,被 JS 词法解析器识别为单行注释吞掉后续代码并抛出 `SyntaxError: Unexpected token 'return'`:
62
+ - 内联脚本因此整体中断执行,表单 submit 监听器、`doVerify()`、RSA 密钥提取与 `/api/remote-mobile/verify` 接口调用均未注册,表现为局域网设备输入正确长期密码点击「立即授权连接」无任何反应,输入错误密码也无错误提示并退化为原生 GET 刷新;
63
+ - 现改用 `new RegExp('/auth/?$')` 显式构造正则对象,彻底消除模板字符串与正则字面量转义规则的歧义与折叠风险。
64
+ - **测试覆盖与语法保障**:
65
+ - 在 `test/routes.test.js` 中新增针对中英双语 `/auth` 页面内联 `<script>` 的自动化测试,通过 `new Function(...)` 语法解析并断言无 `SyntaxError`,且严禁出现未转义退化的 `//auth` 注释;
66
+ - 在 `test/compat.test.js` 中新增对所有注入到客户端的 5 个 Snippet 脚本的 `new Function(...)` 语法合法性全量断言。
67
+
68
+ ---
69
+
8
70
  ## [1.9.2] - 2026-10-13
9
71
 
10
72
  ### Fixed (修复按新包名发布后插件整包无法加载:包身份不一致)
@@ -17,11 +79,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
79
 
18
80
  ---
19
81
 
20
- ## [1.9.1] - 2026-10-13
82
+ ## [1.9.1] - 2026-10-13(本 fork 历史版本,与上游 1.9.1 同号不同内容)
21
83
 
22
84
  ### Fixed (修复授权页无法登录:模板字符串转义与未插值的服务端标识符)
23
85
 
24
- - **授权页三处正则转义被模板字符串吞掉(致命)**:内联脚本里的 `/\/auth\/?$/` 写在 TS 模板字符串内,`\/` 在字符串求值时退化为 `/`,服务端实际发出的 HTML 变成 `replace(//auth/?$/, '/')` —— `//` 被解析为行注释、语句被截断,整个 `<script>` 抛 SyntaxError,`form` 的 submit 回调从未绑定,表现为**任何浏览器下点「立即授权连接」都毫无反应、无法登录**。三处均改为双反斜杠形式。
86
+ - **授权页三处正则转义被模板字符串吞掉(致命)**:内联脚本里的 `/\/auth\/?$/` 写在 TS 模板字符串内,`\/` 在字符串求值时退化为 `/`,服务端实际发出的 HTML 变成 `replace(//auth/?$/, '/')` —— `//` 被解析为行注释、语句被截断,整个 `<script>` 抛 SyntaxError,`form` 的 submit 回调从未绑定,表现为**任何浏览器下点「立即授权连接」都毫无反应、无法登录**。三处均改为双反斜杠形式(1.10.0 起改用上游的 `new RegExp` 写法)。
25
87
  - **失败提示引用未定义的服务端变量**:`data.reason` 缺失时的回退文案写成了裸 `isEn`(服务端变量未做插值),浏览器求值即抛 `ReferenceError`,被外层 catch 吞掉后把真实失败原因显示为「网络连接异常」。改为 `${JSON.stringify(...)}` 插值。
26
88
  - **测试**:`test/login-page.test.js` 新增断言 —— 内联脚本必须可被解析、不得出现被吞掉反斜杠的 `replace(//auth`、不得残留裸服务端标识符(`isEn` / `i18n` / `lang` 等);并用 `node:vm` 假浏览器覆盖「失败响应缺 `reason`」时的提示文案与网关子路径下的 URL 推导。
27
89
 
package/README.md CHANGED
@@ -31,7 +31,7 @@
31
31
  <a href="#about">这是什么</a> •
32
32
  <a href="#advantages">核心优势</a> •
33
33
  <a href="#quick-start">快速开始</a> •
34
- <a href="#config">必备配置</a> •
34
+ <a href="#config">网络配置</a> •
35
35
  <a href="#preview">界面预览</a> •
36
36
  <a href="#features">功能特性</a> •
37
37
  <a href="#install">安装与更新</a> •
@@ -84,23 +84,38 @@ dsh plugin --profile web add dsh-remote-mobile
84
84
  ---
85
85
 
86
86
  <span id="config"></span>
87
- ### 2. 前置配置(开放外部监听)
87
+ ### 2. 前置配置(新装零配置;老用户先迁移)
88
88
 
89
- 由于 DSH 默认仅监听 `127.0.0.1`,为了使 Tailscale 私网或局域网设备能够正常连通,请确保在 `~/.dsh/profiles/web/cordis.patch.yml`(Windows 对应 `C:\Users\<用户名>\.dsh\profiles\web\cordis.patch.yml` 或在资源管理器直接输入 `%USERPROFILE%\.dsh`)中包含以下配置:
89
+ > [!TIP]
90
+ > **🚀 DSH 0.2.1+ 架构升级说明(零配置开箱即用)**:
91
+ > 官方在 DSH 0.2.1+ 中出于安全考虑禁止将底座 `webserver` 直接监听在通配地址 `0.0.0.0` 上。
92
+ > 本插件内置了**智能自适应安全反向代理网关**:
93
+ > - **DSH 0.2.1+(新版,且已删除旧的 `0.0.0.0` 配置)**:**无需再配置 `webserver`**!DSH 保持官方安全的 `127.0.0.1:3080`,插件默认在 `0.0.0.0:3081` 开启独立安全网关。手机扫码默认连接 `3081`(端口被占用时会自动递增,以设置页二维码为准),电脑本机依然使用 `3080`,避免因通配地址校验导致的启动失败。
94
+ > - **DSH 0.1.7 ~ 0.2.0(旧版)**:若 `cordis.patch.yml` 中保留了 `host: '0.0.0.0'`,插件会自动探测并进入 `direct` 模式,保持旧版直连行为;若通过自定义域名/公网 IP 访问,请按下方 `trustedHosts` 配置。
95
+
96
+ > [!IMPORTANT]
97
+ > **⚠️ 老用户升级到 DSH 0.2.1+ 必看**:
98
+ > 如果您之前为了远程访问,曾在 `cordis.patch.yml` 中手动添加了 `webserver.host: '0.0.0.0'`,升级到 DSH 0.2.1+ 后底座会因为官方禁止通配监听而直接抛错崩溃。
99
+ > **请务必先将该行配置删除**,恢复官方默认状态:
100
+ >
101
+ > ```yaml
102
+ > # ❌ 升级 0.2.1+ 后请删除此配置(否则底座会报错并拒绝启动):
103
+ > # - id: webserver
104
+ > # host: '0.0.0.0'
105
+ >
106
+ > # ✅ 正确姿势:完全无需配置 webserver,底座自然监听 127.0.0.1:3080。
107
+ > # 本插件会自动启动 0.0.0.0:3081 独立网关转发,手机访问 3081 即可。
108
+ > ```
109
+ >
110
+ > **安全权衡说明**:网关默认监听 `0.0.0.0:3081`(暂不含 TLS)。若在公共 Wi-Fi 或未经保护的网络下使用,强烈建议配合 Tailscale(WireGuard 隧道天然加密)或通过自定义高强度长期密码与 IP 锁定机制保障安全。
111
+
112
+ 如果您安装了其他可能产生冲突的插件,推荐在 `~/.dsh/profiles/web/cordis.patch.yml`(Windows 对应 `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml`)中禁用冲突项:
90
113
 
91
114
  ```yaml
92
- # 1. 允许 webserver 监听外部网络连接(必选)
93
- - id: webserver
94
- name: '@deepseek-ai/dsh-host-webserver'
95
- inject: [webStartup]
96
- config:
97
- host: '0.0.0.0'
98
- port: 3080
99
-
100
- # 2. (推荐)只保留一款远程/Web 接入类插件,避免功能重复与入口混乱
101
- # 本插件与其他远程接入类插件共存时也能正常启动:检测到配对共享服务被占用
102
- # 会自动让出,并在设置页顶部展示警示横幅说明详情。
103
- # 示例:禁用 @linxin666/dsh-web-ui-all 内置的远程插件
115
+ # (推荐)只保留一款远程/Web 接入类插件,避免功能重复与入口混乱
116
+ # 本插件与其他远程接入类插件共存时也能正常启动:检测到配对共享服务被占用
117
+ # 会自动让出,并在设置页顶部展示警示横幅说明详情。
118
+ # 示例:禁用 @linxin666/dsh-web-ui-all 内置的远程插件
104
119
  - id: web-ui-remote-web-ui
105
120
  disabled: true
106
121
  ```
@@ -171,9 +186,9 @@ dsh web --no-open
171
186
  - 支持管理员在管理面板中一键解锁指定 IP。
172
187
  - **智能静态资源放行机制**:安全门禁内置智能前端静态扩展名识别(放行 `.js`、`.css`、`.png`、`.svg`、`.woff2` 等 20 种合法资源),拦截无扩展名或动态管理 API 请求(如 `/plugins/xxx/admin`),确保第三方生态插件前端正常展示的同时严密保护后台动态接口,无需用户在设置界面手动维护白名单。(DSH 核心静态目录 `/assets/` 属官方资源,单独无条件放行。)
173
188
  - **高可靠原子持久化与防抖**:采用毫秒级写盘防抖节流保护磁盘 I/O(会话数据 500ms、样式片段 300ms),配合 `beforeExit` 进程退出 Flush 保证数据不丢失;`settings.json` 与 `style-snippets.json` 采用临时文件原子替换(`renameSync`),防止异常中断损坏数据。
174
- - **真实 IP 安全提取**:仅信任底层 Socket 真实连接地址,防范伪造的 `X-Forwarded-For` 欺骗攻击。
175
- - **本机回环 CSRF 防御**:插件的变更类 API 会校验浏览器同源信号(`Origin` / `Sec-Fetch-Site`),恶意网页驱使浏览器向 `127.0.0.1` 发起的跨站写请求将被直接拒绝;curl / 本机脚本等无浏览器信号的客户端不受影响。
176
- - **本机回环请求不做上下文改写**:上下文虚拟化仅作用于外部来源流量;本机回环请求保留原始 Host / Origin(不"洗白"),使 DSH 自带的 DNS-Rebinding 与同源校验对外部域名继续生效。SSE 事件流仅限同源连接,不开放跨域读取。
189
+ - **真实 IP 安全提取**:默认仅信任底层 Socket 真实连接地址;当请求被识别为受信反向代理(Host / `X-Forwarded-*`)时,才采用 `X-Forwarded-For` 首个合法 IP,兼顾真实来源与防伪造。
190
+ - **跨站写请求防御**:所有非 GET/HEAD 请求都会校验浏览器同源信号(`Origin` / `Sec-Fetch-Site`),恶意网页驱使浏览器向 `127.0.0.1` 发起的跨站写请求会被直接拒绝;curl / 本机脚本等无浏览器信号的客户端不受影响。注意:该策略也会拦截第三方插件的跨域 webhook / OAuth callback,以及发送 `Origin: null` 的旧 WebView;使用此类集成时请先做兼容性验证。
191
+ - **上下文化与反代边界**:同源/本地变体 Host、Origin 成对规范化;恶意跨站 Origin 原样保留供下游 CSRF 检查。同机 127 反代通过外部 Host、LAN/Tailscale 反代通过 `X-Forwarded-*` 识别为代理流量,不再被误判为免密客户端,必须通过插件 Token 鉴权;受信 Gateway 通过内部 Secret 例外。插件自身为 loopback 请求注入的 127 Forwarded 头会被识别为内部头,不会误伤本机 3080 根路径的 connection 鉴权。SSE 事件流仅限同源连接,不开放跨域读取。
177
192
 
178
193
  ### 4. 实时状态同步与国际化
179
194
  - **SSE 实时事件推送**:基于 Server-Sent Events 实现新设备接入、设备重连、会话撤销及安全告警的实时通知,内置连接双向关闭监听与幂等清理。
@@ -296,10 +311,26 @@ dsh plugin --profile web remove dsh-remote-mobile
296
311
  "secretHash": "", // string,默认空:长期访问密码的 scrypt 加盐哈希值
297
312
  "maxVisitsPerMinute": 60, // number,默认 60:单 IP 每分钟最大访问登录页次数
298
313
  "maxFailedAttempts": 5, // number,默认 5:触发封禁的连续认证失败最大次数
299
- "lockDurationMs": 900000 // number,默认 900000 (15分钟):IP 锁定持续时间(毫秒)
314
+ "lockDurationMs": 900000, // number,默认 900000 (15分钟):IP 锁定持续时间(毫秒)
315
+ "gatewayPort": 3081, // number,默认 3081:独立安全网关端口
316
+ "gatewayHost": "0.0.0.0", // string,默认 0.0.0.0:独立安全网关监听地址
317
+ "mode": "auto", // auto | gateway | direct:auto 自适应(推荐)
318
+ "trustedHosts": [ // string[],默认 []:受信任域名白名单(如 Tailscale MagicDNS 或自定义反代域名)
319
+ "my-device.tailnet.ts.net"
320
+ ]
300
321
  }
301
322
  ```
302
323
 
324
+ > **关于 `trustedHosts` 受信任域名**:若您通过 Tailscale MagicDNS 域名(如 `xxx.tailnet.ts.net`)或外部反向代理域名访问 DSH,请在该数组中添加裸域名。网关与底座会自动将其纳入受信任白名单,防止被网关 Host 检查或第三方插件安全围栏拦截(报 400 或 403)。该白名单同样适用于 **direct 模式**:通过自定义域名、公网 IP 或非本机网卡地址访问时,必须把实际 Host 加入 `trustedHosts`;通过本机网卡 IP / 回环访问会自动识别。
325
+ >
326
+ > **`gatewayPort` / `gatewayHost` / `mode` 说明**:通常保持默认即可。`mode: 'gateway'` 要求底座可回环访问;强制指定**具体 LAN IP** 时会回退 `direct` 并输出警告;指定 `0.0.0.0` / `::` 会启动网关,但会明确提示核心 3080 仍处于暴露状态。修改这些高级配置后需重启 DSH。
327
+ >
328
+ > **反向代理 HTTPS 注意**:若通过 Nginx/Caddy/Tailscale Serve 等反代以 HTTPS 访问,请确保:
329
+ > 1. **保留外部 Host**(Nginx 使用 `proxy_set_header Host $host`,不要把 Host 改写成 `127.0.0.1`);
330
+ > 2. 透传 `X-Forwarded-For`、`X-Forwarded-Host`、`X-Forwarded-Proto: https`(Caddy/Tailscale 通常自动处理)。
331
+ >
332
+ > 插件会用这些头做协议/来源同源校验与 302 重定向;当反代携带 Forwarded 头并被识别为代理时,即使位于 LAN/Tailscale 网段也会禁用 `allowLan`/`allowTailscale` 免密,改为正常 Token 鉴权。若反代把 Host 改写为 `127.0.0.1` 且不发送任何 `X-Forwarded-*` 头,插件无法区分“本机浏览器”和“同机反代”;LAN/Tailscale 反代若缺少这些头,也可能被误判为真实局域网客户端,请务必按上述配置修正。
333
+
303
334
  > **关于配置存储**:DSH 自 **0.1.7** 起(最早见于 v0.1.7-alpha.1 的 release notes,0.2.x 延续)官方已弃用 `~/.dsh/settings.yaml` 作为**实时配置源**——该文件会在启动时被一次性迁移进当前 profile 的 `cordis.patch.yml`,并改名为 `settings.yaml.imported`(详见 [Q8](#faq))。因此本插件不再写入 settings.yaml,而是持久化到上述自有 `settings.json`;首次启动时会自动从旧 `settings.yaml` / `settings.yaml.imported` 迁移一次(把旧长期密码、限频等配置救回)。
304
335
 
305
336
  ### 🎨 移动端样式片段(可选)
@@ -349,18 +380,18 @@ dsh plugin --profile web remove dsh-remote-mobile
349
380
  ## ❓ 常见问题 (FAQ)
350
381
 
351
382
  <details>
352
- <summary><b>Q0: 启动报错 <code>listen EADDRINUSE: address already in use 0.0.0.0:3080</code>?</b></summary>
383
+ <summary><b>Q0: 启动报错 <code>listen EADDRINUSE: address already in use 0.0.0.0:3080 / :3081</code>?</b></summary>
353
384
 
354
- **答**:这是 **3080 端口被其他进程占用**(绝大多数是上一个 `dsh web` 实例尚未完全退出,例如桌面快捷方式拉起的隐藏实例、或重启时新旧进程交叠),**与安装了哪个插件无关**。排查步骤:
385
+ **答**:这是 **3080(DSH 核心)或 3081(插件网关)端口被其他进程占用**(绝大多数是上一个 `dsh web` 实例尚未完全退出,例如桌面快捷方式拉起的隐藏实例、或重启时新旧进程交叠),**与安装了哪个插件无关**。排查步骤:
355
386
 
356
387
  1. **查找占用端口的进程**:
357
- * **macOS / Linux**:`lsof -nP -iTCP:3080`
358
- * **Windows**:`netstat -ano | findstr 3080`(最后一列为 PID)
388
+ * **macOS / Linux**:`lsof -nP -iTCP:3080` / `lsof -nP -iTCP:3081`
389
+ * **Windows**:`netstat -ano | findstr 3080` / `netstat -ano | findstr 3081`(最后一列为 PID)
359
390
  2. **结束旧实例**:
360
391
  * **macOS / Linux**:`kill <PID>`
361
392
  * **Windows**:`taskkill /F /PID <PID>`(或在任务管理器中结束对应 Node 进程)
362
393
  3. 重新启动 `dsh web` 即可。
363
- 4. **注意**:桌面快捷方式(dsh-desktop-launcher 生成的后台实例)与终端手动启动的实例不能同时运行。
394
+ 4. **注意**:桌面快捷方式(dsh-desktop-launcher 生成的后台实例)与终端手动启动的实例不能同时运行。若只是 3081 被占用,插件默认会自动递增尝试下一个端口(最多 10 个),以设置页二维码/终端 Banner 显示的实际端口为准;3080 被占用通常需要先结束旧 DSH 实例。
364
395
 
365
396
  另:若日志显示的是 `service "remoteWebUiPairing" has been registered` 类错误,请升级本插件至最新版本——新版已内置通用共存保护,与其他远程接入类插件并存时可正常启动并自动让出服务,并在设置面板顶部提示冲突详情。
366
397
  </details>
@@ -369,16 +400,16 @@ dsh plugin --profile web remove dsh-remote-mobile
369
400
  <summary><b>Q1: 手机扫码后提示「连接被拒绝」或无法打开页面?</b></summary>
370
401
 
371
402
  **答**:请按以下步骤逐一排查:
372
- 1. **配置检查**:确保电脑上的 `cordis.patch.yml` 中已配置 `host: '0.0.0.0'`,且 DSH 已经重启;
373
- 2. **同一局域网**:局域网访问时,确保手机与电脑连接在同一个 Wi-Fi / 路由器下,且未开启手机端的独立代理/VPN(避免流量绕过局域网);
403
+ 1. **配置检查**:DSH 0.2.1+ **不要**在 `cordis.patch.yml` 中配置 `host: '0.0.0.0'`(会启动失败);保持 DSH 默认的 `127.0.0.1:3080`,插件会自动启动 `3081` 网关。若你是旧版 DSH(0.1.7 ~ 0.2.0,可使用 `0.0.0.0`)或显式使用 `direct` 模式(需具体网卡 IP),才需要配置监听地址。修改配置后必须重启 DSH。
404
+ 2. **同一局域网**:局域网访问时,确保手机与电脑连接在同一个 Wi-Fi / 路由器下,且未开启手机端的独立代理/VPN(避免流量绕过局域网)。
374
405
  3. **Windows 防火墙排查(Windows 用户常见原因)**:
375
406
  * 启动 DSH 时如弹出「Windows Defender 防火墙」警报,请务必勾选 **「专用网络」** 和 **「公用网络」** 并允许访问;
376
- * 若错过弹窗或依然无法连通,可使用**管理员权限打开 PowerShell** 执行以下命令一键放行端口:
407
+ * 若错过弹窗或依然无法连通,可使用**管理员权限打开 PowerShell** 放行网关端口(默认 `3081`;若使用 `direct` 模式则放行核心端口 `3080`):
377
408
  ```powershell
378
- New-NetFirewallRule -DisplayName "DSH Web 3080" -Direction Inbound -LocalPort 3080 -Protocol TCP -Action Allow
409
+ New-NetFirewallRule -DisplayName "DSH Remote Gateway 3081" -Direction Inbound -LocalPort 3081 -Protocol TCP -Action Allow
379
410
  ```
380
- * **检查 Wi-Fi 网络类型**:在 Windows「设置 -> 网络和 Internet -> Wi-Fi」中,确保当前 Wi-Fi 网络配置文件类型已设为 **「专用网络」**(若为「公用网络」,Windows 防火墙会默认拦截局域网入站请求);
381
- 4. **多网卡环境(WSL / 虚拟机)**:Windows 如安装了 WSL2 或 Hyper-V,可能会生成虚拟网卡 IP(如 `172.x.x.x`)。请在插件设置面板中确认使用的是物理无线网卡(WLAN / Wi-Fi)对应的局域网 IP 或 Tailscale 二维码。
411
+ * **检查 Wi-Fi 网络类型**:在 Windows「设置 -> 网络和 Internet -> Wi-Fi」中,确保当前 Wi-Fi 网络配置文件类型已设为 **「专用网络」**(若为「公用网络」,Windows 防火墙会默认拦截局域网入站请求)。
412
+ 4. **多网卡环境(WSL / 虚拟机)**:Windows 如安装了 WSL2 或 Hyper-V,可能会生成虚拟网卡 IP(如 `172.x.x.x`)。请在插件设置面板中确认使用的是物理无线网卡(WLAN / Wi-Fi)对应的局域网 IP 或 Tailscale 二维码;若网关端口曾自动递增,以设置页/终端 Banner 显示的实际端口为准。
382
413
  </details>
383
414
 
384
415
  <details>
@@ -436,10 +467,11 @@ dsh plugin --profile web remove dsh-remote-mobile
436
467
  * **设置面板与 SSE 长连接**:基于 `<base>` 动态解析 API 路径,保存配置、生成配对码、事件推送均不会丢失 `/app/dsh` 前缀(旧版本此处会表现为「点击毫无反应」);
437
468
  * **独立手机登录页与 302 重定向**:登录校验、登录成功后的跳转,以及门禁 / `/auth` 的重定向都会保留当前子路径,不再跳回宿主根目录;
438
469
  * **注意**:服务端 302 重定向依赖反向代理透传 `X-Forwarded-Prefix`;若代理未透传该头,重定向将回退到安全默认路径 `/auth`。
439
- 2. **网关回环 (127.0.0.1) 放行机制**:
440
- * 出于严密的安全防护考量(防止外部恶意客户端伪造 `X-Forwarded-For` 请求头绕过认证),插件门禁严格基于底层物理 Socket 连接判断客户端真实来源。
441
- * 当反向代理/宿主网关直接从本机 `127.0.0.1` 代理至 DSH 时,请求会被识别为本地回环连接并直接放行——此时由网关/NAS 宿主自身的登录与权限隔离机制进行安全兜底。
442
- * 若你希望通过外部统一域名/网关访问时仍强制要求输入本插件的动态配对码或长期访问密码,建议配置反向代理透明透传客户端源 IP,或直接通过 DSH 开放的外部端口(如默认的 `3080` 或自定义映射端口)直连访问。
470
+ 2. **反向代理来源识别与鉴权**:
471
+ * 插件默认仅信任底层物理 Socket;**同机 `127.0.0.1` 反代携带外部 Host**,或**任意反代携带 `X-Forwarded-*`** 时,会被识别为**代理流量**,不再走 `loopback` / `allowLan` / `allowTailscale` 免密,必须使用插件 Token(动态配对码或长期密码)鉴权。
472
+ * 请确保反向代理**保留外部 Host**(不要改写为 `127.0.0.1`),并透传 `X-Forwarded-For`、`X-Forwarded-Host`、`X-Forwarded-Proto: https`;Caddy / Tailscale Serve 通常会自动处理,Nginx 需显式配置。若 Host 被改写为 `127.0.0.1` 且没有任何 Forwarded 头,插件无法区分“本机浏览器”与“同机反代”。
473
+ * 若希望外部统一域名访问时仍强制要求输入本插件的配对码/密码,请按上述方式配置反代,让插件能够识别真实来源并执行 Token 鉴权;不要依赖 NAS/宿主网关自身的登录来替代插件认证。
474
+ * 若你不需要网关模式,也可以使用 `direct` 模式绑定具体网卡 IP;但 DSH 0.2.1+ 下不能使用 `0.0.0.0` 通配地址。
443
475
  </details>
444
476
 
445
477
  <details>
@@ -452,6 +484,49 @@ dsh plugin --profile web remove dsh-remote-mobile
452
484
  - **本插件自 v1.9.0 起**已改为把自身配置持久化到 `~/.dsh/remote-mobile/settings.json`,并在首次启动时**自动从旧 `settings.yaml` / `settings.yaml.imported` 迁移一次**(把长期密码哈希、免密开关、限频阈值等救回),不再依赖 DSH 的 settings.yaml。若你曾发现"长期密码失效/限频重置",升级到 v1.9.0 后会自动恢复。
453
485
  </details>
454
486
 
487
+ <details>
488
+ <summary><b>Q9: DSH 0.2.1+ 启动失败:<code>host "0.0.0.0" is an unspecified (wildcard) address</code>?</b></summary>
489
+
490
+ **答**:这是 DSH 0.2.1+ 的官方安全限制,和插件版本无关。该错误发生在插件加载之前,插件网关此时还没有机会接管,因此必须手动处理:
491
+
492
+ 1. 打开 `~/.dsh/profiles/web/cordis.patch.yml`(Windows:`%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml`);
493
+ 2. 删除或注释掉之前为远程访问添加的 `webserver.host: '0.0.0.0'`(如整段 `- id: webserver ... host: '0.0.0.0'` 仅用于该配置,可整段删除);
494
+ 3. 重启 `dsh web --no-open`。DSH 会恢复 `127.0.0.1:3080`,本插件自动启动 `0.0.0.0:3081` 网关,手机按设置页二维码访问即可。
495
+ </details>
496
+
497
+ <details>
498
+ <summary><b>Q10: 访问 <code>http://127.0.0.1:3080/</code> 提示 <code>dsh web authentication required; reopen the URL printed by dsh web</code>,但 3081 正常?</b></summary>
499
+
500
+ **答**:这是 **DSH 官方的 launch-token 鉴权**,不是插件的 `/auth` 登录页。正常情况下,本插件会接管底座 connection 鉴权,让本机回环请求直接放行;如果 3080 根路径出现该提示,而以下两项正常:
501
+
502
+ - `http://127.0.0.1:3080/api/remote-mobile/status` 返回 200;
503
+ - `http://127.0.0.1:3081/` 返回 200;
504
+
505
+ 说明插件已加载、网关已生效,但当前进程里的 connection 鉴权仍未放行本机根路径。常见原因是 **热重载/热更新没有重新加载插件代码**,或运行的是旧构建。
506
+
507
+ 处理步骤:
508
+
509
+ 1. 确认安装的是 **1.10.0+**,然后**完整停止并重新启动** `dsh web`(仅刷新浏览器页面不够);
510
+ 2. 临时可先用 `http://127.0.0.1:3081/`(插件安全网关)访问,或打开 `dsh web` 启动时打印的带 `?token=...` 的 URL 一次,让官方写入 cookie;
511
+ 3. 若使用反向代理,请按 Q7 保留外部 Host 并透传 `X-Forwarded-*`;
512
+ 4. 如果重启后仍然出现,请附上 DSH 版本、插件版本与启动日志反馈。
513
+
514
+ > 1.10.0 已修复“插件自身为 loopback 请求注入的 127 Forwarded 头被误判为反向代理、导致 3080 根路径被官方 401 拦截”的回归;重启后本机 3080 应恢复正常。
515
+ </details>
516
+
517
+ <details>
518
+ <summary><b>Q11: 设备列表里出现很多 <code>127.0.0.1 / 📱 iPhone (Browser)</code>,但我没有用过 iPhone?</b></summary>
519
+
520
+ **答**:这些通常不是真实设备,而是 **1.10.0 之前测试套件写入的假 session**:
521
+
522
+ - `test/gateway.test.js` 曾用 `store.verify(code, 'iPhone', '127.0.0.1')` 做配对测试;
523
+ - 当时的 `SessionStore` 在测试环境只隔离了 `settings.json`,没有隔离 `devices.json`;
524
+ - 每运行一次 `npm test`,就会向真实的 `~/.dsh/remote-mobile/devices.json` 增加一条 `127.0.0.1 / User-Agent: iPhone / 扫码配对码` 的 session;
525
+ - 真实 iPhone Safari 的 User-Agent 会是很长的 `Mozilla/5.0 (iPhone; ...) ... Safari/...`,不会只有 `iPhone`。
526
+
527
+ 1.10.0 已增加测试隔离:测试环境默认使用临时 `/tmp/dsh-rm-test-devices-*.json`,不会再污染真实设备文件。已有的假 session 可以通过“一键撤销所有设备”后重新配对当前设备清理,或停止 DSH 后从 `devices.json` 中删除对应条目;操作前建议先备份该文件。
528
+ </details>
529
+
455
530
  ---
456
531
 
457
532
  ## 📄 开源许可证
package/README_EN.md CHANGED
@@ -31,7 +31,7 @@ English Documentation · [简体中文](./README.md)
31
31
  <a href="#about">About</a> •
32
32
  <a href="#advantages">Key Advantages</a> •
33
33
  <a href="#quick-start">Quick Start</a> •
34
- <a href="#config">Required Config</a> •
34
+ <a href="#config">Configuration</a> •
35
35
  <a href="#preview">UI Preview</a> •
36
36
  <a href="#features">Features</a> •
37
37
  <a href="#install">Installation & Update</a> •
@@ -84,24 +84,39 @@ dsh plugin --profile web add dsh-remote-mobile
84
84
  ---
85
85
 
86
86
  <span id="config"></span>
87
- ### 2. Configuration (Open External Listening)
87
+ ### 2. Configuration (Zero-Config for New Installs; Migration for Existing Users)
88
88
 
89
- Because DSH defaults to `127.0.0.1`, ensure your `~/.dsh/profiles/web/cordis.patch.yml` (on Windows: `C:\Users\<username>\.dsh\profiles\web\cordis.patch.yml` or enter `%USERPROFILE%\.dsh` in File Explorer) includes the following configuration to allow external connectivity:
89
+ > [!TIP]
90
+ > **🚀 DSH 0.2.1+ Architecture Update (Zero Configuration)**:
91
+ > Starting from DSH 0.2.1+, the host `webserver` strictly disallows binding directly to wildcard addresses (`0.0.0.0`) for security.
92
+ > This plugin now includes a built-in **Smart Adaptive Safe Reverse Proxy Gateway**:
93
+ > - **DSH 0.2.1+ (latest, after removing the old `0.0.0.0` config)**: **No `webserver` config is needed**! DSH remains safely bound to `127.0.0.1:3080`, and this plugin launches a standalone secure gateway on `0.0.0.0:3081` by default. Mobile QR codes use `3081` (it auto-increments on port conflicts; always trust the QR shown in settings), while desktop stays on `3080`, avoiding startup failures caused by wildcard-host validation.
94
+ > - **DSH 0.1.7 ~ 0.2.0 (Legacy)**: If `host: '0.0.0.0'` is still present in `cordis.patch.yml`, the plugin detects it and switches to `direct` mode, preserving legacy direct access. For custom domains or public IPs, configure `trustedHosts` below.
95
+
96
+ > [!IMPORTANT]
97
+ > **⚠️ Upgrade Notice for Existing Users Upgrading to DSH 0.2.1+**:
98
+ > If you previously configured `webserver.host: '0.0.0.0'` in `cordis.patch.yml` for remote access, DSH 0.2.1+ will crash on launch due to the official wildcard ban.
99
+ > **Please remove that line from your configuration** to restore default loopback listening:
100
+ >
101
+ > ```yaml
102
+ > # ❌ Remove this line when upgrading to 0.2.1+ (otherwise the core rejects to boot):
103
+ > # - id: webserver
104
+ > # host: '0.0.0.0'
105
+ >
106
+ > # ✅ Correct setup: Leave webserver unconfigured; DSH naturally binds to 127.0.0.1:3080.
107
+ > # This plugin automatically spins up the standalone gateway on 0.0.0.0:3081 for mobile access.
108
+ > ```
109
+ >
110
+ > **Security Considerations**: The gateway binds to `0.0.0.0:3081` by default without built-in TLS. In untrusted or public Wi-Fi networks, using Tailscale (WireGuard encrypted tunnel) or enforcing strong passwords and IP rate-limiting is strongly recommended.
111
+
112
+ If you have installed other plugins that might conflict, we recommend disabling them in `~/.dsh/profiles/web/cordis.patch.yml` (on Windows: `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml`):
90
113
 
91
114
  ```yaml
92
- # 1. Allow webserver to listen for external connections (Required)
93
- - id: webserver
94
- name: '@deepseek-ai/dsh-host-webserver'
95
- inject: [webStartup]
96
- config:
97
- host: '0.0.0.0'
98
- port: 3080
99
-
100
- # 2. (Recommended) Keep only ONE remote/Web access plugin to avoid duplicated entries.
101
- # This plugin starts normally alongside other remote-access plugins: when it
102
- # detects the shared pairing service is already taken by another plugin, it
103
- # yields automatically and shows a warning banner at the top of its settings panel.
104
- # Example: disable the legacy remote plugin bundled in @linxin666/dsh-web-ui-all
115
+ # (Recommended) Keep only ONE remote/Web access plugin to avoid duplicated entries.
116
+ # This plugin starts normally alongside other remote-access plugins: when it
117
+ # detects the shared pairing service is already taken by another plugin, it
118
+ # yields automatically and shows a warning banner at the top of its settings panel.
119
+ # Example: disable the legacy remote plugin bundled in @linxin666/dsh-web-ui-all
105
120
  - id: web-ui-remote-web-ui
106
121
  disabled: true
107
122
  ```
@@ -170,9 +185,9 @@ Open the DSH Web Console in your browser, navigate to **Settings ⚙️ -> Remot
170
185
  - Administrators can manually unlock blocked IPs with one click in the panel.
171
186
  - **Intelligent Static Asset Whitelisting**: The authentication gate automatically allows verified frontend asset extensions (`.js`, `.css`, `.png`, `.svg`, `.woff2`, etc., 20 types) while blocking extensionless or dynamic API routes (e.g. `/plugins/xxx/admin`), ensuring smooth third-party plugin rendering without manual whitelist configuration. (The DSH core static directory `/assets/` is an official resource and is allowed unconditionally.)
172
187
  - **Resilient Atomic Persistence & Debounce**: Millisecond-level debounce write-throttling to safeguard disk I/O (500ms for session data, 300ms for style snippets), coupled with `beforeExit` process flush hooks and atomic temporary file replacement (`renameSync`) for `settings.json` and `style-snippets.json`.
173
- - **Real Socket IP Extraction**: Relies strictly on underlying Socket connection addresses, preventing spoofed `X-Forwarded-For` header attacks.
174
- - **Loopback CSRF Defense**: Mutating plugin APIs validate browser same-origin signals (`Origin` / `Sec-Fetch-Site`). Cross-site write requests to `127.0.0.1` driven by malicious webpages are rejected outright; non-browser clients (curl / local scripts) are unaffected.
175
- - **No Context Rewriting for Loopback Requests**: Context virtualization applies only to external traffic. Loopback requests keep their original Host / Origin (no "laundering"), so DSH's built-in DNS-rebinding and same-origin checks keep working against external domains. The SSE event stream accepts same-origin connections only, with no cross-origin reads.
188
+ - **Source IP Extraction**: By default only the underlying Socket address is trusted; when a request is recognized as a trusted reverse proxy (via Host / `X-Forwarded-*`), the first valid `X-Forwarded-For` IP is used instead, balancing real-source attribution with anti-spoofing.
189
+ - **Cross-Site Write Defense**: All non-GET/HEAD requests validate browser same-origin signals (`Origin` / `Sec-Fetch-Site`). Cross-site writes driven by malicious webpages against `127.0.0.1` are rejected outright; non-browser clients (curl / local scripts) are unaffected. Note: this policy also blocks cross-origin webhooks / OAuth callbacks from third-party plugins and legacy WebViews that send `Origin: null`; validate compatibility before using such integrations.
190
+ - **Context & Proxy Boundary**: Same-origin/local-variant Host and Origin are normalized as a pair, while malicious cross-site Origins are preserved for downstream CSRF checks. A same-host `127.0.0.1` proxy is recognized via an external Host, and a LAN/Tailscale proxy via `X-Forwarded-*`, so neither is mistaken for a passwordless local client; they must authenticate with the plugin token. The plugin's own trusted gateway is exempted via an internal secret. Loopback requests carrying the plugin's own 127-valued Forwarded headers are recognized as internal and do not break the local 3080 root connection auth. The SSE event stream accepts same-origin connections only, with no cross-origin reads.
176
191
 
177
192
  ### 4. Real-Time Sync & Internationalization
178
193
  - **SSE Real-Time Push**: Push notifications for new device pairing, reconnections, revocations, and security alerts via Server-Sent Events, with bidirectional close listeners and idempotent cleanup.
@@ -295,10 +310,26 @@ Configurations persist in the plugin-owned file `~/.dsh/remote-mobile/settings.j
295
310
  "secretHash": "", // string, default empty: scrypt salted hash of the persistent password
296
311
  "maxVisitsPerMinute": 60, // number, default 60: max login page visits per minute per IP
297
312
  "maxFailedAttempts": 5, // number, default 5: max consecutive failed attempts before IP lockout
298
- "lockDurationMs": 900000 // number, default 900000 (15 mins): IP lockout duration in ms
313
+ "lockDurationMs": 900000, // number, default 900000 (15 mins): IP lockout duration in ms
314
+ "gatewayPort": 3081, // number, default 3081: standalone secure gateway port
315
+ "gatewayHost": "0.0.0.0", // string, default 0.0.0.0: gateway listen address
316
+ "mode": "auto", // auto | gateway | direct: auto is recommended
317
+ "trustedHosts": [ // string[], default []: trusted domain whitelist (e.g. Tailscale MagicDNS or reverse proxy domain)
318
+ "my-device.tailnet.ts.net"
319
+ ]
299
320
  }
300
321
  ```
301
322
 
323
+ > **About `trustedHosts`**: If accessing DSH via Tailscale MagicDNS (e.g. `xxx.tailnet.ts.net`) or an external reverse proxy domain, add bare hostnames to this array. Both the gateway and core will accept them in the host whitelist and plugin trust fences, preventing 400 or 403 errors. The same whitelist applies to **direct mode**: when accessing via a custom domain, public IP, or a non-local hostname, the actual Host must be listed here; local NIC IPs and loopback are recognized automatically.
324
+ >
325
+ > **About `gatewayPort` / `gatewayHost` / `mode`**: defaults are usually sufficient. `mode: 'gateway'` requires the core to be reachable via loopback; forcing a **concrete LAN IP** falls back to `direct` with a warning; using `0.0.0.0` / `::` starts the gateway but explicitly warns that core port 3080 remains exposed. Restart DSH after changing these advanced options.
326
+ >
327
+ > **HTTPS reverse proxy note**: When using Nginx/Caddy/Tailscale Serve with HTTPS, make sure to:
328
+ > 1. **Preserve the external Host** (Nginx: `proxy_set_header Host $host`; do not rewrite it to `127.0.0.1`);
329
+ > 2. Forward `X-Forwarded-For`, `X-Forwarded-Host`, and `X-Forwarded-Proto: https` (Caddy/Tailscale usually do this automatically).
330
+ >
331
+ > The plugin uses these headers for protocol/source-aware same-origin checks and 302 redirects. When a proxy carries Forwarded headers and is recognized as proxied traffic, `allowLan`/`allowTailscale` bypass is disabled even if the proxy sits on a LAN/Tailscale address, so normal token auth applies. If a proxy rewrites Host to `127.0.0.1` and sends no `X-Forwarded-*` headers, the plugin cannot distinguish a local browser from a same-host proxy; a LAN/Tailscale proxy without these headers may also be mistaken for a real LAN client. Configure it as above.
332
+
302
333
  > **About config storage**: Since DSH **0.1.7** (first noted in the v0.1.7-alpha.1 release notes; continued in 0.2.x), `~/.dsh/settings.yaml` is no longer a live configuration source — on boot it is imported once into the active profile's `cordis.patch.yml` and renamed to `settings.yaml.imported` (see [Q8](#faq)). This plugin therefore no longer writes to settings.yaml and persists to its own `settings.json` above; on first launch it automatically migrates once from the legacy `settings.yaml` / `settings.yaml.imported` (recovering your old persistent password, rate limits, etc.).
303
334
 
304
335
  ### 🎨 Mobile Style Snippets (optional)
@@ -348,18 +379,18 @@ Custom snippets (style mini-plugins) and their enabled states are persisted in `
348
379
  ## ❓ FAQ
349
380
 
350
381
  <details>
351
- <summary><b>Q0: Startup fails with <code>listen EADDRINUSE: address already in use 0.0.0.0:3080</code>?</b></summary>
382
+ <summary><b>Q0: Startup fails with <code>listen EADDRINUSE: address already in use 0.0.0.0:3080 / :3081</code>?</b></summary>
352
383
 
353
- **Answer**: This means **port 3080 is held by another process** (in most cases a previous `dsh web` instance has not fully exited — e.g. a hidden instance launched by a desktop shortcut, or overlapping old/new processes during restart). It is **unrelated to which plugins are installed**. Troubleshooting:
384
+ **Answer**: This means **port 3080 (DSH core) or 3081 (plugin gateway) is held by another process** (in most cases a previous `dsh web` instance has not fully exited — e.g. a hidden instance launched by a desktop shortcut, or overlapping old/new processes during restart). It is **unrelated to which plugins are installed**. Troubleshooting:
354
385
 
355
386
  1. **Find the process holding the port**:
356
- * **macOS / Linux**: `lsof -nP -iTCP:3080`
357
- * **Windows**: `netstat -ano | findstr 3080` (PID is in the rightmost column)
387
+ * **macOS / Linux**: `lsof -nP -iTCP:3080` / `lsof -nP -iTCP:3081`
388
+ * **Windows**: `netstat -ano | findstr 3080` / `netstat -ano | findstr 3081` (PID is in the rightmost column)
358
389
  2. **Terminate the old instance**:
359
390
  * **macOS / Linux**: `kill <PID>`
360
391
  * **Windows**: `taskkill /F /PID <PID>` (or kill the Node process in Task Manager)
361
392
  3. Start `dsh web` again.
362
- 4. **Note**: An instance launched via the desktop shortcut (dsh-desktop-launcher) and one started manually in a terminal cannot run simultaneously.
393
+ 4. **Note**: An instance launched via the desktop shortcut (dsh-desktop-launcher) and one started manually in a terminal cannot run simultaneously. If only 3081 is occupied, the plugin automatically probes the next ports (up to 10) by default; use the actual port shown in the settings QR code / terminal banner. A 3080 conflict usually requires terminating the old DSH instance.
363
394
 
364
395
  If your log instead shows `service "remoteWebUiPairing" has been registered`, please upgrade this plugin to the latest version — newer versions ship with generic coexistence protection and can start normally alongside other remote-access plugins by yielding the service automatically, with a banner explaining the details in the settings panel.
365
396
  </details>
@@ -368,16 +399,16 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
368
399
  <summary><b>Q1: Getting "Connection Refused" or cannot open page when scanning on mobile?</b></summary>
369
400
 
370
401
  **Answer**: Please verify the following items step by step:
371
- 1. **Configuration**: Ensure `host: '0.0.0.0'` is properly configured in `cordis.patch.yml` and DSH has been restarted;
372
- 2. **Same Network**: For LAN access, ensure your mobile phone and computer are connected to the exact same Wi-Fi router, and disable standalone VPN/proxy apps on your phone;
402
+ 1. **Configuration**: On DSH 0.2.1+, do **not** set `host: '0.0.0.0'` in `cordis.patch.yml` (it will fail to start). Keep DSH's default `127.0.0.1:3080`; the plugin starts the `3081` gateway automatically. Only legacy DSH (0.1.7 ~ 0.2.0, where `0.0.0.0` is still allowed) or an explicit `direct` mode (concrete NIC IP) needs a listen address. Restart DSH after any config change.
403
+ 2. **Same Network**: For LAN access, ensure your mobile phone and computer are connected to the exact same Wi-Fi router, and disable standalone VPN/proxy apps on your phone.
373
404
  3. **Windows Defender Firewall (Common for Windows users)**:
374
405
  * When prompted by the Windows Firewall popup on startup, ensure both **"Private networks"** and **"Public networks"** are checked before allowing access;
375
- * If you missed the prompt or still cannot connect, run the following command in **PowerShell as Administrator** to allow inbound traffic on port 3080:
406
+ * If you missed the prompt or still cannot connect, run the following command in **PowerShell as Administrator** to allow the gateway port (default `3081`; use `3080` if you are in `direct` mode):
376
407
  ```powershell
377
- New-NetFirewallRule -DisplayName "DSH Web 3080" -Direction Inbound -LocalPort 3080 -Protocol TCP -Action Allow
408
+ New-NetFirewallRule -DisplayName "DSH Remote Gateway 3081" -Direction Inbound -LocalPort 3081 -Protocol TCP -Action Allow
378
409
  ```
379
410
  * **Check Wi-Fi Network Profile**: Under Windows "Settings -> Network & Internet -> Wi-Fi", make sure your current Wi-Fi profile is set to **"Private network"** (Windows "Public network" blocks inbound LAN connections by default);
380
- 4. **Virtual / WSL Network Adapters**: If WSL2 or Hyper-V is installed on Windows, virtual network adapters (e.g. `172.x.x.x`) may be present. Make sure you use the physical WLAN / Wi-Fi LAN IP or the Tailscale QR code shown in the plugin settings panel.
411
+ 4. **Virtual / WSL Network Adapters**: If WSL2 or Hyper-V is installed on Windows, virtual network adapters (e.g. `172.x.x.x`) may be present. Make sure you use the physical WLAN / Wi-Fi LAN IP or the Tailscale QR code shown in the plugin settings panel. If the gateway port auto-incremented, use the actual port shown in the settings panel / terminal banner.
381
412
  </details>
382
413
 
383
414
  <details>
@@ -388,6 +419,12 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
388
419
  * **LAN Bypass**: Inherent risks. Anyone connected to your Wi-Fi (including guests or unauthorized devices) can control your workspace. **Never enable this on untrusted networks**.
389
420
  </details>
390
421
 
422
+ <details>
423
+ <summary><b>Q3: Does mobile support creating and switching workspaces normally?</b></summary>
424
+
425
+ **Answer**: **Supported.** The plugin uses request isolation so that mobile devices get the same workspace management and execution capabilities as the desktop Web interface.
426
+ </details>
427
+
391
428
  <details>
392
429
  <summary><b>Q4: Not happy with the mobile styles and want full control?</b></summary>
393
430
 
@@ -405,12 +442,6 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
405
442
  3. The banner can be temporarily dismissed with ✕; it disappears after a refresh once the conflict is resolved.
406
443
  </details>
407
444
 
408
- <details>
409
- <summary><b>Q3: Does mobile support creating and switching workspaces normally?</b></summary>
410
-
411
- **Answer**: **Supported.** The plugin uses request isolation so that mobile devices get the same workspace management and execution capabilities as the desktop Web interface.
412
- </details>
413
-
414
445
  <details>
415
446
  <summary><b>Q6: Why is installing standalone plugins recommended over monolithic all-in-one packages (e.g. @linxin666/dsh-web-all)?</b></summary>
416
447
 
@@ -435,10 +466,11 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
435
466
  * **Settings panel & SSE live connection**: API paths are resolved dynamically from `<base>`, so saving config, generating pairing codes, and receiving live events never lose the `/app/dsh` prefix (on earlier versions this showed up as “clicking does nothing”);
436
467
  * **Standalone mobile login page & 302 redirects**: login verification, post-login navigation, and the gate / `/auth` redirects all stay within the current sub-path instead of jumping out to the host root;
437
468
  * **Note**: server-side 302 redirects depend on the reverse proxy forwarding `X-Forwarded-Prefix`; if it is not forwarded, redirects fall back to the safe default path `/auth`.
438
- 2. **Gateway Loopback (127.0.0.1) Bypass Mechanism**:
439
- * For rigorous security (preventing external attackers from forging `X-Forwarded-For` headers to bypass authentication), the plugin security gate strictly evaluates client authenticity based on the physical socket connection.
440
- * When a reverse proxy / host gateway relays traffic directly from localhost `127.0.0.1` to DSH, the request is identified as a local loopback connection and allowed without pairing code prompts—backed by the NAS/host gateway's own authentication and permission boundary.
441
- * If you wish to enforce the plugin's pairing code or persistent password even when entering through an external reverse proxy, configure the proxy to transparently pass client IPs or connect directly via the exposed DSH listening port (e.g. default port `3080` or custom mapped ports).
469
+ 2. **Reverse Proxy Source Detection & Authentication**:
470
+ * By default the plugin trusts the physical socket only. A **same-host `127.0.0.1` proxy carrying an external Host**, or **any proxy carrying `X-Forwarded-*`**, is recognized as **proxied traffic** and no longer receives `loopback` / `allowLan` / `allowTailscale` passwordless access; it must authenticate with the plugin token (pairing code or persistent password).
471
+ * Make sure the reverse proxy **preserves the external Host** (do not rewrite it to `127.0.0.1`) and forwards `X-Forwarded-For`, `X-Forwarded-Host`, and `X-Forwarded-Proto: https`. Caddy / Tailscale Serve usually handle this automatically; Nginx requires explicit configuration. If Host is rewritten to `127.0.0.1` with no Forwarded headers, the plugin cannot distinguish a local browser from a same-host proxy.
472
+ * If you want the plugin pairing code/password enforced even through an external unified domain, configure the proxy as above so the plugin can identify the real source and apply token auth. Do not rely on the NAS/host gateway login as a replacement for plugin authentication.
473
+ * If you do not need gateway mode, you may use `direct` mode with a concrete NIC IP; on DSH 0.2.1+ a wildcard `0.0.0.0` is not allowed.
442
474
  </details>
443
475
 
444
476
  <details>
@@ -451,6 +483,49 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
451
483
  - **As of v1.9.0**, this plugin persists its own config to `~/.dsh/remote-mobile/settings.json` and, on first launch, **migrates once from the legacy `settings.yaml` / `settings.yaml.imported`** (recovering the persistent-password hash, bypass toggles, rate limits, etc.). It no longer depends on DSH's settings.yaml. If you saw a "persistent password stopped working / limits reset" symptom, upgrading to v1.9.0 restores it automatically.
452
484
  </details>
453
485
 
486
+ <details>
487
+ <summary><b>Q9: DSH 0.2.1+ fails to start with <code>host "0.0.0.0" is an unspecified (wildcard) address</code>?</b></summary>
488
+
489
+ **Answer**: This is DSH 0.2.1+'s official security restriction, not a plugin issue. The error happens before the plugin loads, so the gateway cannot take over yet; you must fix it manually:
490
+
491
+ 1. Open `~/.dsh/profiles/web/cordis.patch.yml` (Windows: `%USERPROFILE%\.dsh\profiles\web\cordis.patch.yml`);
492
+ 2. Remove or comment out the `webserver.host: '0.0.0.0'` line added previously for remote access (if the whole `- id: webserver ... host: '0.0.0.0'` block exists only for this, remove the block);
493
+ 3. Restart `dsh web --no-open`. DSH returns to `127.0.0.1:3080`, this plugin starts the `0.0.0.0:3081` gateway automatically, and mobile devices can scan the QR code from the settings panel.
494
+ </details>
495
+
496
+ <details>
497
+ <summary><b>Q10: <code>http://127.0.0.1:3080/</code> shows <code>dsh web authentication required; reopen the URL printed by dsh web</code>, but 3081 works?</b></summary>
498
+
499
+ **Answer**: This is **DSH's official launch-token authentication**, not the plugin's `/auth` page. Normally the plugin takes over the host `connection` auth so loopback requests are allowed. If the 3080 root shows this message while both of the following work:
500
+
501
+ - `http://127.0.0.1:3080/api/remote-mobile/status` returns 200;
502
+ - `http://127.0.0.1:3081/` returns 200;
503
+
504
+ then the plugin is loaded and the gateway is active, but the current process's connection auth is still not allowing the local root request. The common cause is that a **hot reload/hot update did not reload the plugin code**, or an older build is running.
505
+
506
+ Steps:
507
+
508
+ 1. Confirm the installed plugin is **1.10.0+**, then **fully stop and restart** `dsh web` (refreshing the browser is not enough);
509
+ 2. As a temporary workaround, use `http://127.0.0.1:3081/` (the plugin gateway), or open the `?token=...` URL printed by `dsh web` once to set the official cookie;
510
+ 3. If using a reverse proxy, preserve the external Host and forward `X-Forwarded-*` as described in Q7;
511
+ 4. If it persists after restart, report the DSH version, plugin version, and startup logs.
512
+
513
+ > 1.10.0 fixes the regression where loopback requests received plugin-injected 127 Forwarded headers that were mistaken for a reverse proxy, causing the 3080 root to be rejected by DSH's official 401. After restart, the local 3080 root should work again.
514
+ </details>
515
+
516
+ <details>
517
+ <summary><b>Q11: The device list contains many <code>127.0.0.1 / 📱 iPhone (Browser)</code> entries, but I never used an iPhone?</b></summary>
518
+
519
+ **Answer**: These are usually not real devices, but **fake sessions written by the test suite before 1.10.0**:
520
+
521
+ - `test/gateway.test.js` used `store.verify(code, 'iPhone', '127.0.0.1')` for pairing tests;
522
+ - the old `SessionStore` only isolated `settings.json` in test environments, not `devices.json`;
523
+ - every `npm test` run added a `127.0.0.1 / User-Agent: iPhone / pairing-code` session to the real `~/.dsh/remote-mobile/devices.json`;
524
+ - a real iPhone Safari User-Agent is a long `Mozilla/5.0 (iPhone; ...) ... Safari/...` string, not just `iPhone`.
525
+
526
+ 1.10.0 adds test isolation: tests now use a temporary `/tmp/dsh-rm-test-devices-*.json` by default, so they no longer pollute the real device file. Existing fake sessions can be removed with "Revoke all devices" followed by re-pairing the current devices, or by stopping DSH and removing the matching entries from `devices.json` after taking a backup.
527
+ </details>
528
+
454
529
  ---
455
530
 
456
531
  ## 📄 License
@@ -85,7 +85,9 @@ export function decryptWithPrivateKey(encryptedBase64) {
85
85
  return decrypted.toString('utf8');
86
86
  }
87
87
  catch { }
88
- // 3. 尝试 PKCS#1 v1.5 填充兼容模式
88
+ // 3. 尝试 PKCS#1 v1.5 填充兼容模式(legacy best-effort)。
89
+ // Node 20+/OpenSSL 3 因 CVE-2023-46809 可能默认禁用该填充的私钥解密,
90
+ // 此时仅现代 WebCrypto 的 OAEP 路径可用;不要在服务端强制启用不安全的 security-revert。
89
91
  try {
90
92
  const decrypted = privateDecrypt({
91
93
  key: pair.privateKey,