dsh-remote-mobile 1.7.0 → 1.8.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 +16 -0
- package/README.md +58 -33
- package/README_EN.md +59 -34
- package/lib/auth/gate.js +3 -1
- package/lib/auth/prefix.d.ts +19 -0
- package/lib/auth/prefix.js +41 -0
- package/lib/client.js +93 -67
- package/lib/routes/api.js +4 -2
- package/lib/routes/login-page.js +36 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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.8.0] - 2026-10-08
|
|
9
|
+
|
|
10
|
+
### Fixed & Security (适配 NAS / 网关子路径前缀反代,修复静默失败与开放重定向)
|
|
11
|
+
|
|
12
|
+
- **子路径 / 反向代理前缀自适应**:
|
|
13
|
+
- **前端设置面板与 SSE 长连接**:新增基于 `document.baseURI` 动态解析的 API 基础路径 `RM_API_BASE`,替换原先硬编码的 15 处 `/api/remote-mobile/...` 绝对路径。经统一网关子路径(如飞牛 fnOS `/app/dsh/`)反代时,请求不再丢失前缀,修复「点保存 / 生成配对码毫无反应」的静默失败;
|
|
14
|
+
- **独立手机登录页**:`verifyApiUrl` 与 `successRedirectUrl` 改为按当前路径与 `<base>` 动态推导,登录校验及登录成功后的跳转均保持在网关子路径内;
|
|
15
|
+
- **服务端 302 重定向**:新增 `resolveRedirectPath` / `resolveForwardedPrefix` 工具,使安全门禁(`gate.ts`)与 `/auth` 路由的重定向自动拼接 `X-Forwarded-Prefix`,不再跳出到宿主根路径。
|
|
16
|
+
- **错误处理与反馈**:
|
|
17
|
+
- **请求层容错**:封装 `safeFetchJson`,统一捕获非 2xx、HTML 错误页等异常响应并透传服务端 `reason`,避免 `res.json()` 解析失败被 Promise 链静默吞掉;
|
|
18
|
+
- **交互反馈**:设置面板与样式卡片补齐 `.catch` 与失败分支,改用 `showToast` / `showFeedback` 提示具体原因,杜绝操作无反馈。
|
|
19
|
+
- **安全加固**:严格校验 `X-Forwarded-Prefix`(`src/auth/prefix.ts`),仅接受同站绝对路径,拒绝 `//` 协议相对 URL、反斜杠、控制字符与协议前缀,防御开放重定向与 CRLF 注入。
|
|
20
|
+
- **文档**:README(中 / 英)新增 Q7,说明统一网关子路径部署注意事项、`X-Forwarded-Prefix` 依赖,以及本地回环 (127.0.0.1) 放行机制。
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
8
24
|
## [1.7.0] - 2026-09-15
|
|
9
25
|
|
|
10
26
|
### Fixed & Compatibility (解决 Issue #6 · 适配官方新版三栏布局与右侧边栏)
|
package/README.md
CHANGED
|
@@ -8,9 +8,17 @@
|
|
|
8
8
|
[](https://www.npmjs.com/package/dsh-remote-mobile)
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[](https://github.com/IceApriler/dsh-remote-mobile/blob/master/LICENSE)
|
|
11
|
+
[](https://github.com/IceApriler/dsh-remote-mobile)
|
|
12
|
+
[](https://github.com/IceApriler/dsh-remote-mobile/pulls)
|
|
11
13
|
|
|
12
14
|
<p align="center">
|
|
13
|
-
<b>零改动底层代码 ·
|
|
15
|
+
<b>零改动底层代码 · 局域网 / Tailscale 安全接入 · 扫码配对 · 移动端样式适配 · 工作区能力复用 · 实时状态推送</b>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
📱 <b>桌面级移动工作台</b>:手机上也能新建 / 切换工作区、执行终端命令<br>
|
|
20
|
+
🛡️ <b>多重安全认证</b>:扫码配对 · 长期密码 · 免密直连 + RSA 加密 + 防爆破封禁<br>
|
|
21
|
+
🎨 <b>移动端样式片段</b>:内置三段预设,不满意可自定义 CSS
|
|
14
22
|
</p>
|
|
15
23
|
|
|
16
24
|
[English Documentation](./README_EN.md) · [简体中文](./README.md)
|
|
@@ -23,6 +31,8 @@
|
|
|
23
31
|
<a href="#preview">界面预览</a> •
|
|
24
32
|
<a href="#features">功能特性</a> •
|
|
25
33
|
<a href="#install">安装与更新</a> •
|
|
34
|
+
<a href="#advanced">高级配置</a> •
|
|
35
|
+
<a href="#storage">本地文件存储</a> •
|
|
26
36
|
<a href="#faq">常见问题</a>
|
|
27
37
|
</p>
|
|
28
38
|
|
|
@@ -37,15 +47,15 @@
|
|
|
37
47
|
|
|
38
48
|
DSH 核心服务出于安全考虑默认仅监听本地回环地址(`127.0.0.1`),手机、平板或其他电脑无法从外部访问 Web 控制台。本项目通过**访问控制中间件**与**请求隔离技术**,安全地开放了 **Tailscale 虚拟私网** 以及 **本地局域网 (Wi-Fi/LAN)** 访问能力,并提供传输加密、扫码配对、长期密码认证与防暴力破解审计体系。
|
|
39
49
|
|
|
40
|
-
手机端访问时,插件还针对 DSH 界面做了**移动端样式适配**:侧边栏抽屉折叠与可拖拽展开把手、新版官方三栏 Grid
|
|
50
|
+
手机端访问时,插件还针对 DSH 界面做了**移动端样式适配**:侧边栏抽屉折叠与可拖拽展开把手、新版官方三栏 Grid 布局与右侧边栏兼容、设置弹窗居中微缩、对话正文高密度排版等,让手机上的使用体验更贴近桌面端。
|
|
41
51
|
|
|
42
52
|
---
|
|
43
53
|
|
|
44
54
|
<span id="advantages"></span>
|
|
45
55
|
## ⚡ 核心优势
|
|
46
56
|
|
|
47
|
-
* 🚀
|
|
48
|
-
* 📱 **手机端专属适配与同源复用**:直接复用 DSH Web
|
|
57
|
+
* 🚀 **突破网络壁垒,复用桌面端能力**:用于在局域网 / Tailscale 下访问 DSH Web。在移动端或外部设备访问时,**支持新建工作区、切换工作区、执行终端命令等常用桌面端操作**。
|
|
58
|
+
* 📱 **手机端专属适配与同源复用**:直接复用 DSH 官方 Web 前端(手机与桌面是同一套界面)与各类生态插件(如侧边栏、任务看板、终端面板等),无需单独维护手机端后台。插件已针对移动端做好了基础响应式适配(如侧边栏抽屉折叠、设置弹窗居中、对话正文字号与行距精细调小以提升一屏信息量),并内置**样式片段覆写功能**,支持开箱即用或按需注入自定义 CSS 样式小插件。
|
|
49
59
|
* 🛡️ **安全门禁与传输加密**:内置 RSA 非对称公钥加密、`scrypt` 加盐慢哈希密码落盘、连续输错自动熔断锁定 IP,拦截公网/局域网未经授权的访问。
|
|
50
60
|
* 📲 **扫码快速配对**:自动识别并生成 Tailscale CGNAT 与局域网专属访问链接与二维码,手机扫码即可完成长效授权。
|
|
51
61
|
* 🔄 **SSE 实时状态推送**:设备上线、下线、注销、IP 锁定等安全事件即时推送,无需前端轮询。
|
|
@@ -65,7 +75,7 @@ dsh plugin --profile web add dsh-remote-mobile
|
|
|
65
75
|
```
|
|
66
76
|
|
|
67
77
|
> [!IMPORTANT]
|
|
68
|
-
> **生态插件安装建议**:**不建议安装 `@linxin666/dsh-web-all`
|
|
78
|
+
> **生态插件安装建议**:**不建议安装 `@linxin666/dsh-web-all` 全家桶**,更推荐**按需安装单体功能插件**。全家桶将多个前端模块合并打包,可能引发移动端悬浮入口重复与手势交互冲突;如需任务看板、宠物等生态功能,建议直接安装对应的单体包(原因、推荐命令与共存说明见 [常见问题 Q6](#faq))。
|
|
69
79
|
|
|
70
80
|
---
|
|
71
81
|
|
|
@@ -94,22 +104,7 @@ dsh plugin --profile web add dsh-remote-mobile
|
|
|
94
104
|
> **💡 说明**:插件自身注册已由 DSH Bundle 体系全自动处理,**无需**在 `cordis.patch.yml` 中额外添加 `id: remote-mobile`。Windows 用户可按 `Win + R` 键输入 `%USERPROFILE%\.dsh\profiles\web` 快速直达配置目录。
|
|
95
105
|
|
|
96
106
|
> [!WARNING]
|
|
97
|
-
>
|
|
98
|
-
>
|
|
99
|
-
> * **为什么不建议安装 `@linxin666/dsh-web-all` 全家桶?**
|
|
100
|
-
> 1. **避免功能冲突与重复入口**:该全家桶将多个子插件的前端逻辑编译打包在同一个 `client.js` 中,其移动端自适应逻辑在模块加载时会自执行。即使在 `cordis.patch.yml` 中配置了 `web-ui-remote-web-ui: disabled: true`,浏览器加载脚本后依然会渲染出小鲸鱼悬浮按钮(`#dshRemoteWhale`),导致两个插件的移动端入口与手势交互出现重复和冲突。
|
|
101
|
-
> 2. **体积更轻量、升级更解耦**:全家桶将多个功能捆绑在一起,按需安装单体插件可以保持环境精简,也便于未来各插件独立升级与维护。
|
|
102
|
-
>
|
|
103
|
-
> * **推荐的最佳实践(按需单独安装单体插件)**:
|
|
104
|
-
> 建议按需安装各自独立的功能单体包,例如:
|
|
105
|
-
> ```bash
|
|
106
|
-
> # 推荐方式:需要哪个装哪个,职责清晰、互不干扰
|
|
107
|
-
> dsh plugin --profile web add @linxin666/dsh-client-ui-task-board
|
|
108
|
-
> dsh plugin --profile web add @linxin666/dsh-client-ui-web-ui-settings
|
|
109
|
-
> dsh plugin --profile web add @linxin666/dsh-pet
|
|
110
|
-
> dsh plugin --profile web add @linxin666/dsh-ssh
|
|
111
|
-
> ```
|
|
112
|
-
> *(注:自 v1.6.0 起,`dsh-remote-mobile` 已内置冲突防护机制,会自动前置注入 `dsh-remote-force-desktop` 并协同 CSS 避免小鲸鱼悬浮按钮 `#dshRemoteWhale` 重复挂载,保障已安装全家桶环境下的共存体验;若追求更清爽的环境,按需安装单体包依然是最佳实践)*。
|
|
107
|
+
> **不建议安装聚合全家桶(`@linxin666/dsh-web-all`)**:它会把多个子插件的前端逻辑合并打包到同一个 `client.js` 中,加载时自执行,可能与本插件产生移动端入口重复与手势冲突。推荐**按需安装所需单体插件**(如任务看板、宠物等)。完整原因、推荐命令与共存说明见 [常见问题 Q6](#faq)。
|
|
113
108
|
|
|
114
109
|
---
|
|
115
110
|
|
|
@@ -138,7 +133,7 @@ dsh web --no-open
|
|
|
138
133
|
|
|
139
134
|
---
|
|
140
135
|
|
|
141
|
-
###
|
|
136
|
+
### 手机端实机演示(同源免登与工作区能力)
|
|
142
137
|
|
|
143
138
|
| 登录 | 对话列表字号缩放 | 侧边栏移动端样式 | 官方轨迹查看 |
|
|
144
139
|
| :---: | :---: | :---: | :---: |
|
|
@@ -161,18 +156,20 @@ dsh web --no-open
|
|
|
161
156
|
- **设备会话管理**:实时查看已连接设备的类型、操作系统、浏览器、来源 IP 及最近活跃时间,支持单设备注销与一键注销全部设备。
|
|
162
157
|
|
|
163
158
|
### 3. 安全防护
|
|
164
|
-
- **传输层 RSA
|
|
159
|
+
- **传输层 RSA 非对称加密**:登录认证接口在客户端即完成 RSA 加密,敏感密码与配对码加密后才经网络传输。
|
|
160
|
+
- **HTTPS 等安全上下文**:优先使用 Web Crypto 原生 `RSA-OAEP-SHA256`;
|
|
161
|
+
- **DSH 默认的 HTTP 局域网 / Tailscale 访问(非安全上下文)**:回退至纯 JS 垫片,并使用 `crypto.getRandomValues()` 做密码学安全随机数填充。
|
|
165
162
|
- **scrypt 慢哈希存储**:服务端采用 `scrypt` 加盐慢哈希算法(`scrypt:${salt}:${hash}`)对密码进行落盘存储,比对过程采用 `crypto.timingSafeEqual` 恒定时间比较以防范时序侧信道攻击。
|
|
166
163
|
- **防暴力破解与限频保护**:
|
|
167
164
|
- 连续输错凭证达到阈值(默认 5 次)自动锁定该 IP 15 分钟,拦截后续验证请求并返回 HTTP 429;
|
|
168
165
|
- 滑动窗口限频(默认 60 次/分钟),防范高频恶意刷量探测;
|
|
169
166
|
- 访问审计与锁定状态持久化落盘,服务重启后自动恢复;
|
|
170
167
|
- 支持管理员在管理面板中一键解锁指定 IP。
|
|
171
|
-
- **智能静态资源放行机制**:安全门禁内置智能前端静态扩展名识别(放行 `.js`、`.css`、`.png`、`.svg`、`.woff2` 等 20
|
|
168
|
+
- **智能静态资源放行机制**:安全门禁内置智能前端静态扩展名识别(放行 `.js`、`.css`、`.png`、`.svg`、`.woff2` 等 20 种合法资源),拦截无扩展名或动态管理 API 请求(如 `/plugins/xxx/admin`),确保第三方生态插件前端正常展示的同时严密保护后台动态接口,无需用户在设置界面手动维护白名单。(DSH 核心静态目录 `/assets/` 属官方资源,单独无条件放行。)
|
|
172
169
|
- **高可靠原子持久化与防抖**:采用毫秒级写盘防抖节流保护磁盘 I/O(会话数据 500ms、样式片段 300ms),配合 `beforeExit` 进程退出 Flush 保证数据不丢失;`settings.yaml` 与 `style-snippets.json` 采用临时文件原子替换(`renameSync`),防止异常中断损坏数据。
|
|
173
170
|
- **真实 IP 安全提取**:仅信任底层 Socket 真实连接地址,防范伪造的 `X-Forwarded-For` 欺骗攻击。
|
|
174
171
|
- **本机回环 CSRF 防御**:插件的变更类 API 会校验浏览器同源信号(`Origin` / `Sec-Fetch-Site`),恶意网页驱使浏览器向 `127.0.0.1` 发起的跨站写请求将被直接拒绝;curl / 本机脚本等无浏览器信号的客户端不受影响。
|
|
175
|
-
-
|
|
172
|
+
- **本机回环请求不做上下文改写**:上下文虚拟化仅作用于外部来源流量;本机回环请求保留原始 Host / Origin(不"洗白"),使 DSH 自带的 DNS-Rebinding 与同源校验对外部域名继续生效。SSE 事件流仅限同源连接,不开放跨域读取。
|
|
176
173
|
|
|
177
174
|
### 4. 实时状态同步与国际化
|
|
178
175
|
- **SSE 实时事件推送**:基于 Server-Sent Events 实现新设备接入、设备重连、会话撤销及安全告警的实时通知,内置连接双向关闭监听与幂等清理。
|
|
@@ -183,14 +180,18 @@ dsh web --no-open
|
|
|
183
180
|
|
|
184
181
|
DSH Web 界面在手机上仍有不少沿袭桌面端的样式问题,插件内置「移动端样式片段」模块,把移动端适配拆分为可独立启停的 CSS 片段:
|
|
185
182
|
|
|
186
|
-
-
|
|
183
|
+
- **按界面区域划分的三段内置预设**(默认移动端启用、PC 关闭):
|
|
184
|
+
- `preset-sidebar` **侧边栏抽屉导航**:折叠时侧边栏 0 宽度占用、可拖拽悬浮把手、自适应抽屉宽度、44px 触控友好区;
|
|
185
|
+
- `preset-settings` **设置面板适配**:上下堆叠自适应、顶部导航横滑、内容纵向自然滚览;
|
|
186
|
+
- `preset-main` **对话正文高密度排版**:小字号 12.5px + 紧凑行距 + 收紧边距,每行展示更多内容;基于稳定 HTML 元素与 localName 后缀,不做布局缩放,无右侧留白;
|
|
187
187
|
- **PC / 移动端分别启停(按视口宽度判定,与设备无关)**:每段预设和每个自定义片段都有独立的「🖥️ PC」「📱 移动端」两个开关;「移动端」= 窄视口(≤900px)生效——**PC 浏览器拉小窗口也会生效**,「PC」= 宽视口(>900px)生效,两端都开 = 全宽度生效;
|
|
188
|
+
- **关于 `html[data-dsh-mobile]` 选择器**:该标记仅在**移动端 UA** 请求时写入。若自定义 CSS 以 `html[data-dsh-mobile]` 打头,则只在手机等移动端 UA 下命中;希望「窄屏桌面窗口也生效」时请直接写选择器(无需 `html[data-dsh-mobile]` 前缀),注入层会自动包裹媒体查询。
|
|
188
189
|
- **用户自定义(样式小插件)**:在 **设置 ⚙️ → 远程与移动端 → 🎨 移动端样式片段** 中粘贴自己的 CSS 即可新增片段,支持编辑/启停/删除/**一键复制**(每个片段「查看 CSS」旁都有「📋 复制样式」按钮),持久化于 `~/.dsh/remote-mobile/style-snippets.json`,保存后下一次页面加载即生效,无需重启;
|
|
189
|
-
- **按 UA 打标记 + 按宽度生效**:样式按视口宽度档生效(见上),与设备 UA 无关;移动端 UA 请求额外给 `<html>` 打上 `data-dsh-mobile="1"`
|
|
190
|
+
- **按 UA 打标记 + 按宽度生效**:样式按视口宽度档生效(见上),与设备 UA 无关;移动端 UA 请求额外给 `<html>` 打上 `data-dsh-mobile="1"` 标记作为作用域钩子(以其作为选择器时仅移动端 UA 命中,详见上条),并注入可拖拽的展开把手脚本(全端注入,运行时仅在侧边栏折叠时生效)。
|
|
190
191
|
|
|
191
192
|
### 6. 插件通用共存保护
|
|
192
193
|
|
|
193
|
-
- **启动零冲突**:`remoteWebUiPairing` 配对共享服务被其他远程/Web
|
|
194
|
+
- **启动零冲突**:`remoteWebUiPairing` 配对共享服务被其他远程/Web 接入类插件占用时,本插件自动让出(延迟裁决:等待激活窗口结束后检测服务名归属),避免服务重名导致的整树回滚崩溃——从插件市场安装后可直接启动,无需任何手动配置;
|
|
194
195
|
- **冲突状态可见化**:检测到共存时,设置页顶部展示可关闭的警示横幅(中英文),说明让出行为与「只保留其一」的建议;处理完成后刷新页面即不再出现;
|
|
195
196
|
- **一键诊断报告**:横幅内 **「📋 复制诊断与修复信息」** 按钮可复制完整诊断报告——运行时自动识别占用方插件包名与 loader entry id,报告含精确到行的修复配置与命令(禁用对方 / 卸载本插件二选一),粘贴给 AI 助手即可按步骤处理。
|
|
196
197
|
|
|
@@ -279,9 +280,10 @@ dsh plugin --profile web remove dsh-remote-mobile
|
|
|
279
280
|
|
|
280
281
|
---
|
|
281
282
|
|
|
283
|
+
<span id="advanced"></span>
|
|
282
284
|
## ⚙️ 高级配置
|
|
283
285
|
|
|
284
|
-
|
|
286
|
+
插件已接入 DSH 官方 Settings 体系,配置项支持在 Web 界面中直观调整,也可在 `~/.dsh/settings.yaml` 的 `dsh-remote-mobile` 命名空间下手动修改:
|
|
285
287
|
|
|
286
288
|
```yaml
|
|
287
289
|
dsh-remote-mobile:
|
|
@@ -303,7 +305,7 @@ dsh-remote-mobile:
|
|
|
303
305
|
"presetStates": {
|
|
304
306
|
"preset-sidebar": { "pc": false, "mobile": true },
|
|
305
307
|
"preset-settings": { "pc": false, "mobile": true },
|
|
306
|
-
"preset-main": { "pc":
|
|
308
|
+
"preset-main": { "pc": false, "mobile": true }
|
|
307
309
|
},
|
|
308
310
|
"custom": [
|
|
309
311
|
{
|
|
@@ -322,6 +324,7 @@ dsh-remote-mobile:
|
|
|
322
324
|
|
|
323
325
|
---
|
|
324
326
|
|
|
327
|
+
<span id="storage"></span>
|
|
325
328
|
## 📂 本地文件存储位置
|
|
326
329
|
|
|
327
330
|
> **💡 路径提示**:在 macOS / Linux 下根目录为 `~/.dsh/`;在 Windows 下对应为 `C:\Users\<你的用户名>\.dsh\`(可在文件资源管理器地址栏直接输入 `%USERPROFILE%\.dsh` 直达)。
|
|
@@ -388,7 +391,8 @@ dsh-remote-mobile:
|
|
|
388
391
|
<details>
|
|
389
392
|
<summary><b>Q4: 手机端样式还是不满意,想完全自己定制?</b></summary>
|
|
390
393
|
|
|
391
|
-
**答**:打开 **设置 ⚙️ → 远程与移动端 → 🎨 移动端样式片段**,先尝试启停三段内置预设(侧边栏 / 设置面板 / 正文,每段都有独立的 PC 与移动端开关);还不够就在「自定义片段」里粘贴自己的 CSS(例如
|
|
394
|
+
**答**:打开 **设置 ⚙️ → 远程与移动端 → 🎨 移动端样式片段**,先尝试启停三段内置预设(侧边栏 / 设置面板 / 正文,每段都有独立的 PC 与移动端开关);还不够就在「自定义片段」里粘贴自己的 CSS(例如 `.我的选择器 { ... }`)。保存后手机端刷新页面立即可见,改动全部落在 `~/.dsh/remote-mobile/style-snippets.json`,升级插件不会丢失。
|
|
395
|
+
> 小提示:若选择器写成 `html[data-dsh-mobile] .xxx`,则只在移动端 UA 下生效,窄屏桌面窗口不会命中;希望两端窄屏都生效,请直接写选择器。
|
|
392
396
|
</details>
|
|
393
397
|
|
|
394
398
|
<details>
|
|
@@ -406,8 +410,29 @@ dsh-remote-mobile:
|
|
|
406
410
|
|
|
407
411
|
**答**:主要是基于**避免功能冲突**与**环境轻量解耦**的考量:
|
|
408
412
|
1. **避免移动端入口重复与手势交互冲突**:全家桶插件会将十几个独立功能插件的前端代码编译打包在同一个 `client.js` 中,其内置的移动端自适应逻辑在模块加载时会自执行。即使在 `cordis.patch.yml` 中配置了 `web-ui-remote-web-ui: disabled: true`,浏览器加载脚本后仍可能渲染出小鲸鱼悬浮按钮(`#dshRemoteWhale`),导致两个插件的移动端悬浮入口重叠以及触控手势冲突。
|
|
409
|
-
2.
|
|
410
|
-
|
|
413
|
+
2. **轻量可控与独立升级**:按需单独安装所需插件,既能保证环境轻盈,也便于各个插件独立升级、回滚与维护,避免依赖绑定。推荐按需安装各自独立的单体包,例如:
|
|
414
|
+
```bash
|
|
415
|
+
# 需要哪个装哪个,职责清晰、互不干扰
|
|
416
|
+
dsh plugin --profile web add @linxin666/dsh-client-ui-task-board
|
|
417
|
+
dsh plugin --profile web add @linxin666/dsh-client-ui-web-ui-settings
|
|
418
|
+
dsh plugin --profile web add @linxin666/dsh-pet
|
|
419
|
+
dsh plugin --profile web add @linxin666/dsh-ssh
|
|
420
|
+
```
|
|
421
|
+
*(注:自 v1.6.0 起,`dsh-remote-mobile` 已内置兼容防护——注入抑制脚本向 `sessionStorage` 写入 `dsh-remote-force-desktop=1` 并协同 CSS,避免 `#dshRemoteWhale` 被**重复挂载**;但小鲸鱼入口本身仍可能由对方渲染,入口重叠的根源在对方,故仍建议按需安装单体包)*。
|
|
422
|
+
</details>
|
|
423
|
+
|
|
424
|
+
<details>
|
|
425
|
+
<summary><b>Q7: 部署在 NAS / 统一网关 / Nginx 反向代理下(如飞牛 fnOS <code>/app/dsh/</code>)有什么注意事项?</b></summary>
|
|
426
|
+
|
|
427
|
+
**答**:
|
|
428
|
+
1. **子路径与前缀自适应**:自 v1.8.0 起,前端已原生适配 `<base href="...">`,服务端重定向已支持 `X-Forwarded-Prefix` 请求头。经网关子路径代理访问时(如 `http://<ip>:5666/app/dsh/`),各项请求都会自动保持在当前子路径下:
|
|
429
|
+
* **设置面板与 SSE 长连接**:基于 `<base>` 动态解析 API 路径,保存配置、生成配对码、事件推送均不会丢失 `/app/dsh` 前缀(旧版本此处会表现为「点击毫无反应」);
|
|
430
|
+
* **独立手机登录页与 302 重定向**:登录校验、登录成功后的跳转,以及门禁 / `/auth` 的重定向都会保留当前子路径,不再跳回宿主根目录;
|
|
431
|
+
* **注意**:服务端 302 重定向依赖反向代理透传 `X-Forwarded-Prefix`;若代理未透传该头,重定向将回退到安全默认路径 `/auth`。
|
|
432
|
+
2. **网关回环 (127.0.0.1) 放行机制**:
|
|
433
|
+
* 出于严密的安全防护考量(防止外部恶意客户端伪造 `X-Forwarded-For` 请求头绕过认证),插件门禁严格基于底层物理 Socket 连接判断客户端真实来源。
|
|
434
|
+
* 当反向代理/宿主网关直接从本机 `127.0.0.1` 代理至 DSH 时,请求会被识别为本地回环连接并直接放行——此时由网关/NAS 宿主自身的登录与权限隔离机制进行安全兜底。
|
|
435
|
+
* 若你希望通过外部统一域名/网关访问时仍强制要求输入本插件的动态配对码或长期访问密码,建议配置反向代理透明透传客户端源 IP,或直接通过 DSH 开放的外部端口(如默认的 `3080` 或自定义映射端口)直连访问。
|
|
411
436
|
</details>
|
|
412
437
|
|
|
413
438
|
---
|
package/README_EN.md
CHANGED
|
@@ -8,9 +8,17 @@
|
|
|
8
8
|
[](https://www.npmjs.com/package/dsh-remote-mobile)
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[](https://github.com/IceApriler/dsh-remote-mobile/blob/master/LICENSE)
|
|
11
|
+
[](https://github.com/IceApriler/dsh-remote-mobile)
|
|
12
|
+
[](https://github.com/IceApriler/dsh-remote-mobile/pulls)
|
|
11
13
|
|
|
12
14
|
<p align="center">
|
|
13
|
-
<b>Zero Core Modifications ·
|
|
15
|
+
<b>Zero Core Modifications · Secure LAN / Tailscale Access · QR Pairing · Mobile UI Adaptation · Workspace Parity · Real-Time Push</b>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
<p align="center">
|
|
19
|
+
📱 <b>Desktop-grade mobile workspace</b>: create / switch workspaces and run terminal commands on your phone<br>
|
|
20
|
+
🛡️ <b>Multi-tier auth</b>: QR pairing · persistent password · passwordless bypass + RSA encryption + brute-force lockout<br>
|
|
21
|
+
🎨 <b>Mobile style snippets</b>: three built-in presets, fully customizable with your own CSS
|
|
14
22
|
</p>
|
|
15
23
|
|
|
16
24
|
English Documentation · [简体中文](./README.md)
|
|
@@ -23,6 +31,8 @@ English Documentation · [简体中文](./README.md)
|
|
|
23
31
|
<a href="#preview">UI Preview</a> •
|
|
24
32
|
<a href="#features">Features</a> •
|
|
25
33
|
<a href="#install">Installation & Update</a> •
|
|
34
|
+
<a href="#advanced">Advanced Configuration</a> •
|
|
35
|
+
<a href="#storage">Local File Storage</a> •
|
|
26
36
|
<a href="#faq">FAQ</a>
|
|
27
37
|
</p>
|
|
28
38
|
|
|
@@ -37,15 +47,15 @@ English Documentation · [简体中文](./README.md)
|
|
|
37
47
|
|
|
38
48
|
By default, DSH strictly listens only to the local loopback address (`127.0.0.1`), preventing mobile devices, tablets, or external computers from accessing the Web console. This plugin uses **access control middleware** and **request isolation** to securely open up **Tailscale Private Network** and **Local Area Network (LAN / Wi-Fi)** access, complete with RSA transmission encryption, QR code pairing, persistent password auth, and automated brute-force defense.
|
|
39
49
|
|
|
40
|
-
When accessed from a phone, the plugin also adapts the DSH interface for **mobile styles**: a collapsible sidebar drawer with a draggable floating toggle,
|
|
50
|
+
When accessed from a phone, the plugin also adapts the DSH interface for **mobile styles**: a collapsible sidebar drawer with a draggable floating toggle, compatibility with the official 3-column Grid layout & right sidebar, a centered/scaled settings dialog, and dense conversation typography — so the mobile experience feels closer to the desktop.
|
|
41
51
|
|
|
42
52
|
---
|
|
43
53
|
|
|
44
54
|
<span id="advantages"></span>
|
|
45
55
|
## ⚡ Key Advantages
|
|
46
56
|
|
|
47
|
-
* 🚀 **Break Network Barriers with
|
|
48
|
-
* 📱 **Dedicated Mobile Adaptations & Same-Origin Reuse**: Directly reuses the official DSH Web
|
|
57
|
+
* 🚀 **Break Network Barriers with Desktop-Grade Workspace**: For accessing DSH Web over LAN / Tailscale. On mobile devices, **common desktop operations such as creating / switching workspaces and running terminal commands are supported**.
|
|
58
|
+
* 📱 **Dedicated Mobile Adaptations & Same-Origin Reuse**: Directly reuses the official DSH Web frontend (the very same UI as desktop) and the wider plugin ecosystem (sidebars, task boards, terminal panels, etc.) without maintaining a separate mobile backend. Includes out-of-the-box mobile adaptations (sidebar drawer toggle, centered settings modal, and compact chat font scaling for higher information density), alongside a built-in **Style Snippet Override system** to toggle presets or inject custom CSS snippets on demand.
|
|
49
59
|
* 🛡️ **Security Gateway & Transport Encryption**: Built-in client-side RSA asymmetric public key encryption, `scrypt` salted slow-hashing password persistence, and automated IP lockout upon consecutive brute-force failures, blocking unauthorized access over public/LAN networks.
|
|
50
60
|
* 📲 **Quick QR Pairing**: Auto-detects Tailscale CGNAT and LAN IP addresses to generate dedicated pairing QR codes. Scan the code to authenticate and obtain persistent credentials.
|
|
51
61
|
* 🔄 **SSE Real-Time Push**: Device connection, reconnection, revocation, and security alert events are pushed in real time via Server-Sent Events without polling.
|
|
@@ -65,7 +75,7 @@ dsh plugin --profile web add dsh-remote-mobile
|
|
|
65
75
|
```
|
|
66
76
|
|
|
67
77
|
> [!IMPORTANT]
|
|
68
|
-
> **Ecosystem Plugin Advice**: **Installing `@linxin666/dsh-web-all` (all-in-one package) is not recommended**. We suggest **installing individual standalone plugins on demand**. The all-in-one package bundles multiple frontend modules together, which may result in duplicated mobile floating buttons and conflicting gesture handlers. If you need task boards, pet widgets, or other tools, install their standalone packages directly (see
|
|
78
|
+
> **Ecosystem Plugin Advice**: **Installing `@linxin666/dsh-web-all` (all-in-one package) is not recommended**. We suggest **installing individual standalone plugins on demand**. The all-in-one package bundles multiple frontend modules together, which may result in duplicated mobile floating buttons and conflicting gesture handlers. If you need task boards, pet widgets, or other tools, install their standalone packages directly (see [FAQ Q6](#faq) for the rationale, recommended commands, and coexistence notes).
|
|
69
79
|
|
|
70
80
|
---
|
|
71
81
|
|
|
@@ -95,22 +105,7 @@ Because DSH defaults to `127.0.0.1`, ensure your `~/.dsh/profiles/web/cordis.pat
|
|
|
95
105
|
> **💡 Note**: Plugin self-registration is automatically handled by the DSH Bundle system. You do **not** need to add `id: remote-mobile` manually. Windows users can press `Win + R` and enter `%USERPROFILE%\.dsh\profiles\web` to quickly jump to the configuration folder.
|
|
96
106
|
|
|
97
107
|
> [!WARNING]
|
|
98
|
-
>
|
|
99
|
-
>
|
|
100
|
-
> * **Why do we recommend avoiding `@linxin666/dsh-web-all`?**
|
|
101
|
-
> 1. **Avoid duplicated entries and gesture conflicts**: The all-in-one package compiles the frontend logic of multiple plugins into a single `client.js`. Its mobile adaptation logic executes upon script load. Even if you set `web-ui-remote-web-ui: disabled: true` in `cordis.patch.yml`, the browser may still render the floating whale button (`#dshRemoteWhale`), leading to duplicated mobile entry points and conflicting touch gestures.
|
|
102
|
-
> 2. **Lightweight and independently upgradable**: Installing standalone packages keeps the environment minimal and lets you upgrade, rollback, or configure each component independently.
|
|
103
|
-
>
|
|
104
|
-
> * **Recommended Best Practice (Install Standalone Plugins)**:
|
|
105
|
-
> Install only the specific standalone packages you actually need, for example:
|
|
106
|
-
> ```bash
|
|
107
|
-
> # Recommended: clear responsibilities, decoupled, fully controllable
|
|
108
|
-
> dsh plugin --profile web add @linxin666/dsh-client-ui-task-board
|
|
109
|
-
> dsh plugin --profile web add @linxin666/dsh-client-ui-web-ui-settings
|
|
110
|
-
> dsh plugin --profile web add @linxin666/dsh-pet
|
|
111
|
-
> dsh plugin --profile web add @linxin666/dsh-ssh
|
|
112
|
-
> ```
|
|
113
|
-
> *(Note: Starting from v1.6.0, `dsh-remote-mobile` includes automated compatibility handling that prepends `dsh-remote-force-desktop` to sessionStorage and coordinates with CSS to prevent the `#dshRemoteWhale` button from mounting twice. For the cleanest setup, installing standalone packages remains the best practice)*.
|
|
108
|
+
> **Installing the all-in-one bundle (`@linxin666/dsh-web-all`) is not recommended**: it compiles the frontend logic of multiple sub-plugins into a single `client.js` that self-executes on load, which may duplicate mobile entry points and cause gesture conflicts with this plugin. Install only the standalone plugins you need (task board, pet, etc.). See [FAQ Q6](#faq) for the full rationale, recommended commands, and coexistence notes.
|
|
114
109
|
|
|
115
110
|
---
|
|
116
111
|
|
|
@@ -137,7 +132,7 @@ Open the DSH Web Console in your browser, navigate to **Settings ⚙️ -> Remot
|
|
|
137
132
|
|
|
138
133
|
---
|
|
139
134
|
|
|
140
|
-
### Mobile Live Demonstration (Same-Origin Auth &
|
|
135
|
+
### Mobile Live Demonstration (Same-Origin Auth & Workspace Control)
|
|
141
136
|
|
|
142
137
|
| Login & Auth | Chat Font Scaling | Mobile Sidebar Styling | Official Trajectory / Trace |
|
|
143
138
|
| :---: | :---: | :---: | :---: |
|
|
@@ -155,23 +150,25 @@ Open the DSH Web Console in your browser, navigate to **Settings ⚙️ -> Remot
|
|
|
155
150
|
|
|
156
151
|
### 2. Multi-Tier Authentication
|
|
157
152
|
- **Dynamic 6-Digit Pairing Codes**: Generates 6-digit short codes (5-minute validity, single-use) that exchange for 365-day persistent authentication cookies on mobile.
|
|
158
|
-
- **Persistent Access Passwords**: Set customizable persistent passwords (minimum 6 characters with letters and numbers) for
|
|
153
|
+
- **Persistent Access Passwords**: Set customizable persistent passwords (minimum 6 characters with letters and numbers) for convenient long-term logins across multiple devices.
|
|
159
154
|
- **Direct Bypass Mode**: Enable passwordless direct bypass independently for Tailscale or LAN. Disabling bypass immediately cleans up temporary credentials and resets device state.
|
|
160
155
|
- **Device Session Management**: Real-time inspection of connected device types, OS, browser, source IP, and last active timestamp, with single-device revocation and one-click bulk revocation.
|
|
161
156
|
|
|
162
157
|
### 3. Security & Cryptography
|
|
163
|
-
- **Transport RSA Asymmetric Encryption**:
|
|
158
|
+
- **Transport RSA Asymmetric Encryption**: The login endpoint encrypts on the client first, so sensitive passwords and pairing codes travel the network only in ciphertext.
|
|
159
|
+
- **Secure contexts (HTTPS)**: prioritizes native Web Crypto `RSA-OAEP-SHA256`;
|
|
160
|
+
- **DSH default HTTP LAN/Tailscale access (non-secure context)**: falls back to a pure JS shim using `crypto.getRandomValues()` for cryptographically secure random padding.
|
|
164
161
|
- **scrypt Slow Hash Storage**: Server persists passwords with `scrypt` salted slow-hashing (`scrypt:${salt}:${hash}`). Verification uses `crypto.timingSafeEqual` constant-time comparison to prevent timing side-channel attacks.
|
|
165
162
|
- **Brute-Force Defense & Rate Limiting**:
|
|
166
163
|
- Automatically locks out IPs for 15 minutes after reaching consecutive failed attempt thresholds (default: 5), returning HTTP 429;
|
|
167
164
|
- Sliding-window rate limiting (default: 60 visits/min) to prevent high-frequency brute-force scraping;
|
|
168
165
|
- Audit logs and lockout states persist to disk and restore across restarts;
|
|
169
166
|
- Administrators can manually unlock blocked IPs with one click in the panel.
|
|
170
|
-
- **Intelligent Static Asset Whitelisting**: The authentication gate automatically allows verified frontend asset extensions (`.js`, `.css`, `.png`, `.svg`, `.woff2`, etc., 20
|
|
167
|
+
- **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.)
|
|
171
168
|
- **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.yaml` and `style-snippets.json`.
|
|
172
169
|
- **Real Socket IP Extraction**: Relies strictly on underlying Socket connection addresses, preventing spoofed `X-Forwarded-For` header attacks.
|
|
173
170
|
- **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.
|
|
174
|
-
- **No Context
|
|
171
|
+
- **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.
|
|
175
172
|
|
|
176
173
|
### 4. Real-Time Sync & Internationalization
|
|
177
174
|
- **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.
|
|
@@ -182,14 +179,18 @@ Open the DSH Web Console in your browser, navigate to **Settings ⚙️ -> Remot
|
|
|
182
179
|
|
|
183
180
|
The DSH web UI still carries many desktop-oriented styles on phones. This plugin ships a built-in **Mobile Style Snippets** module that splits mobile adaptation into toggleable CSS snippets:
|
|
184
181
|
|
|
185
|
-
- **Three area-based built-in presets
|
|
182
|
+
- **Three area-based built-in presets** (mobile on / PC off by default):
|
|
183
|
+
- `preset-sidebar` **Sidebar Drawer**: the collapsed sidebar occupies 0 width, with a draggable floating toggle, adaptive drawer width, and touch-friendly 44px tap targets;
|
|
184
|
+
- `preset-settings` **Settings Panel**: vertically stacked layout with horizontally scrollable nav tabs and natural vertical scrolling;
|
|
185
|
+
- `preset-main` **Dense Conversation Typography**: compact 12.5px text, tight line-height and margins for more content per line; built on stable HTML elements and localName suffixes, with no layout scaling and no right-side gaps;
|
|
186
186
|
- **Separate PC / Mobile toggles (viewport-width based, device-independent)**: every preset and custom snippet has independent PC and Mobile switches; "Mobile" = applies at narrow viewports (≤900px) — **a PC browser with a narrow window gets it too**; "PC" = applies at wide viewports (>900px); both on = applies at all widths;
|
|
187
|
+
- **About the `html[data-dsh-mobile]` selector**: that marker is only written for **mobile-UA** requests. If your custom CSS starts with `html[data-dsh-mobile]`, it only matches phone/mobile-UA clients; to also target a narrow desktop window, write the selector directly (without the `html[data-dsh-mobile]` prefix) and the injection layer wraps it in the media query automatically.
|
|
187
188
|
- **Custom snippets (your own mini-plugins)**: paste your CSS in **Settings ⚙️ → Remote & Mobile → 🎨 Mobile Style Snippets** to add a snippet, with edit/toggle/delete/**one-click copy** support (every snippet has a "📋 Copy CSS" button next to "View CSS"). Persisted to `~/.dsh/remote-mobile/style-snippets.json`; changes apply on the next page load without restart;
|
|
188
|
-
- **UA-based marker + width-based styling**: styles apply by viewport-width band (see above), independent of device UA; mobile UA requests additionally add `data-dsh-mobile="1"` to `<html>` as a scope hook, and the draggable toggle script is injected for all clients (it only activates while the sidebar is collapsed).
|
|
189
|
+
- **UA-based marker + width-based styling**: styles apply by viewport-width band (see above), independent of device UA; mobile UA requests additionally add `data-dsh-mobile="1"` to `<html>` as a scope hook (using it as a selector only matches mobile UA — see the note above), and the draggable toggle script is injected for all clients (it only activates while the sidebar is collapsed).
|
|
189
190
|
|
|
190
191
|
### 6. Generic Plugin Coexistence Protection
|
|
191
192
|
|
|
192
|
-
- **Conflict-free startup**: when the shared pairing service `remoteWebUiPairing` is already registered by another remote/Web access plugin, this plugin yields automatically (deferred arbitration: it waits until the activation window settles, then checks who owns the service name),
|
|
193
|
+
- **Conflict-free startup**: when the shared pairing service `remoteWebUiPairing` is already registered by another remote/Web access plugin, this plugin yields automatically (deferred arbitration: it waits until the activation window settles, then checks who owns the service name), avoiding the whole-tree rollback caused by service-name collisions — installing from the plugin market works out of the box with zero manual configuration;
|
|
193
194
|
- **Visible coexistence status**: when a conflict is detected, a dismissible warning banner (Chinese/English) appears at the top of the settings panel, explaining the yield behavior and the "keep only one" recommendation; refresh after resolving and it disappears;
|
|
194
195
|
- **One-click diagnosis report**: the **"📋 Copy diagnosis & fix"** button in the banner copies a full report — the conflicting plugin's package name and loader entry id are auto-detected at runtime, and the report includes line-exact fix configuration and commands (disable the other / remove this one), ready to paste to an AI assistant.
|
|
195
196
|
|
|
@@ -278,9 +279,10 @@ dsh plugin --profile web remove dsh-remote-mobile
|
|
|
278
279
|
|
|
279
280
|
---
|
|
280
281
|
|
|
282
|
+
<span id="advanced"></span>
|
|
281
283
|
## ⚙️ Advanced Configuration
|
|
282
284
|
|
|
283
|
-
|
|
285
|
+
Integrated with the DSH official Settings system. Configurations can be adjusted in the Web UI or edited in `~/.dsh/settings.yaml` under the `dsh-remote-mobile` namespace:
|
|
284
286
|
|
|
285
287
|
```yaml
|
|
286
288
|
dsh-remote-mobile:
|
|
@@ -302,7 +304,7 @@ Custom snippets (style mini-plugins) and their enabled states are persisted in `
|
|
|
302
304
|
"presetStates": {
|
|
303
305
|
"preset-sidebar": { "pc": false, "mobile": true },
|
|
304
306
|
"preset-settings": { "pc": false, "mobile": true },
|
|
305
|
-
"preset-main": { "pc":
|
|
307
|
+
"preset-main": { "pc": false, "mobile": true }
|
|
306
308
|
},
|
|
307
309
|
"custom": [
|
|
308
310
|
{
|
|
@@ -321,6 +323,7 @@ Custom snippets (style mini-plugins) and their enabled states are persisted in `
|
|
|
321
323
|
|
|
322
324
|
---
|
|
323
325
|
|
|
326
|
+
<span id="storage"></span>
|
|
324
327
|
## 📂 Local File Storage Locations
|
|
325
328
|
|
|
326
329
|
> **💡 Path Note**: On macOS / Linux, the base path is `~/.dsh/`; on Windows, it resolves to `C:\Users\<username>\.dsh\` (you can directly paste `%USERPROFILE%\.dsh` into the File Explorer address bar).
|
|
@@ -381,7 +384,8 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
|
|
|
381
384
|
<details>
|
|
382
385
|
<summary><b>Q4: Not happy with the mobile styles and want full control?</b></summary>
|
|
383
386
|
|
|
384
|
-
**A**: Open **Settings ⚙️ → Remote & Mobile → 🎨 Mobile Style Snippets**. First try toggling the three area presets (sidebar / settings / conversation), each with independent PC and Mobile switches; if that is not enough, paste your own CSS into the "Custom Snippets" area (e.g.
|
|
387
|
+
**A**: Open **Settings ⚙️ → Remote & Mobile → 🎨 Mobile Style Snippets**. First try toggling the three area presets (sidebar / settings / conversation), each with independent PC and Mobile switches; if that is not enough, paste your own CSS into the "Custom Snippets" area (e.g. `.my-selector { ... }`). Refresh the mobile page to see the effect immediately. Everything lives in `~/.dsh/remote-mobile/style-snippets.json` and survives plugin upgrades.
|
|
388
|
+
> Tip: if your selector starts with `html[data-dsh-mobile]`, it only applies to mobile-UA clients and will not match a narrow desktop window; drop the prefix if you want both.
|
|
385
389
|
</details>
|
|
386
390
|
|
|
387
391
|
<details>
|
|
@@ -405,8 +409,29 @@ If your log instead shows `service "remoteWebUiPairing" has been registered`, pl
|
|
|
405
409
|
|
|
406
410
|
**Answer**: This is primarily based on **avoiding functional conflicts** and **keeping the environment lightweight and decoupled**:
|
|
407
411
|
1. **Avoid duplicated mobile entries and touch gesture conflicts**: All-in-one packages compile the frontend logic of dozens of sub-plugins into a single `client.js`. Its mobile adaptation logic executes upon script load. Even if you set `web-ui-remote-web-ui: disabled: true` in `cordis.patch.yml`, the browser may still render the floating whale button (`#dshRemoteWhale`), causing duplicated mobile floating entry points and touch gesture interference.
|
|
408
|
-
2. **Lightweight and independently upgradable**: Installing standalone packages on demand
|
|
409
|
-
|
|
412
|
+
2. **Lightweight and independently upgradable**: Installing standalone packages on demand keeps a minimal dependency footprint and allows independent upgrading, rollback, and maintenance. Install only the standalone packages you need, for example:
|
|
413
|
+
```bash
|
|
414
|
+
# Install only what you need — clear responsibilities, no interference
|
|
415
|
+
dsh plugin --profile web add @linxin666/dsh-client-ui-task-board
|
|
416
|
+
dsh plugin --profile web add @linxin666/dsh-client-ui-web-ui-settings
|
|
417
|
+
dsh plugin --profile web add @linxin666/dsh-pet
|
|
418
|
+
dsh plugin --profile web add @linxin666/dsh-ssh
|
|
419
|
+
```
|
|
420
|
+
*(Note: As of v1.6.0, `dsh-remote-mobile` includes compatibility protection — an injected suppression script writes `dsh-remote-force-desktop=1` into `sessionStorage` and coordinates with CSS to prevent `#dshRemoteWhale` from being **mounted twice**. The whale entry itself may still be rendered by the other plugin, and the root cause of the overlap lies there, so installing standalone packages is still recommended)*.
|
|
421
|
+
</details>
|
|
422
|
+
|
|
423
|
+
<details>
|
|
424
|
+
<summary><b>Q7: What should I keep in mind when deploying behind a NAS / Unified Gateway / Nginx reverse proxy (e.g. fnOS <code>/app/dsh/</code>)?</b></summary>
|
|
425
|
+
|
|
426
|
+
**Answer**:
|
|
427
|
+
1. **Sub-path & Prefix Adaptive**: As of v1.8.0, the frontend natively adapts to `<base href="...">`, and server-side redirects support the `X-Forwarded-Prefix` header. When accessed through a sub-path gateway (e.g. `http://<ip>:5666/app/dsh/`), requests automatically stay scoped within the current sub-path:
|
|
428
|
+
* **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”);
|
|
429
|
+
* **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;
|
|
430
|
+
* **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`.
|
|
431
|
+
2. **Gateway Loopback (127.0.0.1) Bypass Mechanism**:
|
|
432
|
+
* 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.
|
|
433
|
+
* 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.
|
|
434
|
+
* 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).
|
|
410
435
|
</details>
|
|
411
436
|
|
|
412
437
|
---
|
package/lib/auth/gate.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isTailscaleIp, isLanIp, getClientIp } from './tailscale.js';
|
|
2
|
+
import { resolveRedirectPath } from './prefix.js';
|
|
2
3
|
/**
|
|
3
4
|
* 判断请求是否来自本机回环
|
|
4
5
|
*/
|
|
@@ -117,8 +118,9 @@ export function createGlobalAuthGate(store) {
|
|
|
117
118
|
const accept = req.headers['accept'] || '';
|
|
118
119
|
const isHtmlRequest = req.method === 'GET' && (accept.includes('text/html') || req.url === '/' || req.url?.startsWith('/?'));
|
|
119
120
|
if (isHtmlRequest) {
|
|
121
|
+
const authLocation = resolveRedirectPath(req.headers, '/auth');
|
|
120
122
|
res.writeHead(302, {
|
|
121
|
-
Location:
|
|
123
|
+
Location: authLocation,
|
|
122
124
|
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
|
123
125
|
});
|
|
124
126
|
res.end();
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 反向代理路径前缀解析与安全重定向工具
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* 校验并提取反向代理传递的前缀路径 (如 X-Forwarded-Prefix)
|
|
6
|
+
*
|
|
7
|
+
* 安全约束:
|
|
8
|
+
* 1. 必须以单个 / 开头,且不能以 // 开头(防御协议相对 URL 与开放重定向)
|
|
9
|
+
* 2. 不能包含协议标识 (http:, https:, etc.)
|
|
10
|
+
* 3. 不能包含反斜杠或控制字符(防御浏览器 URL 规范化逃逸与 CRLF 注入)
|
|
11
|
+
* 4. 去除末尾冗余的斜杠
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveForwardedPrefix(raw: unknown): string;
|
|
14
|
+
/**
|
|
15
|
+
* 构造带网关前缀的重定向目标路径
|
|
16
|
+
* @param headers 请求头
|
|
17
|
+
* @param targetPath 目标相对路径,如 '/auth' 或 '/'
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveRedirectPath(headers: Record<string, string | string[] | undefined> | undefined, targetPath: string): string;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 反向代理路径前缀解析与安全重定向工具
|
|
3
|
+
*/
|
|
4
|
+
/**
|
|
5
|
+
* 校验并提取反向代理传递的前缀路径 (如 X-Forwarded-Prefix)
|
|
6
|
+
*
|
|
7
|
+
* 安全约束:
|
|
8
|
+
* 1. 必须以单个 / 开头,且不能以 // 开头(防御协议相对 URL 与开放重定向)
|
|
9
|
+
* 2. 不能包含协议标识 (http:, https:, etc.)
|
|
10
|
+
* 3. 不能包含反斜杠或控制字符(防御浏览器 URL 规范化逃逸与 CRLF 注入)
|
|
11
|
+
* 4. 去除末尾冗余的斜杠
|
|
12
|
+
*/
|
|
13
|
+
export function resolveForwardedPrefix(raw) {
|
|
14
|
+
if (typeof raw !== 'string')
|
|
15
|
+
return '';
|
|
16
|
+
const trimmed = raw.trim();
|
|
17
|
+
if (!trimmed || trimmed === '/')
|
|
18
|
+
return '';
|
|
19
|
+
// 必须以单个 / 开头,拒绝 // (如 //evil.com)、带有协议字符、以及含反斜杠与控制字符
|
|
20
|
+
if (!trimmed.startsWith('/') ||
|
|
21
|
+
trimmed.startsWith('//') ||
|
|
22
|
+
/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(trimmed) ||
|
|
23
|
+
/[\\\x00-\x1F\x7F]/.test(trimmed)) {
|
|
24
|
+
return '';
|
|
25
|
+
}
|
|
26
|
+
return trimmed.replace(/\/+$/, '');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* 构造带网关前缀的重定向目标路径
|
|
30
|
+
* @param headers 请求头
|
|
31
|
+
* @param targetPath 目标相对路径,如 '/auth' 或 '/'
|
|
32
|
+
*/
|
|
33
|
+
export function resolveRedirectPath(headers, targetPath) {
|
|
34
|
+
const rawPrefix = headers?.['x-forwarded-prefix'];
|
|
35
|
+
const prefix = resolveForwardedPrefix(typeof rawPrefix === 'string' ? rawPrefix : Array.isArray(rawPrefix) ? rawPrefix[0] : '');
|
|
36
|
+
const normPath = targetPath.startsWith('/') ? targetPath : `/${targetPath}`;
|
|
37
|
+
if (!prefix) {
|
|
38
|
+
return normPath;
|
|
39
|
+
}
|
|
40
|
+
return `${prefix}${normPath}`;
|
|
41
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -53,123 +53,123 @@ var import_react10 = __toESM(require("react"), 1);
|
|
|
53
53
|
var import_react9 = __toESM(require("react"), 1);
|
|
54
54
|
|
|
55
55
|
// src/client/api.js
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
56
|
+
var RM_API_BASE = (function() {
|
|
57
|
+
try {
|
|
58
|
+
if (typeof document !== "undefined" && document.baseURI) {
|
|
59
|
+
return new URL("api/remote-mobile/", document.baseURI).toString();
|
|
60
|
+
}
|
|
61
|
+
return "/api/remote-mobile/";
|
|
62
|
+
} catch (e) {
|
|
63
|
+
return "/api/remote-mobile/";
|
|
64
|
+
}
|
|
65
|
+
})();
|
|
66
|
+
function safeFetchJson(url, options) {
|
|
67
|
+
return fetch(url, options).then(function(res) {
|
|
68
|
+
if (!res.ok) {
|
|
69
|
+
return res.text().then(function(text) {
|
|
70
|
+
var msg = "HTTP " + res.status;
|
|
71
|
+
try {
|
|
72
|
+
var parsed = JSON.parse(text);
|
|
73
|
+
if (parsed && (parsed.reason || parsed.message || parsed.error)) {
|
|
74
|
+
msg = parsed.reason || parsed.message || parsed.error;
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
}
|
|
78
|
+
throw new Error(msg);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return res.json().catch(function() {
|
|
82
|
+
throw new Error("\u54CD\u5E94\u89E3\u6790\u5931\u8D25 (Invalid JSON response)");
|
|
83
|
+
});
|
|
59
84
|
});
|
|
60
85
|
}
|
|
86
|
+
function fetchStatus() {
|
|
87
|
+
return safeFetchJson(RM_API_BASE + "status?_t=" + Date.now());
|
|
88
|
+
}
|
|
61
89
|
function generatePairCode() {
|
|
62
|
-
return
|
|
63
|
-
return res.json();
|
|
64
|
-
});
|
|
90
|
+
return safeFetchJson(RM_API_BASE + "generate-code", { method: "POST" });
|
|
65
91
|
}
|
|
66
92
|
function updateBypassConfig(allowTailscale, allowLan) {
|
|
67
|
-
return
|
|
93
|
+
return safeFetchJson(RM_API_BASE + "update-options", {
|
|
68
94
|
method: "POST",
|
|
69
95
|
headers: { "Content-Type": "application/json" },
|
|
70
96
|
body: JSON.stringify({ allowTailscale, allowLan })
|
|
71
|
-
}).then(function(res) {
|
|
72
|
-
return res.json();
|
|
73
97
|
});
|
|
74
98
|
}
|
|
75
99
|
function updateAdvancedSecurityOptions(payload) {
|
|
76
|
-
return
|
|
100
|
+
return safeFetchJson(RM_API_BASE + "update-options", {
|
|
77
101
|
method: "POST",
|
|
78
102
|
headers: { "Content-Type": "application/json" },
|
|
79
103
|
body: JSON.stringify(payload)
|
|
80
|
-
}).then(function(res) {
|
|
81
|
-
return res.json();
|
|
82
104
|
});
|
|
83
105
|
}
|
|
84
106
|
function updateSecret(secret) {
|
|
85
|
-
return
|
|
107
|
+
return safeFetchJson(RM_API_BASE + "set-secret", {
|
|
86
108
|
method: "POST",
|
|
87
109
|
headers: { "Content-Type": "application/json" },
|
|
88
110
|
body: JSON.stringify({ secret })
|
|
89
|
-
}).then(function(res) {
|
|
90
|
-
return res.json();
|
|
91
111
|
});
|
|
92
112
|
}
|
|
93
113
|
function clearSecretApi() {
|
|
94
|
-
return
|
|
114
|
+
return safeFetchJson(RM_API_BASE + "clear-secret", {
|
|
95
115
|
method: "POST",
|
|
96
116
|
headers: { "Content-Type": "application/json" }
|
|
97
|
-
}).then(function(res) {
|
|
98
|
-
return res.json();
|
|
99
117
|
});
|
|
100
118
|
}
|
|
101
119
|
function revokeDeviceApi(token) {
|
|
102
|
-
return
|
|
120
|
+
return safeFetchJson(RM_API_BASE + "revoke-device", {
|
|
103
121
|
method: "POST",
|
|
104
122
|
headers: { "Content-Type": "application/json" },
|
|
105
123
|
body: JSON.stringify({ token })
|
|
106
|
-
}).then(function(res) {
|
|
107
|
-
return res.json();
|
|
108
124
|
});
|
|
109
125
|
}
|
|
110
126
|
function revokeAllDevicesApi() {
|
|
111
|
-
return
|
|
127
|
+
return safeFetchJson(RM_API_BASE + "revoke-all", {
|
|
112
128
|
method: "POST",
|
|
113
129
|
headers: { "Content-Type": "application/json" }
|
|
114
|
-
}).then(function(res) {
|
|
115
|
-
return res.json();
|
|
116
130
|
});
|
|
117
131
|
}
|
|
118
132
|
function unlockIpApi(ip) {
|
|
119
|
-
return
|
|
133
|
+
return safeFetchJson(RM_API_BASE + "unlock-ip", {
|
|
120
134
|
method: "POST",
|
|
121
135
|
headers: { "Content-Type": "application/json" },
|
|
122
136
|
body: JSON.stringify({ ip })
|
|
123
|
-
}).then(function(res) {
|
|
124
|
-
return res.json();
|
|
125
137
|
});
|
|
126
138
|
}
|
|
127
139
|
function clearIpStatsApi() {
|
|
128
|
-
return
|
|
140
|
+
return safeFetchJson(RM_API_BASE + "clear-ip-stats", {
|
|
129
141
|
method: "POST",
|
|
130
142
|
headers: { "Content-Type": "application/json" }
|
|
131
|
-
}).then(function(res) {
|
|
132
|
-
return res.json();
|
|
133
143
|
});
|
|
134
144
|
}
|
|
135
145
|
function fetchStyles() {
|
|
136
|
-
return
|
|
137
|
-
return res.json();
|
|
138
|
-
});
|
|
146
|
+
return safeFetchJson(RM_API_BASE + "styles?_t=" + Date.now());
|
|
139
147
|
}
|
|
140
148
|
function saveStyleSnippet(payload) {
|
|
141
|
-
return
|
|
149
|
+
return safeFetchJson(RM_API_BASE + "styles", {
|
|
142
150
|
method: "POST",
|
|
143
151
|
headers: { "Content-Type": "application/json" },
|
|
144
152
|
body: JSON.stringify(payload)
|
|
145
|
-
}).then(function(res) {
|
|
146
|
-
return res.json();
|
|
147
153
|
});
|
|
148
154
|
}
|
|
149
155
|
function toggleStyleApi(id, scope, enabled) {
|
|
150
|
-
return
|
|
156
|
+
return safeFetchJson(RM_API_BASE + "styles/toggle", {
|
|
151
157
|
method: "POST",
|
|
152
158
|
headers: { "Content-Type": "application/json" },
|
|
153
159
|
body: JSON.stringify({ id, scope, enabled })
|
|
154
|
-
}).then(function(res) {
|
|
155
|
-
return res.json();
|
|
156
160
|
});
|
|
157
161
|
}
|
|
158
162
|
function deleteStyleSnippetApi(id) {
|
|
159
|
-
return
|
|
163
|
+
return safeFetchJson(RM_API_BASE + "styles/delete", {
|
|
160
164
|
method: "POST",
|
|
161
165
|
headers: { "Content-Type": "application/json" },
|
|
162
166
|
body: JSON.stringify({ id })
|
|
163
|
-
}).then(function(res) {
|
|
164
|
-
return res.json();
|
|
165
167
|
});
|
|
166
168
|
}
|
|
167
169
|
function resetStylesApi() {
|
|
168
|
-
return
|
|
170
|
+
return safeFetchJson(RM_API_BASE + "styles/reset", {
|
|
169
171
|
method: "POST",
|
|
170
172
|
headers: { "Content-Type": "application/json" }
|
|
171
|
-
}).then(function(res) {
|
|
172
|
-
return res.json();
|
|
173
173
|
});
|
|
174
174
|
}
|
|
175
175
|
|
|
@@ -3080,7 +3080,11 @@ function StylesCard({ lang, showToast }) {
|
|
|
3080
3080
|
enabled ? lang === "en" ? scopeLabel + " enabled!" : scopeLabel + " \u5DF2\u542F\u7528\uFF01" : lang === "en" ? scopeLabel + " disabled!" : scopeLabel + " \u5DF2\u505C\u7528\uFF01",
|
|
3081
3081
|
"success"
|
|
3082
3082
|
);
|
|
3083
|
+
} else {
|
|
3084
|
+
showFeedback((lang === "en" ? "Toggle failed: " : "\u5207\u6362\u5931\u8D25\uFF1A") + (data.reason || ""), "danger");
|
|
3083
3085
|
}
|
|
3086
|
+
}).catch((err) => {
|
|
3087
|
+
showFeedback((lang === "en" ? "Toggle failed: " : "\u5207\u6362\u5931\u8D25\uFF1A") + (err?.message || err), "danger");
|
|
3084
3088
|
});
|
|
3085
3089
|
};
|
|
3086
3090
|
const startEdit = (s) => {
|
|
@@ -3103,7 +3107,7 @@ function StylesCard({ lang, showToast }) {
|
|
|
3103
3107
|
};
|
|
3104
3108
|
const save = () => {
|
|
3105
3109
|
if (!name.trim() || !css.trim()) {
|
|
3106
|
-
|
|
3110
|
+
showFeedback(t("stylesFieldsRequired", lang), "danger");
|
|
3107
3111
|
return;
|
|
3108
3112
|
}
|
|
3109
3113
|
saveStyleSnippet({
|
|
@@ -3118,10 +3122,10 @@ function StylesCard({ lang, showToast }) {
|
|
|
3118
3122
|
cancelEdit();
|
|
3119
3123
|
showFeedback(t("stylesSaveSuccess", lang));
|
|
3120
3124
|
} else {
|
|
3121
|
-
|
|
3125
|
+
showFeedback((lang === "en" ? "Save failed: " : "\u4FDD\u5B58\u5931\u8D25\uFF1A") + (data.reason || ""), "danger");
|
|
3122
3126
|
}
|
|
3123
|
-
}).catch(() => {
|
|
3124
|
-
|
|
3127
|
+
}).catch((err) => {
|
|
3128
|
+
showFeedback((lang === "en" ? "Save failed: " : "\u4FDD\u5B58\u5931\u8D25\uFF1A") + (err?.message || err), "danger");
|
|
3125
3129
|
});
|
|
3126
3130
|
};
|
|
3127
3131
|
const remove = (s) => {
|
|
@@ -3132,7 +3136,11 @@ function StylesCard({ lang, showToast }) {
|
|
|
3132
3136
|
if (editingId === s.id) cancelEdit();
|
|
3133
3137
|
load();
|
|
3134
3138
|
showFeedback(lang === "en" ? "Style snippet deleted!" : "\u81EA\u5B9A\u4E49\u6837\u5F0F\u7247\u6BB5\u5DF2\u5220\u9664\uFF01");
|
|
3139
|
+
} else {
|
|
3140
|
+
showFeedback((lang === "en" ? "Delete failed: " : "\u5220\u9664\u5931\u8D25\uFF1A") + (data.reason || ""), "danger");
|
|
3135
3141
|
}
|
|
3142
|
+
}).catch((err) => {
|
|
3143
|
+
showFeedback((lang === "en" ? "Delete failed: " : "\u5220\u9664\u5931\u8D25\uFF1A") + (err?.message || err), "danger");
|
|
3136
3144
|
});
|
|
3137
3145
|
}
|
|
3138
3146
|
};
|
|
@@ -3142,7 +3150,11 @@ function StylesCard({ lang, showToast }) {
|
|
|
3142
3150
|
if (data.success) {
|
|
3143
3151
|
load();
|
|
3144
3152
|
showFeedback(lang === "en" ? "Style snippets reset to defaults!" : "\u5DF2\u6062\u590D\u6240\u6709\u7247\u6BB5\u7684\u9ED8\u8BA4\u542F\u505C\u72B6\u6001\uFF01");
|
|
3153
|
+
} else {
|
|
3154
|
+
showFeedback((lang === "en" ? "Reset failed: " : "\u91CD\u7F6E\u5931\u8D25\uFF1A") + (data.reason || ""), "danger");
|
|
3145
3155
|
}
|
|
3156
|
+
}).catch((err) => {
|
|
3157
|
+
showFeedback((lang === "en" ? "Reset failed: " : "\u91CD\u7F6E\u5931\u8D25\uFF1A") + (err?.message || err), "danger");
|
|
3146
3158
|
});
|
|
3147
3159
|
};
|
|
3148
3160
|
const toggleExpand = (id) => {
|
|
@@ -3526,8 +3538,8 @@ function StylesCard({ lang, showToast }) {
|
|
|
3526
3538
|
}
|
|
3527
3539
|
|
|
3528
3540
|
// src/client/version.js
|
|
3529
|
-
var PLUGIN_VERSION = "v1.
|
|
3530
|
-
var BUILD_TIME = "2026-
|
|
3541
|
+
var PLUGIN_VERSION = "v1.8.0";
|
|
3542
|
+
var BUILD_TIME = "2026-10-08 20:18:19";
|
|
3531
3543
|
|
|
3532
3544
|
// src/client/components/MainSection.jsx
|
|
3533
3545
|
var import_jsx_runtime9 = require("react/jsx-runtime");
|
|
@@ -3574,6 +3586,12 @@ function TailscaleMobileSection(props) {
|
|
|
3574
3586
|
const [hasUpdate, setHasUpdate] = (0, import_react9.useState)(false);
|
|
3575
3587
|
const [conflictBannerDismissed, setConflictBannerDismissed] = (0, import_react9.useState)(false);
|
|
3576
3588
|
const qrTabTouchedRef = (0, import_react9.useRef)(false);
|
|
3589
|
+
const toastTimerRef = (0, import_react9.useRef)(null);
|
|
3590
|
+
const showToast = (0, import_react9.useCallback)((message, type = "success", duration = 2500) => {
|
|
3591
|
+
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
3592
|
+
setToast({ message, type });
|
|
3593
|
+
toastTimerRef.current = setTimeout(() => setToast(null), duration);
|
|
3594
|
+
}, []);
|
|
3577
3595
|
const updateQrTab = (0, import_react9.useCallback)((tab) => {
|
|
3578
3596
|
qrTabTouchedRef.current = true;
|
|
3579
3597
|
setSelectedTab(tab);
|
|
@@ -3758,6 +3776,8 @@ function TailscaleMobileSection(props) {
|
|
|
3758
3776
|
const nextVal = !status.allowTailscale;
|
|
3759
3777
|
updateBypassConfig(nextVal, status.allowLan).then(() => {
|
|
3760
3778
|
setStatus((prev) => ({ ...prev, allowTailscale: nextVal }));
|
|
3779
|
+
}).catch((err) => {
|
|
3780
|
+
showToast((currentLang === "en" ? "Operation failed: " : "\u64CD\u4F5C\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3761
3781
|
});
|
|
3762
3782
|
};
|
|
3763
3783
|
const toggleLan = () => {
|
|
@@ -3768,6 +3788,8 @@ function TailscaleMobileSection(props) {
|
|
|
3768
3788
|
}
|
|
3769
3789
|
updateBypassConfig(status.allowTailscale, nextVal).then(() => {
|
|
3770
3790
|
setStatus((prev) => ({ ...prev, allowLan: nextVal }));
|
|
3791
|
+
}).catch((err) => {
|
|
3792
|
+
showToast((currentLang === "en" ? "Operation failed: " : "\u64CD\u4F5C\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3771
3793
|
});
|
|
3772
3794
|
};
|
|
3773
3795
|
const saveSecret = () => {
|
|
@@ -3792,8 +3814,10 @@ function TailscaleMobileSection(props) {
|
|
|
3792
3814
|
3e3
|
|
3793
3815
|
);
|
|
3794
3816
|
} else {
|
|
3795
|
-
|
|
3817
|
+
showToast((currentLang === "en" ? "Save failed: " : "\u4FDD\u5B58\u5931\u8D25\uFF1A") + (data.reason || ""), "danger", 3500);
|
|
3796
3818
|
}
|
|
3819
|
+
}).catch((err) => {
|
|
3820
|
+
showToast((currentLang === "en" ? "Save failed: " : "\u4FDD\u5B58\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3797
3821
|
});
|
|
3798
3822
|
};
|
|
3799
3823
|
const clearSecret = () => {
|
|
@@ -3806,6 +3830,8 @@ function TailscaleMobileSection(props) {
|
|
|
3806
3830
|
"info",
|
|
3807
3831
|
3e3
|
|
3808
3832
|
);
|
|
3833
|
+
}).catch((err) => {
|
|
3834
|
+
showToast((currentLang === "en" ? "Clear failed: " : "\u6E05\u9664\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3809
3835
|
});
|
|
3810
3836
|
}
|
|
3811
3837
|
};
|
|
@@ -3818,6 +3844,8 @@ function TailscaleMobileSection(props) {
|
|
|
3818
3844
|
"info",
|
|
3819
3845
|
2500
|
|
3820
3846
|
);
|
|
3847
|
+
}).catch((err) => {
|
|
3848
|
+
showToast((currentLang === "en" ? "Revoke failed: " : "\u65AD\u5F00\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3821
3849
|
});
|
|
3822
3850
|
}
|
|
3823
3851
|
};
|
|
@@ -3830,6 +3858,8 @@ function TailscaleMobileSection(props) {
|
|
|
3830
3858
|
"info",
|
|
3831
3859
|
2500
|
|
3832
3860
|
);
|
|
3861
|
+
}).catch((err) => {
|
|
3862
|
+
showToast((currentLang === "en" ? "Revoke failed: " : "\u65AD\u5F00\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3833
3863
|
});
|
|
3834
3864
|
}
|
|
3835
3865
|
};
|
|
@@ -3842,7 +3872,8 @@ function TailscaleMobileSection(props) {
|
|
|
3842
3872
|
"success",
|
|
3843
3873
|
3e3
|
|
3844
3874
|
);
|
|
3845
|
-
}).catch(() => {
|
|
3875
|
+
}).catch((err) => {
|
|
3876
|
+
showToast((currentLang === "en" ? "Unlock failed: " : "\u89E3\u9501\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3846
3877
|
});
|
|
3847
3878
|
}
|
|
3848
3879
|
};
|
|
@@ -3855,7 +3886,8 @@ function TailscaleMobileSection(props) {
|
|
|
3855
3886
|
"success",
|
|
3856
3887
|
2500
|
|
3857
3888
|
);
|
|
3858
|
-
}).catch(() => {
|
|
3889
|
+
}).catch((err) => {
|
|
3890
|
+
showToast((currentLang === "en" ? "Clear failed: " : "\u6E05\u7A7A\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3859
3891
|
});
|
|
3860
3892
|
}
|
|
3861
3893
|
};
|
|
@@ -3881,11 +3913,11 @@ function TailscaleMobileSection(props) {
|
|
|
3881
3913
|
refreshStatusAndCode();
|
|
3882
3914
|
showToast(t("saveConfigSuccessToast", currentLang), "success", 3e3);
|
|
3883
3915
|
} else {
|
|
3884
|
-
|
|
3916
|
+
showToast((currentLang === "en" ? "Save failed: " : "\u4FDD\u5B58\u5931\u8D25\uFF1A") + (data.reason || ""), "danger", 3500);
|
|
3885
3917
|
}
|
|
3886
|
-
}).catch(() => {
|
|
3918
|
+
}).catch((err) => {
|
|
3887
3919
|
setIsSaving(false);
|
|
3888
|
-
|
|
3920
|
+
showToast((currentLang === "en" ? "Save failed: " : "\u4FDD\u5B58\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3889
3921
|
});
|
|
3890
3922
|
};
|
|
3891
3923
|
const resetAdvancedConfigDefaults = () => {
|
|
@@ -3906,11 +3938,11 @@ function TailscaleMobileSection(props) {
|
|
|
3906
3938
|
refreshStatusAndCode();
|
|
3907
3939
|
showToast(t("restoreDefaultsSuccessToast", currentLang), "success", 3e3);
|
|
3908
3940
|
} else {
|
|
3909
|
-
|
|
3941
|
+
showToast((currentLang === "en" ? "Reset failed: " : "\u91CD\u7F6E\u5931\u8D25\uFF1A") + (data.reason || ""), "danger", 3500);
|
|
3910
3942
|
}
|
|
3911
|
-
}).catch(() => {
|
|
3943
|
+
}).catch((err) => {
|
|
3912
3944
|
setIsSaving(false);
|
|
3913
|
-
|
|
3945
|
+
showToast((currentLang === "en" ? "Reset failed: " : "\u91CD\u7F6E\u5931\u8D25\uFF1A") + (err?.message || err), "danger", 3500);
|
|
3914
3946
|
});
|
|
3915
3947
|
};
|
|
3916
3948
|
const copyDirectLink = () => {
|
|
@@ -3927,12 +3959,6 @@ function TailscaleMobileSection(props) {
|
|
|
3927
3959
|
setStatus((prev) => ({ ...prev, copied: false }));
|
|
3928
3960
|
}, 2e3);
|
|
3929
3961
|
};
|
|
3930
|
-
const toastTimerRef = (0, import_react9.useRef)(null);
|
|
3931
|
-
const showToast = (0, import_react9.useCallback)((message, type = "success", duration = 2500) => {
|
|
3932
|
-
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
|
|
3933
|
-
setToast({ message, type });
|
|
3934
|
-
toastTimerRef.current = setTimeout(() => setToast(null), duration);
|
|
3935
|
-
}, []);
|
|
3936
3962
|
const copyText = (text, successTip) => {
|
|
3937
3963
|
const triggerToast = () => {
|
|
3938
3964
|
showToast(
|
|
@@ -4552,7 +4578,7 @@ function apply(ctx) {
|
|
|
4552
4578
|
window.__DSH_REMOTE_MOBILE_SSE_LISTENER__ = true;
|
|
4553
4579
|
var initSse = function() {
|
|
4554
4580
|
try {
|
|
4555
|
-
var eventSource = new EventSource("
|
|
4581
|
+
var eventSource = new EventSource(RM_API_BASE + "events");
|
|
4556
4582
|
eventSource.onmessage = function(event) {
|
|
4557
4583
|
try {
|
|
4558
4584
|
var data = JSON.parse(event.data || "{}");
|
package/lib/routes/api.js
CHANGED
|
@@ -4,6 +4,7 @@ import { getPublicKeyPem, decryptWithPrivateKey, validateSecretStrength, RSA_KEY
|
|
|
4
4
|
import { getLoginPageHtml } from './login-page.js';
|
|
5
5
|
import { DEFAULT_STYLE_FILE } from '../styles/style-snippets.js';
|
|
6
6
|
import { getPairingBridgeState } from '../bridge/compat.js';
|
|
7
|
+
import { resolveRedirectPath } from '../auth/prefix.js';
|
|
7
8
|
function jsonResponse(res, status, data) {
|
|
8
9
|
res.writeHead(status, {
|
|
9
10
|
'Content-Type': 'application/json; charset=utf-8',
|
|
@@ -287,9 +288,10 @@ export function createRoutes(store, styleStore) {
|
|
|
287
288
|
const token = store.extractTokenFromRequest(req);
|
|
288
289
|
const ua = req.headers['user-agent'] || '';
|
|
289
290
|
const hasValidToken = token ? store.validateToken(token, clientIp) : false;
|
|
290
|
-
// 如果开启了免密直连(Tailscale 或 局域网)或者已持有有效 Token
|
|
291
|
+
// 如果开启了免密直连(Tailscale 或 局域网)或者已持有有效 Token,直接放行直跳根目录(支持网关前缀)
|
|
291
292
|
if ((opts.allowTailscale && isTs) || (opts.allowLan && isLan) || hasValidToken) {
|
|
292
|
-
|
|
293
|
+
const rootLocation = resolveRedirectPath(req.headers, '/');
|
|
294
|
+
res.writeHead(302, { Location: rootLocation });
|
|
293
295
|
return res.end();
|
|
294
296
|
}
|
|
295
297
|
// 记录 IP 访问计数并进行防高频刷量限频与设备型号解析
|
package/lib/routes/login-page.js
CHANGED
|
@@ -332,6 +332,40 @@ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
|
|
|
332
332
|
input.value = queryToken;
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
// 自适应网关子路径 / baseURI 与重定向
|
|
336
|
+
const verifyApiUrl = (function () {
|
|
337
|
+
try {
|
|
338
|
+
if (document.baseURI && document.querySelector('base')) {
|
|
339
|
+
return new URL('api/remote-mobile/verify', document.baseURI).toString();
|
|
340
|
+
}
|
|
341
|
+
const pathname = window.location.pathname.replace(/\/auth\/?$/, '/');
|
|
342
|
+
return new URL('api/remote-mobile/verify', window.location.origin + pathname).toString();
|
|
343
|
+
} catch (e) {
|
|
344
|
+
return '/api/remote-mobile/verify';
|
|
345
|
+
}
|
|
346
|
+
})();
|
|
347
|
+
|
|
348
|
+
const successRedirectUrl = (function () {
|
|
349
|
+
try {
|
|
350
|
+
// 预留参数能力:支持通过 ?redirect=/path 携带登录后重定向目标
|
|
351
|
+
const redirectParam = urlParams.get('redirect');
|
|
352
|
+
if (redirectParam && redirectParam.startsWith('/') && !redirectParam.startsWith('//')) {
|
|
353
|
+
const appPrefix = window.location.pathname.replace(/\/auth\/?$/, '');
|
|
354
|
+
if (appPrefix && redirectParam.startsWith(appPrefix)) {
|
|
355
|
+
return window.location.origin + redirectParam;
|
|
356
|
+
}
|
|
357
|
+
return window.location.origin + appPrefix + redirectParam;
|
|
358
|
+
}
|
|
359
|
+
if (document.baseURI && document.querySelector('base')) {
|
|
360
|
+
return new URL('./', document.baseURI).toString();
|
|
361
|
+
}
|
|
362
|
+
const appPrefix = window.location.pathname.replace(/\/auth\/?$/, '/');
|
|
363
|
+
return window.location.origin + appPrefix;
|
|
364
|
+
} catch (e) {
|
|
365
|
+
return '/';
|
|
366
|
+
}
|
|
367
|
+
})();
|
|
368
|
+
|
|
335
369
|
form.addEventListener('submit', (e) => {
|
|
336
370
|
e.preventDefault();
|
|
337
371
|
const val = input.value.trim();
|
|
@@ -347,7 +381,7 @@ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
|
|
|
347
381
|
|
|
348
382
|
try {
|
|
349
383
|
const payload = await encryptCredential(credential);
|
|
350
|
-
const res = await fetch(
|
|
384
|
+
const res = await fetch(verifyApiUrl, {
|
|
351
385
|
method: 'POST',
|
|
352
386
|
headers: { 'Content-Type': 'application/json' },
|
|
353
387
|
body: JSON.stringify(payload)
|
|
@@ -358,7 +392,7 @@ export function getLoginPageHtml(publicKeyPem, lang = 'zh') {
|
|
|
358
392
|
statusMsg.innerText = ${JSON.stringify(i18n.success)};
|
|
359
393
|
statusMsg.style.display = 'block';
|
|
360
394
|
setTimeout(() => {
|
|
361
|
-
window.location.href =
|
|
395
|
+
window.location.href = successRedirectUrl;
|
|
362
396
|
}, 600);
|
|
363
397
|
} else {
|
|
364
398
|
statusMsg.className = 'status-msg error';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-mobile",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "DeepSeek Harness 远程与移动端安全网关插件:零修改 DSH 底层代码安全开放局域网与 Tailscale 连接 | DeepSeek Harness (DSH) Remote & Mobile Security Guard: safely opens Tailscale/LAN with zero core modifications, QR scan auth, RSA encryption & brute-force defense.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|