@feiyang666/dsh-mobile-remote 1.7.1 → 1.8.1
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 +72 -0
- package/README.md +3 -1
- package/README.zh.md +3 -1
- package/RELEASE_NOTES.md +56 -13
- package/lib/client.js +9 -5
- package/lib/external.js +40 -38
- package/lib/index.js +324 -66
- package/lib/patch.js +244 -0
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# 更新日志 / Changelog
|
|
2
2
|
|
|
3
|
+
## v1.8.1 (2026-10-07)
|
|
4
|
+
|
|
5
|
+
### 修复 / Fixed
|
|
6
|
+
|
|
7
|
+
- **密钥(launch token)没被携带,手机 / 外网打开链接即 401**(`lib/index.js`):这是本版最关键的修复。
|
|
8
|
+
- **根因**:`withAuthUrl()` 只用严格模式 `ctx.get('connection')` 取宿主服务。cordis 的
|
|
9
|
+
`ctx.get(name)` 默认 `strict=true`,当插件 fiber 未把 `connection` 列入 `inject` 时可能取不到,
|
|
10
|
+
函数便静默回退成「裸地址」——于是 `url` / `lanUrls` / `external.url` 全都不带 `?token=`,
|
|
11
|
+
手机扫码后落到官方 401「dsh web authentication required」。
|
|
12
|
+
- 新增 `resolveConnection()`:严格模式 → 非严格 `ctx.get('connection', false)` → `ctx.connection`
|
|
13
|
+
三级回退;新增 `resolveBrowserAuth()`:`conn.browserAuth` 不可见时直接复用
|
|
14
|
+
`HostConnectionService`(它本身就是 `BrowserAuth` 的委托层,具备 `authorizeIndex` /
|
|
15
|
+
`authenticatedUrl` / `isAuthenticated`)。
|
|
16
|
+
- **外网经反代时协议不一致导致 token 在跳转中丢失**(`lib/index.js`):裸地址自动登录原本生成
|
|
17
|
+
`http://<host>/?token=…` 绝对地址。经 nginx / frp 反代时本进程只看到明文 HTTP,发出的 http:// 会被
|
|
18
|
+
反代 301 回 https,且 token 在跨协议跳转中被丢掉,握手永远完不成。现改为**相对地址跳转**
|
|
19
|
+
(`Location: /?token=…`),由浏览器按当前协议与 authority 自行解析,http / https / 反代子路径全部正确;
|
|
20
|
+
同时不再解析重编码 token,直接透传原始 query,避免 base64url 的编码歧义。
|
|
21
|
+
- **外网隧道指向过期端口,导致 502 Bad Gateway**(`lib/external.js`):`autoRestoreExternal()`
|
|
22
|
+
把持久化的 `localPort`(旧值如 3080)当显式端口传给 `startExternal()`,覆盖了当前真实端口,
|
|
23
|
+
frpc 于是转发到无人监听的端口(日志:`connect to local service 127.0.0.1:3080 ... refused`)。
|
|
24
|
+
现在一律以**当前真实监听端口**为准,并在写 `frpc.toml` 前校验代理 `localPort` 是否与之一致,不一致直接中止并报错。
|
|
25
|
+
- **`connection.trustedHosts` 从未真正生效,手机 / 外网功能全 403**(`lib/patch.js`、`lib/external.js`):
|
|
26
|
+
旧实现写的是 `trustedHosts: !!js "['域名', ...ctx.webRuntime.trustedHosts]"`,但 dsh 0.1.7 / 0.2.x 的
|
|
27
|
+
`webStartup` 服务**并不存在 `webRuntime` 字段**,表达式求值成对象而非数组,被 connection 的
|
|
28
|
+
`z.array(String)` 拒绝——表现为「页面能打开,但所有 API 403 forbidden」。
|
|
29
|
+
- **开关开启时若探测不到局域网 IP,会丢掉 connection 块**(`lib/patch.js`):`lanAddresses()`
|
|
30
|
+
在 ESM 下残留了 `require('node:os')`,抛出的 `ReferenceError` 被外层 `catch {}` 静默吞掉,
|
|
31
|
+
永远返回空列表。已改为顶层 `import`,并把 `npm run check` 补上 `lib/patch.js`
|
|
32
|
+
(此前该文件根本没被语法检查覆盖,才让上述错误长期潜伏)。
|
|
33
|
+
- **`302` 跳到自身造成无限重定向循环**(`lib/index.js`):取不到带 token 的地址时原本回退成
|
|
34
|
+
`location: '/'`,浏览器会反复请求自己。现改为明确返回 401,交回官方语义。
|
|
35
|
+
|
|
36
|
+
### 新增 / New
|
|
37
|
+
|
|
38
|
+
- **`lib/patch.js`**:统一维护 profile `cordis.patch.yml` 的受管区域(幂等整体重写)。
|
|
39
|
+
「开启远程控制」现在一次性写两块——`webserver.host = '0.0.0.0'`(手机能连上)与
|
|
40
|
+
`connection.trustedHosts = ['192.168.x.x:port', …]`(手机能调 API),并用
|
|
41
|
+
`normalizeAuthority()` 保证每个 authority 都是能通过 `assertTrustedAuthority` 的规范形式;
|
|
42
|
+
外网域名由 `ensureTrustedAuthority()` 增量并入同一区域,不再产生互相冲突的重复块。
|
|
43
|
+
- **端口自适应**(`lib/index.js`):每 15s 跟随 `ctx.webServer.port`,端口变化(改 `--port`、
|
|
44
|
+
被占用回退到 OS 分配、profile patch 变更)时自动把外网隧道重绑到新端口。
|
|
45
|
+
- **`tests/patch.test.mjs`**:针对 patch 读写的功能测试,覆盖 authority 规范化、
|
|
46
|
+
受管块幂等重写、域名增量并入、关闭时整块移除等 32 项断言。
|
|
47
|
+
|
|
48
|
+
### 变更 / Changed
|
|
49
|
+
|
|
50
|
+
- 未授权裸地址若既取不到 token 也无法安全自动登录,返回明确的 401,而不是把
|
|
51
|
+
「dsh web is starting」这种误导性 503 给到手机端。
|
|
52
|
+
- 兼容性不变:老版 dsh(无 `connection` / 无浏览器会话认证)仍按原行为回退。
|
|
53
|
+
|
|
54
|
+
## v1.8.0 (2026-09-06)
|
|
55
|
+
|
|
56
|
+
### 新增 / New
|
|
57
|
+
|
|
58
|
+
- **裸地址直达(自动登录)**(`lib/index.js`):本插件现在接管 `GET /`。手机 / 远程设备**直接访问裸地址**(如 `http://192.168.3.119:3080`,不带 token)即可自动进入——
|
|
59
|
+
- 未授权访问 → 服务端 `302` 到带 token 的 URL(token 由服务端自动注入)→ 浏览器自动跟随 → 官方完成 `303 + Set-Cookie` → 自动回到裸 `/`;
|
|
60
|
+
- 已授权设备访问裸地址 → 直接返回官方完整 UI;
|
|
61
|
+
- 官方 token → cookie 的握手**一步未绕开**,只是把「手动重开带 token 链接」自动化,全程对用户无感,地址栏始终是干净裸地址。
|
|
62
|
+
- **新版 dsh 浏览器会话认证适配**(`lib/index.js` + `lib/client.js`):dsh `>=0.1.2-rc.1` 对 Web UI 强制「浏览器会话认证」(启动时打印一次性令牌,任何无 cookie 访问返回 401)。本插件自动兼容:
|
|
63
|
+
- `GET /` 由插件接管:首次裸地址访问自动完成授权,无需手动带 token;
|
|
64
|
+
- `/__dsh_remote/status` 返回的 `url` / `lanUrls` / `external.url` 同时携带官方认证 token(程序化调用仍可用),移动端 UI / 二维码 / 地址列表展示干净裸地址并提示「首次访问自动完成连接授权」;
|
|
65
|
+
- 老版 dsh(无认证 / 无 `connection` 服务)自动回退:`url` / `lanUrls` 为普通裸地址、`/` 直接返回官方 index,行为与旧版完全一致。
|
|
66
|
+
|
|
67
|
+
### 修复 / Fixed
|
|
68
|
+
- 无(行为变更见下)。
|
|
69
|
+
|
|
70
|
+
### 变更 / Changed
|
|
71
|
+
- **接管 `GET /`**:为避免与官方 index owner 冲突,插件在内部拉取官方 index(含全部官方 index taps 注入)时临时注销自身的 `/` 路由,取回后重新注册;插件卸载 / 服务停止时正确清理。
|
|
72
|
+
- 手机访问地址 / 二维码 / 局域网列表改展示**裸地址**(配合自动登录,首次访问即可用),不再显示一长串 `?token=`。
|
|
73
|
+
- 安全提示:裸地址自动登录等效于「局域网内能访问到该地址的设备都会自动获得授权」。若需要更强隔离,请在插件设置中开启**远程访问密码门禁**(对外网隧道生效)并仅在可信网络使用。
|
|
74
|
+
|
|
3
75
|
## v1.7.1 (2026-08-29)
|
|
4
76
|
|
|
5
77
|
### 新增 / New
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
# DeepSeek Harness Mobile Remote Control (dsh-mobile-remote)
|
|
3
|
+
# DeepSeek Harness Mobile Remote Control (dsh-mobile-remote)
|
|
4
4
|
|
|
5
5
|
**English** · [简体中文](./README.zh.md)
|
|
6
6
|
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
- **Mobile image upload** (v1.5.0): a new **"Upload image" button next to the + in the composer toolbar** lets phone users pick photos (system gallery / file picker) for DeepSeek's vision models — format/count/size pre-checks, thumbnail rail, and send-time upload all reuse DSH's native pipeline; the desktop UI is untouched.
|
|
24
24
|
- **Connection QR code**: auto-generates a QR for `http://<lan-ip>:<port>` — scan it with your phone's camera / browser to connect.
|
|
25
|
+
- **Bare-URL direct access with auto sign-in** (v1.8.0): just open `http://192.168.x.x:3080` (no token) and it enters automatically — the server transparently performs the new dsh browser-session handshake (`dsh >= 0.1.2-rc.1`); older dsh versions keep their original behavior.
|
|
25
26
|
- **On / Off toggle**: switches the webserver listen address (`0.0.0.0` ↔ `127.0.0.1`) with one click. Applied via dsh's official HMR hot-reload — **no service restart needed**.
|
|
26
27
|
- **Connected device count**: mobile heartbeats report online devices in real time.
|
|
27
28
|
- **Remote access password gate** (v1.2.0+): set a password locally; external-tunnel visitors must enter it before the page loads — local/LAN access stays password-free.
|
|
@@ -201,6 +202,7 @@ Then restart the app.
|
|
|
201
202
|
| Symptom | Cause / Fix |
|
|
202
203
|
| --- | --- |
|
|
203
204
|
| No "Remote Control" panel in Settings | Plugin not activated. Check the `cordis.patch.yml` row exists with the right `name`; restart and hard-refresh |
|
|
205
|
+
| Phone opening the bare URL shows "authentication required" | Running a pre-v1.8.0 plugin, or the server hasn't loaded this plugin's `/` takeover. Install v1.8.0+ and **restart the dsh web service** (on old dsh without auth this plugin falls back to its previous behavior) |
|
|
204
206
|
| Phone still can't connect after toggling ON | Same Wi-Fi? Firewall allowing port 3080? Accessing the LAN IP (not `127.0.0.1`)? |
|
|
205
207
|
| Phone connects but shows 403 | dsh's trust fence: make sure the plugin is installed and dsh is started the supported way (`--patch` / profile patch overriding `webserver.host`) |
|
|
206
208
|
| Device count stays 0 | The phone page must be opened once to start heartbeat; wait a few seconds and refresh |
|
package/README.zh.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<div align="center">
|
|
2
2
|
|
|
3
|
-
# DeepSeek Harness 移动端远程控制插件(dsh-mobile-remote
|
|
3
|
+
# DeepSeek Harness 移动端远程控制插件(dsh-mobile-remote)
|
|
4
4
|
|
|
5
5
|
[English](./README.md) | **简体中文**
|
|
6
6
|
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
|
|
23
23
|
- **移动端上传图片**(v1.5.0):composer 工具行 **+ 加号旁新增「上传图片」按钮**,手机直接选图(系统相册 / 文件选择器)即可让 DeepSeek 识图模型识别——格式 / 数量 / 大小预检查、缩略图预览、随消息上传全部复用 DSH 原生链路,桌面端不受影响;
|
|
24
24
|
- **连接二维码**:自动生成 `http://<局域网IP>:<端口>` 的二维码,手机相机 / 浏览器扫码即连;
|
|
25
|
+
- **裸地址直达,自动登录**(v1.8.0):直接访问裸地址 `http://192.168.x.x:3080`(不带任何 token)即可自动进入——服务端自动完成新版 dsh 的浏览器会话认证(`≥0.1.2-rc.1`),无需复制带 `?token=` 的长链接;老版 dsh 自动回退,行为不变;
|
|
25
26
|
- **开启 / 关闭开关**:一键切换 webserver 监听地址(`0.0.0.0` ↔ `127.0.0.1`),经 dsh 官方 HMR 热重载**无需重启服务**;
|
|
26
27
|
- **当前连接的设备数量**:移动端心跳上报,实时统计在线设备数;
|
|
27
28
|
- **远程访问密码门禁**(v1.2.0+):管理员本机设置密码,外网隧道访问进入页面需先输密码,本机/局域网不受影响;
|
|
@@ -201,6 +202,7 @@ dsh plugin --profile web remove @feiyang666/dsh-mobile-remote
|
|
|
201
202
|
| 现象 | 原因 / 处理 |
|
|
202
203
|
| --- | --- |
|
|
203
204
|
| 设置里没有「远程控制」面板 | 插件未激活。确认 `cordis.patch.yml` 里的行存在且 `name` 正确;重启后刷新(清缓存) |
|
|
205
|
+
| 手机打开裸地址显示「authentication required / dsh web authentication required」 | 用的是 dsh `0.1.2-rc.1` 之前的老版插件,或服务端未加载本插件的 `/` 接管。确认已安装 v1.8.0+ 并**重启 dsh web 服务**(老版 dsh 无认证时按原行为返回 401 提示,可改用带 token 地址或升级 dsh) |
|
|
204
206
|
| 开启开关后手机还是连不上 | 确认手机与电脑同一 Wi-Fi;确认防火墙放行 3080 端口;确认访问的是局域网 IP 而非 `127.0.0.1` |
|
|
205
207
|
| 手机能访问但显示 403 | dsh 的信任围栏:请确认本插件已安装、且以官方支持的方式启动(`--patch` / profile patch 覆盖 `webserver.host`) |
|
|
206
208
|
| 设备数为 0 | 手机端页面需打开过才会心跳上报;等待几秒刷新 |
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,7 +1,36 @@
|
|
|
1
|
-
# dsh-mobile-remote v1.
|
|
1
|
+
# dsh-mobile-remote v1.8.1
|
|
2
2
|
|
|
3
3
|
**DeepSeek Harness 移动端远程控制插件** —— 在 Web UI 设置页内置「远程控制」:连接二维码、一键开关、在线设备数,让手机通过局域网访问并操控电脑上的 DeepSeek Harness。移动端提供上传图片入口、底部抽屉式模型选择、发送状态提示等移动优先体验。
|
|
4
4
|
|
|
5
|
+
## 本版更新(v1.8.1)—— 修复「链接不带密钥、手机/外网打不开」
|
|
6
|
+
|
|
7
|
+
v1.8.0 引入的自动登录在**新版 dsh(0.1.7 / 0.2.x)上实际是坏的**,本版把整条链路修通并做了端到端验证。
|
|
8
|
+
|
|
9
|
+
### 修复了什么
|
|
10
|
+
|
|
11
|
+
| 症状 | 根因 |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| 打开链接**不带 `?token=`**,手机扫码即 401 | `withAuthUrl()` 只试严格模式的 `ctx.get('connection')`;cordis 默认 `strict=true`,插件 fiber 未 inject `connection` 时取不到,便静默退回裸地址 |
|
|
14
|
+
| 外网经反代打开失败、`/api` 一直 401 | 自动登录原本生成 `http://<host>/?token=…`,被 nginx 301 回 https 时 **token 被丢掉**,握手从未发生 |
|
|
15
|
+
| 外网域名 **502 Bad Gateway** | `autoRestoreExternal()` 拿持久化的旧 `localPort`(3080)覆盖当前端口,frpc 转发到无人监听的端口 |
|
|
16
|
+
| 页面能打开但**功能全 403 forbidden** | patch 里写的 `...ctx.webRuntime.trustedHosts` —— 该字段在 0.1.7/0.2.x **不存在**,表达式求值成对象,被 `z.array(String)` 拒绝,`trustedHosts` 等于从未生效 |
|
|
17
|
+
| 开启开关后连接块凭空消失 | `lanAddresses()` 在 ESM 下残留 `require('node:os')`,`ReferenceError` 被空 `catch` 吞掉,永远返回 `[]` |
|
|
18
|
+
|
|
19
|
+
### 关键改动
|
|
20
|
+
|
|
21
|
+
- **密钥稳定携带**:`resolveConnection()` 三级回退(严格 → 非严格 → `ctx.connection`),`resolveBrowserAuth()` 在 `browserAuth` 不可见时直接复用 `HostConnectionService`(它本身就是 `BrowserAuth` 的委托层)。
|
|
22
|
+
- **相对地址跳转**:裸地址自动登录改为 `Location: /?token=…`,由浏览器按当前协议解析,http / https / 反代子路径全部正确,且不再重编码 token。
|
|
23
|
+
- **端口自适应**:始终以当前真实监听端口为准;写 `frpc.toml` 前校验端口一致(不一致直接中止报错);运行中端口变化会在 15s 内自动重绑隧道。
|
|
24
|
+
- **`trustedHosts` 真正生效**:新增 `lib/patch.js` 统一维护 profile patch 受管区域,开启开关时同时写 `webserver.host='0.0.0.0'` 与 `connection.trustedHosts=['192.168.x.x:port', …]`(经 `normalizeAuthority` 规范化为可通过 `assertTrustedAuthority` 的形式),外网域名增量并入同一区域。
|
|
25
|
+
- **修复无限重定向**:取不到 token 时不再 `302` 到自身,改为明确 `401`。
|
|
26
|
+
|
|
27
|
+
### 验证结果
|
|
28
|
+
|
|
29
|
+
在 **dsh 0.2.0-rc.2** 上做了端到端验证,**26/26 全部通过**:
|
|
30
|
+
|
|
31
|
+
- 局域网 `http://192.168.3.119:19387` 与外网 `https://cwj.dsh.cwj666.top` 两条链路均:裸地址 `302` 自动登录(相对地址、带 token)→ `303` + `Set-Cookie`(`dsh-auth-*`,`HttpOnly`)→ 带 cookie 拿到 `200` 完整 UI(含移动端补丁)→ `/api` 通过信任围栏与认证;
|
|
32
|
+
- 安全回归:错误 token 拒绝且不发 cookie;无凭证 `/api` 被拒;无 token 访问 `/` 不会直接吐出 UI。
|
|
33
|
+
|
|
5
34
|
## 安装方式
|
|
6
35
|
|
|
7
36
|
```bash
|
|
@@ -10,28 +39,42 @@ dsh plugin --profile web add @feiyang666/dsh-mobile-remote
|
|
|
10
39
|
|
|
11
40
|
安装完成后**重启 dsh web 服务**,打开 `http://127.0.0.1:3080` → **设置 → 远程控制** 即可使用。
|
|
12
41
|
|
|
13
|
-
##
|
|
42
|
+
## v1.8.0 功能回顾 —— 裸地址直达(自动登录)
|
|
43
|
+
|
|
44
|
+
> **配合新版 DeepSeek Harness(dsh ≥ 0.1.2)的浏览器会话认证**,v1.8.0 引入「裸地址直达,自动登录」:手机直接输裸地址就能进,全程无感。
|
|
45
|
+
|
|
46
|
+
### 裸地址直达(自动登录)
|
|
47
|
+
|
|
48
|
+
以往访问 `http://192.168.x.x:3080` 会看到官方 401「authentication required」,必须先复制一长串带 `?token=` 的链接。现在:
|
|
49
|
+
|
|
50
|
+
- 手机 / 远程设备**直接打开裸地址**(`http://192.168.3.119:3080`)→ 插件服务端自动完成官方认证(302 → token → 303 + Set-Cookie)→ **自动进入**,地址栏始终是干净裸地址;
|
|
51
|
+
- 已授权设备访问裸地址 → 直接返回完整官方 UI;
|
|
52
|
+
- 官方 token → cookie 握手**一步未绕开**,只是把手动重开带 token 链接变成自动化,30 天会话到期后再次访问也会自动授权,依然无感。
|
|
53
|
+
|
|
54
|
+
### 兼容新版 dsh 浏览器会话认证
|
|
14
55
|
|
|
15
|
-
|
|
56
|
+
dsh `>=0.1.2-rc.1` 对 Web UI 强制浏览器会话认证,本插件已完整适配:
|
|
16
57
|
|
|
17
|
-
|
|
58
|
+
- 插件接管 `GET /`:首次裸地址访问自动完成授权;内部取回官方 index(含全部官方注入),插件卸载时正确清理;
|
|
59
|
+
- 手机访问地址 / 二维码 / 局域网地址列表全部展示干净裸地址,并提示「首次访问自动完成连接授权」;
|
|
60
|
+
- `/__dsh_remote/status` 的 `url` / `lanUrls` / `external.url` 同时携带官方 token(程序化调用仍可用);
|
|
61
|
+
- **老版 dsh 完全兼容**:无认证 / 无 `connection` 时自动回退为旧行为,升级无风险。
|
|
18
62
|
|
|
19
|
-
|
|
20
|
-
- **心跳优先走 WS**:WS 已连上时心跳经长连接送达(零 HTTP 请求),未连上才回退 HTTP 兜底;
|
|
21
|
-
- **状态查询 60s 节流**:即使移动端每 5s 轮询本机 `/status`,也不会高频打中转服务器;前端优先读 WS 推送缓存;
|
|
22
|
-
- **ws 依赖缺失自动降级**:`ws` 未安装时 WS 客户端 no-op,心跳与状态查询回退 HTTP 低频兜底,**功能不中断**。
|
|
63
|
+
### 安全提示
|
|
23
64
|
|
|
24
|
-
|
|
65
|
+
裸地址自动登录等效于「局域网内能访问到该地址的设备都会自动获得授权」。若需要更强隔离,请在设置中开启**远程访问密码门禁**(对外网隧道生效)并仅在可信网络使用。
|
|
25
66
|
|
|
26
|
-
##
|
|
67
|
+
## 历史更新摘要
|
|
27
68
|
|
|
28
|
-
- **v1.
|
|
29
|
-
- **v1.
|
|
69
|
+
- **v1.8.0**:裸地址直达(自动登录)+ 适配新版 dsh 浏览器会话认证(本版存在的问题见上)
|
|
70
|
+
- **v1.7.1**:外网访问面板数据通道状态一目了然(WS 实时推送 / HTTP 兜底徽标);运行时长本地每秒刷新;远程访问密码界面重做(DeepSeek 官网风格 + 服务信息面板 + 加载动画);清除 / 修改密码需验证当前密码;修复 WS 双重序列化导致一直「等待推送」
|
|
71
|
+
- **v1.7.0**:中转服务器 WS 状态推送客户端(长连接替代每 5s HTTP 轮询,指数退避重连,ws 缺失自动降级 HTTP 兜底)
|
|
30
72
|
|
|
31
73
|
## 已知事项
|
|
32
74
|
|
|
33
|
-
-
|
|
75
|
+
- 开启后服务暴露到局域网,**请仅在可信网络使用**;公网 / 外网访问请务必开启远程访问密码
|
|
34
76
|
- 外网隧道需确保服务器 `.env` 的 `FRP_TOKEN` 与 `/www/server/frps/frps.toml` 的 `auth.token` 完全一致,否则面板会明确提示鉴权失败
|
|
77
|
+
- 若使用的是 dsh `0.1.2-rc.1` 之前的老版本,本插件的裸地址 / 自动登录行为自动回退,功能不受影响
|
|
35
78
|
|
|
36
79
|
## 变更日志
|
|
37
80
|
|
package/lib/client.js
CHANGED
|
@@ -345,10 +345,13 @@ window.__ModuleLoader__.load({
|
|
|
345
345
|
style: Object.assign({}, st.switch, on ? st.switchOn : null),
|
|
346
346
|
}, el('span', { style: Object.assign({}, st.knob, on ? st.knobOn : null) }));
|
|
347
347
|
|
|
348
|
-
//
|
|
348
|
+
// 局域网二维码(裸地址:插件已在服务端接管 / 自动完成认证,无需带 token)
|
|
349
|
+
var bareLanUrl = (status && status.lanAddresses && status.lanAddresses.length && status.port)
|
|
350
|
+
? ('http://' + status.lanAddresses[0] + ':' + status.port)
|
|
351
|
+
: (status && status.url || null);
|
|
349
352
|
var qrEl;
|
|
350
|
-
if (on &&
|
|
351
|
-
var qrSrc = '/__dsh_remote/qr?url=' + encodeURIComponent(
|
|
353
|
+
if (on && bareLanUrl) {
|
|
354
|
+
var qrSrc = '/__dsh_remote/qr?url=' + encodeURIComponent(bareLanUrl);
|
|
352
355
|
qrEl = el('div', { style: st.qrBox },
|
|
353
356
|
el('img', { src: qrSrc, alt: '连接二维码', style: st.qrImg }));
|
|
354
357
|
} else if (status) {
|
|
@@ -698,8 +701,8 @@ window.__ModuleLoader__.load({
|
|
|
698
701
|
el('p', { style: st.title }, '手机连接'),
|
|
699
702
|
el('p', { style: st.sub }, '手机与电脑连接同一 Wi-Fi,用相机或浏览器扫码访问')),
|
|
700
703
|
qrEl,
|
|
701
|
-
|
|
702
|
-
? el('a', { href:
|
|
704
|
+
bareLanUrl
|
|
705
|
+
? el('a', { href: bareLanUrl, style: st.link, target: '_blank', rel: 'noreferrer' }, bareLanUrl)
|
|
703
706
|
: el('p', { style: st.hint }, fmtUrl(status && status.url))),
|
|
704
707
|
|
|
705
708
|
// 统计
|
|
@@ -779,6 +782,7 @@ window.__ModuleLoader__.load({
|
|
|
779
782
|
status && status.lanAddresses.length
|
|
780
783
|
? el('div', { style: st.card },
|
|
781
784
|
el('p', { style: st.title }, '手机访问地址'),
|
|
785
|
+
el('p', { style: st.hint }, '直接打开即可,首次访问会自动完成连接授权'),
|
|
782
786
|
el('pre', { style: st.code },
|
|
783
787
|
status.lanAddresses.map(function (ip) { return 'http://' + ip + ':' + status.port; }).join('\n')))
|
|
784
788
|
: null,
|
package/lib/external.js
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from 'node:fs'
|
|
25
25
|
import { spawn } from 'node:child_process'
|
|
26
26
|
import { createWsStatusClient } from './ws-client.js'
|
|
27
|
+
import { ensureTrustedAuthority } from './patch.js'
|
|
27
28
|
|
|
28
29
|
/** 默认中转服务地址(自建 dsh-update-server,Nginx 反代 443→3200,对外无端口)。
|
|
29
30
|
* 可用 DSH_REMOTE_SERVER 覆盖。 */
|
|
@@ -192,44 +193,22 @@ async function downloadFrpc(base) {
|
|
|
192
193
|
}
|
|
193
194
|
|
|
194
195
|
/**
|
|
195
|
-
*
|
|
196
|
+
* 放行外网域名(加入受管区域的 connection.trustedHosts)。
|
|
197
|
+
*
|
|
198
|
+
* 旧实现写的是 `trustedHosts: !!js "['域名', ...ctx.webRuntime.trustedHosts]"`,
|
|
199
|
+
* 但 dsh 0.1.7 / 0.2.x 的 `webStartup` 服务(见 dsh-web-app/lib/startup.js)只提供
|
|
200
|
+
* `openBrowser / host / port / trustedHosts`,**并不存在 `webRuntime`**。该表达式
|
|
201
|
+
* 因此会抛错或求值成一个对象而非数组,被 connection 的 `z.array(String)` 拒绝,
|
|
202
|
+
* 信任围栏等于从未真正放行——这正是「外网/局域网能打开页面但功能全 403」的根因。
|
|
203
|
+
*
|
|
204
|
+
* 现在改为由 lib/patch.js 统一维护:写出真实的字符串数组,并与 webserver 开关
|
|
205
|
+
* 同处一个受管区域,避免多次追加产生冲突块。
|
|
196
206
|
* @param {string} patchFile - profile 的 cordis.patch.yml 绝对路径
|
|
197
207
|
* @param {string} domain - 外网域名(不带端口)
|
|
198
208
|
* @returns {boolean}
|
|
199
209
|
*/
|
|
200
|
-
/**
|
|
201
|
-
* 写 profile 的 connection trustedHosts,放行外网域名。
|
|
202
|
-
* 必须使用 loader 中的正确插件 id `connection`,并用 !!js 表达式保留
|
|
203
|
-
* webRuntime 自动推导的 LAN/CLI trusted hosts,否则 patch 不生效 / 覆盖损坏。
|
|
204
|
-
*/
|
|
205
210
|
function ensureTrustedHost(patchFile, domain) {
|
|
206
|
-
|
|
207
|
-
// 移除官方模板的空数组占位行 `[]`(与 index.js setPatchEnabled 同理):
|
|
208
|
-
// 它单独构成一个 YAML 文档,与追加的列表块拼接会形成无分隔符的多文档流,
|
|
209
|
-
// js-yaml 解析报 "end of the stream or a document separator is expected"。
|
|
210
|
-
content = content.replace(/^\s*\[\s*\]\s*$/gm, '')
|
|
211
|
-
// 1) 清理旧的错误块(id: client-connection 或旧格式的纯数组),避免堆积/语法冲突
|
|
212
|
-
content = content.replace(/\n*(# --- dsh-mobile-remote external trusted-host ---)\n[\s\S]*?(# --- end dsh-mobile-remote external trusted-host ---)\n*/g, '\n')
|
|
213
|
-
content = content.replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
|
|
214
|
-
// 2) 若已含目标域名的正确配置则直接返回
|
|
215
|
-
if (content.includes(`'${domain}'`)) return true
|
|
216
|
-
// 3) 追加正确块:id=connection + !!js 表达式(保留 webRuntime.trustedHosts)
|
|
217
|
-
// 注意:dsh-app-boot 的 entryListSchema(js-yaml JSON_SCHEMA + JsExpr)中 !!js 为
|
|
218
|
-
// scalar kind,只能修饰标量字符串。若直接写在 flow sequence 前(!!js [...]),
|
|
219
|
-
// js-yaml 会报 `unknown tag !<tag:yaml.org,2002:js>`,导致 profile 无法启动。
|
|
220
|
-
// 因此表达式必须用双引号包成 YAML 标量,loader 求值时再还原为数组。
|
|
221
|
-
const block = [
|
|
222
|
-
'',
|
|
223
|
-
'# --- dsh-mobile-remote external trusted-host ---',
|
|
224
|
-
'- id: connection',
|
|
225
|
-
' config:',
|
|
226
|
-
` trustedHosts: !!js "['${domain}', ...ctx.webRuntime.trustedHosts]"`,
|
|
227
|
-
'# --- end dsh-mobile-remote external trusted-host ---',
|
|
228
|
-
'',
|
|
229
|
-
]
|
|
230
|
-
const next = (content + '\n' + block.join('\n')).replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
|
|
231
|
-
writeFileSync(patchFile, next, 'utf8')
|
|
232
|
-
return true
|
|
211
|
+
return ensureTrustedAuthority(patchFile, domain)
|
|
233
212
|
}
|
|
234
213
|
|
|
235
214
|
/** frpc 进程 pid 文件路径(用于清理残留实例,避免多进程抢同一代理名)。 */
|
|
@@ -395,7 +374,10 @@ async function startExternal(state, body, patchFile, defaultLocalPort) {
|
|
|
395
374
|
}
|
|
396
375
|
|
|
397
376
|
async function startExternalInner(state, body, patchFile, defaultLocalPort) {
|
|
398
|
-
|
|
377
|
+
// 端口优先级:调用方显式指定 > 当前真实监听端口。
|
|
378
|
+
// 不再让过期的持久化 localPort 覆盖当前端口(否则 frpc 会转发到无人监听的端口)。
|
|
379
|
+
const localPort = Number((body && body.localPort) || defaultLocalPort || 0)
|
|
380
|
+
const data = await bindToServer(body, localPort)
|
|
399
381
|
const base = serverBase(body)
|
|
400
382
|
const domain = data.externalDomain
|
|
401
383
|
|| String(data.externalUrl || '').replace(/^https?:\/\//, '').split(':')[0]
|
|
@@ -419,7 +401,17 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
|
|
|
419
401
|
|
|
420
402
|
// 2) 写 frpc.toml(含 loginFailExit=false 与回环地址覆写)
|
|
421
403
|
const cfgPath = frpcConfigPath()
|
|
422
|
-
|
|
404
|
+
const frpcConfig = finalizeFrpcConfig(data.frpcConfig)
|
|
405
|
+
// 防线:确认代理确实指向当前监听端口。错配时 frpc 会连不上本地服务,
|
|
406
|
+
// 表现为外网域名 502 Bad Gateway(日志:connect to local service ... refused)。
|
|
407
|
+
if (localPort) {
|
|
408
|
+
const m = /localPort\s*=\s*(\d+)/.exec(frpcConfig)
|
|
409
|
+
const proxyPort = m ? Number(m[1]) : 0
|
|
410
|
+
if (proxyPort && proxyPort !== localPort) {
|
|
411
|
+
throw new Error(`frpc 配置的 localPort=${proxyPort} 与当前监听端口 ${localPort} 不一致,已中止以避免外网 502`)
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
writeFileSync(cfgPath, frpcConfig, 'utf8')
|
|
423
415
|
|
|
424
416
|
// 2.5) 清理残留旧 frpc 实例,避免多个进程抢同一代理名(proxy already exists)
|
|
425
417
|
killStaleFrpc()
|
|
@@ -480,7 +472,7 @@ async function startExternalInner(state, body, patchFile, defaultLocalPort) {
|
|
|
480
472
|
// 记录实际生效的绑定参数并持久化,dsh 重启后由 autoRestoreExternal 自动恢复
|
|
481
473
|
state.bindCode = (body && body.bindCode) || state.bindCode || ''
|
|
482
474
|
state.serverBase = base
|
|
483
|
-
state.localPort =
|
|
475
|
+
state.localPort = localPort
|
|
484
476
|
savePersistedState(state)
|
|
485
477
|
// 建立 WS 长连接接收隧道状态推送(替代每 5s HTTP 轮询中转服务器);
|
|
486
478
|
// ws 依赖缺失(离线安装)时 wsClient.connect() 自动 no-op,心跳走 HTTP 兜底
|
|
@@ -613,12 +605,22 @@ async function autoRestoreExternal(state, patchFile, defaultLocalPort) {
|
|
|
613
605
|
const saved = loadPersistedState()
|
|
614
606
|
if (!saved) return
|
|
615
607
|
try {
|
|
608
|
+
// 端口以「当前真实监听端口」为准。
|
|
609
|
+
// 持久化的 localPort 可能来自上一次运行(例如默认 3080,或用户改过 --port),
|
|
610
|
+
// 若直接当成显式端口传下去,会覆盖当前端口,让 frpc 转发到一个没人监听的
|
|
611
|
+
// 端口上——表现为外网域名 502 Bad Gateway(frpc 日志:connect to local
|
|
612
|
+
// service 127.0.0.1:3080 refused)。这里只沿用与当前端口一致的旧值,
|
|
613
|
+
// 不一致时改用当前端口。
|
|
614
|
+
const live = Number(defaultLocalPort) || 0
|
|
615
|
+
const stale = Number(saved.localPort) || 0
|
|
616
616
|
const body = {
|
|
617
617
|
bindCode: saved.bindCode,
|
|
618
618
|
serverBase: saved.serverBase || undefined,
|
|
619
|
-
localPort: saved.localPort,
|
|
620
619
|
}
|
|
621
|
-
|
|
620
|
+
if (stale && live && stale !== live) {
|
|
621
|
+
state.lastLog = `检测到端口变化(持久化 ${stale} → 当前 ${live}),外网隧道已改用当前端口`
|
|
622
|
+
}
|
|
623
|
+
await startExternal(state, body, patchFile, live || stale)
|
|
622
624
|
} catch (e) {
|
|
623
625
|
state.status = 'error'
|
|
624
626
|
state.error = `自动恢复外网访问失败:${String((e && e.message) || e)}`
|
package/lib/index.js
CHANGED
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, rmSync,
|
|
34
34
|
} from 'node:fs'
|
|
35
35
|
import { spawn } from 'node:child_process'
|
|
36
|
+
import http from 'node:http'
|
|
36
37
|
import qrcode from './qrcode.js'
|
|
37
38
|
import {
|
|
38
39
|
startExternal, stopExternal, externalView,
|
|
@@ -44,6 +45,10 @@ import {
|
|
|
44
45
|
isEnabled, login, verifyToken, revokeToken, clearSessions,
|
|
45
46
|
} from './password.js'
|
|
46
47
|
import { wsAvailable } from './ws-client.js'
|
|
48
|
+
import {
|
|
49
|
+
patchFilePath, isManagedPatchEnabled,
|
|
50
|
+
collectTrustedAuthorities, writeManagedPatch,
|
|
51
|
+
} from './patch.js'
|
|
47
52
|
|
|
48
53
|
/** 插件名(loader 诊断用)。 */
|
|
49
54
|
export const name = 'dsh-mobile-remote'
|
|
@@ -51,6 +56,170 @@ export const name = 'dsh-mobile-remote'
|
|
|
51
56
|
/** 依赖 dsh-host-webserver 注入的 webServer 服务,保证 tapIndex / register 可用。 */
|
|
52
57
|
export const inject = ['webServer']
|
|
53
58
|
|
|
59
|
+
// ------------------------------------------------------------
|
|
60
|
+
// 新版 dsh(>=0.1.2-rc.1)Web UI 浏览器会话认证适配
|
|
61
|
+
// 新版 dsh 每次启动生成一次性令牌,任何浏览器访问首页都必须先带
|
|
62
|
+
// `?token=`(服务端 303 + Set-Cookie 后跳回 /),否则返回 401。手机扫码 /
|
|
63
|
+
// 手动输入 LAN 地址访问的是同一个 dsh Web UI,同样受该认证保护。
|
|
64
|
+
// 因此本插件在返回「手机访问地址 / 二维码 URL / 外网 URL」时,若宿主提供了
|
|
65
|
+
// 官方 connection 服务(dsh-client-connection 的 BrowserAuth),就用其
|
|
66
|
+
// authenticatedUrl() 自动附加 token;老版 dsh(无 connection / 无认证)时
|
|
67
|
+
// 返回原 URL,行为与旧版完全一致。
|
|
68
|
+
// ------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* 用宿主 connection 服务的 authenticatedUrl() 给访问 URL 附加认证 token。
|
|
71
|
+
* 老版 dsh(无 connection / 未提供该方法)时原样返回,兼容旧版与命令行裸启。
|
|
72
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
73
|
+
* @param {string} base - 形如 http://192.168.1.5:3080 的裸访问地址
|
|
74
|
+
* @returns {string} 带 token 的完整访问地址(或原地址)
|
|
75
|
+
*/
|
|
76
|
+
function resolveConnection(ctx) {
|
|
77
|
+
if (!ctx) return undefined
|
|
78
|
+
// 1) cordis:ctx.get(name) 默认 strict=true,只返回「provider fiber 已激活」的服务。
|
|
79
|
+
// 插件 fiber 未 inject connection 时可能取不到,故补非严格与属性两条回退。
|
|
80
|
+
if (typeof ctx.get === 'function') {
|
|
81
|
+
try {
|
|
82
|
+
const strict = ctx.get('connection')
|
|
83
|
+
if (strict) return strict
|
|
84
|
+
} catch (e) { /* 继续回退 */ }
|
|
85
|
+
try {
|
|
86
|
+
const loose = ctx.get('connection', false)
|
|
87
|
+
if (loose) return loose
|
|
88
|
+
} catch (e) { /* 继续回退 */ }
|
|
89
|
+
}
|
|
90
|
+
// 2) 少数宿主把服务直接挂在 ctx 上(ctx.connection 在未 inject 时可能抛错)。
|
|
91
|
+
try {
|
|
92
|
+
if (ctx.connection) return ctx.connection
|
|
93
|
+
} catch (e) { /* 忽略 */ }
|
|
94
|
+
return undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function withAuthUrl(ctx, base) {
|
|
98
|
+
if (!base || typeof base !== 'string') return base
|
|
99
|
+
try {
|
|
100
|
+
const conn = resolveConnection(ctx)
|
|
101
|
+
if (conn && typeof conn.authenticatedUrl === 'function') {
|
|
102
|
+
return conn.authenticatedUrl(base)
|
|
103
|
+
}
|
|
104
|
+
} catch (e) { /* 取 connection 失败则按无认证处理,返回原 URL */ }
|
|
105
|
+
return base
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 把「带 token 的绝对地址」降级成 origin 相对的 `/?token=…`。
|
|
110
|
+
* 浏览器按当前协议 + authority 解析,因此 http / https / 反代路径前缀都能正确工作,
|
|
111
|
+
* 也避开了反代跨协议 301 丢 token 的问题。
|
|
112
|
+
* @param {string|null} absUrl - authenticatedUrl() 的返回值
|
|
113
|
+
* @returns {string|null} 形如 `/?token=xxx` 的相对地址;拿不到 token 时返回 null
|
|
114
|
+
*/
|
|
115
|
+
function relativeTokenLocation(absUrl) {
|
|
116
|
+
if (!absUrl || typeof absUrl !== 'string') return null
|
|
117
|
+
try {
|
|
118
|
+
const u = new URL(absUrl)
|
|
119
|
+
// 直接透传原始 pathname + search,不解析、不重新编码 token:
|
|
120
|
+
// token 是 base64url,经 searchParams 往返会引入 +/空格 之类的编码歧义。
|
|
121
|
+
if (!u.search || !/[?&]token=/.test(u.search)) return null
|
|
122
|
+
const path = u.pathname && u.pathname !== '' ? u.pathname : '/'
|
|
123
|
+
return `${path}${u.search}`
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return null
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 判断该请求对浏览器而言的真实协议。
|
|
132
|
+
* 外网访问通常经 nginx / frp 反代,本机看到的永远是明文 HTTP,
|
|
133
|
+
* 因此必须优先信任反代设置的 X-Forwarded-Proto,否则会生成 http:// 跳转,
|
|
134
|
+
* 被反代再 301 回 https,形成协议来回跳转。
|
|
135
|
+
* @param {import('node:http').IncomingMessage} req
|
|
136
|
+
* @returns {'http'|'https'}
|
|
137
|
+
*/
|
|
138
|
+
function requestScheme(req) {
|
|
139
|
+
try {
|
|
140
|
+
const xf = req.headers['x-forwarded-proto']
|
|
141
|
+
if (typeof xf === 'string' && xf.trim()) {
|
|
142
|
+
// 可能是 "https, http" 这样的列表,取最外层(第一个)。
|
|
143
|
+
const first = xf.split(',')[0].trim().toLowerCase()
|
|
144
|
+
if (first === 'https' || first === 'http') return first
|
|
145
|
+
}
|
|
146
|
+
// 直连时按 socket 是否 TLS 判断(本插件自身不终结 TLS,通常为 http)。
|
|
147
|
+
if (req.socket && req.socket.encrypted) return 'https'
|
|
148
|
+
} catch (e) { /* 忽略,回落 http */ }
|
|
149
|
+
return 'http'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* 取宿主的浏览器会话认证器(connection.browserAuth)。
|
|
154
|
+
* 新版 dsh(>=0.1.2)由它完成 token → cookie 的官方握手;
|
|
155
|
+
* HostConnectionService 自身即 BrowserAuth 的委托层,字段不可见时可直接复用。
|
|
156
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
157
|
+
* @returns {object|undefined}
|
|
158
|
+
*/
|
|
159
|
+
function resolveBrowserAuth(ctx) {
|
|
160
|
+
const conn = resolveConnection(ctx)
|
|
161
|
+
if (!conn) return undefined
|
|
162
|
+
if (conn.browserAuth) return conn.browserAuth
|
|
163
|
+
if (typeof conn.authorizeIndex === 'function' && typeof conn.authenticatedUrl === 'function') return conn
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ------------------------------------------------------------
|
|
168
|
+
// 裸地址直达(自动登录)
|
|
169
|
+
// 手机 / 远程设备直接访问裸地址(http://192.168.x.x:3080,不带 token)时,
|
|
170
|
+
// 官方 dsh 会返回 401 纯文本提示。本插件接管 GET /:
|
|
171
|
+
// · 未授权 → 302 到带 token 的 URL(服务端自动完成官方 303 + Set-Cookie)
|
|
172
|
+
// · 已授权 → 返回官方 index(经内部请求取回,含全部 index taps 注入)
|
|
173
|
+
// 官方 token → cookie 的握手完整保留,只是把「手动重开带 token 链接」自动化。
|
|
174
|
+
// 内部拉取时需临时注销本插件的 / 路由,让请求直达官方 index owner。
|
|
175
|
+
// ------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
/** 简易 HTTP GET:收集 cookie、跟随 3xx 重定向(最多 4 跳),返回最终响应。 */
|
|
178
|
+
function httpFollow(startUrl, maxRedirects = 4) {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
const jar = new Map()
|
|
181
|
+
let current = startUrl
|
|
182
|
+
let hops = 0
|
|
183
|
+
const step = () => {
|
|
184
|
+
if (hops++ > maxRedirects) return resolve(null)
|
|
185
|
+
let u
|
|
186
|
+
try { u = new URL(current) } catch { return resolve(null) }
|
|
187
|
+
const headers = {}
|
|
188
|
+
if (jar.size) headers.cookie = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ')
|
|
189
|
+
const req = http.request({
|
|
190
|
+
host: u.hostname, port: Number(u.port) || 80,
|
|
191
|
+
path: u.pathname + (u.search || ''), method: 'GET', headers, timeout: 8000,
|
|
192
|
+
}, (res) => {
|
|
193
|
+
for (const sc of res.headers['set-cookie'] || []) {
|
|
194
|
+
const kv = String(sc).split(';')[0]
|
|
195
|
+
const at = kv.indexOf('=')
|
|
196
|
+
if (at > 0) jar.set(kv.slice(0, at).trim(), kv.slice(at + 1).trim())
|
|
197
|
+
}
|
|
198
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
199
|
+
res.resume()
|
|
200
|
+
try { current = new URL(res.headers.location, current).href } catch { return resolve(null) }
|
|
201
|
+
return step()
|
|
202
|
+
}
|
|
203
|
+
const chunks = []
|
|
204
|
+
res.on('data', (c) => chunks.push(c))
|
|
205
|
+
res.on('end', () => {
|
|
206
|
+
if (res.statusCode !== 200) return resolve(null)
|
|
207
|
+
const keep = {}
|
|
208
|
+
for (const h of ['content-type', 'cache-control', 'content-security-policy', 'referrer-policy', 'x-content-type-options']) {
|
|
209
|
+
if (res.headers[h]) keep[h] = res.headers[h]
|
|
210
|
+
}
|
|
211
|
+
resolve({ status: 200, headers: keep, body: Buffer.concat(chunks) })
|
|
212
|
+
})
|
|
213
|
+
res.on('error', () => resolve(null))
|
|
214
|
+
})
|
|
215
|
+
req.on('timeout', () => { req.destroy(); resolve(null) })
|
|
216
|
+
req.on('error', () => resolve(null))
|
|
217
|
+
req.end()
|
|
218
|
+
}
|
|
219
|
+
step()
|
|
220
|
+
})
|
|
221
|
+
}
|
|
222
|
+
|
|
54
223
|
/** 移动端断点:与 DSH 原生 SIDEBAR_AUTO_COLLAPSE = 1024 对齐。 */
|
|
55
224
|
const MOBILE_MAX_WIDTH = 1024
|
|
56
225
|
|
|
@@ -128,6 +297,113 @@ export function apply(ctx) {
|
|
|
128
297
|
// —— Service Worker:远程访问首屏缓存加速(仅 HTTPS 外网生效)——
|
|
129
298
|
ctx.webServer.register({ kind: 'exact', path: '/__dsh_remote/sw.js', handler: handleServiceWorker() }),
|
|
130
299
|
]
|
|
300
|
+
|
|
301
|
+
// —— 裸地址直达(自动登录):接管 GET / ——
|
|
302
|
+
// 未授权访问裸地址时 302 到带 token 的 URL(浏览器自动完成官方 303 + Set-Cookie),
|
|
303
|
+
// 已授权时返回官方 index。官方 token→cookie 握手完整保留。
|
|
304
|
+
let rootDisposed = false
|
|
305
|
+
let rootRouteDisposer = null
|
|
306
|
+
let rootIndexCache = null // { headers, body } 官方最终 index(含全部 index taps 注入)
|
|
307
|
+
let rootIndexJob = null
|
|
308
|
+
|
|
309
|
+
const registerRootRoute = () => {
|
|
310
|
+
if (rootDisposed || rootRouteDisposer) return
|
|
311
|
+
rootRouteDisposer = ctx.webServer.register({ kind: 'exact', path: '/', handler: rootHandler })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// 内部拉取官方 index:临时注销本插件的 / 路由,让请求直达官方 index owner
|
|
315
|
+
// (frontend-static,经完整授权检查与全部 taps),取回最终 HTML 后缓存并重新注册。
|
|
316
|
+
// 关键:必须用「带 token 的本机地址」拉取;若宿主启用浏览器会话认证而此处拿不到
|
|
317
|
+
// token,官方会回 401,则 rootIndexCache 保持 null,由调用方回退到官方 index owner。
|
|
318
|
+
const ensureRootIndex = () => {
|
|
319
|
+
if (rootIndexCache) return Promise.resolve(rootIndexCache)
|
|
320
|
+
if (!rootIndexJob) {
|
|
321
|
+
rootIndexJob = (async () => {
|
|
322
|
+
if (rootRouteDisposer) { try { rootRouteDisposer() } catch { /* ignore */ } rootRouteDisposer = null }
|
|
323
|
+
try {
|
|
324
|
+
await new Promise((r) => setTimeout(r, 30))
|
|
325
|
+
const p = port || 3080
|
|
326
|
+
const tokenUrl = withAuthUrl(ctx, `http://127.0.0.1:${p}/`)
|
|
327
|
+
const same = tokenUrl === `http://127.0.0.1:${p}/`
|
|
328
|
+
rootIndexCache = await httpFollow(tokenUrl)
|
|
329
|
+
// 无 token 可用且官方回 401:说明宿主强制认证但本上下文取不到 browserAuth。
|
|
330
|
+
// 绝不能把空结果当成可用 index,交由调用方走官方 index owner。
|
|
331
|
+
if (same && rootIndexCache === null) {
|
|
332
|
+
try { ctx.logger?.warn?.('[dsh-mobile-remote] 无法获取浏览器会话 token:首页自动登录不可用,已交回官方认证') } catch { /* ignore */ }
|
|
333
|
+
}
|
|
334
|
+
} catch { /* 保持 null,由调用方回退 */ }
|
|
335
|
+
registerRootRoute()
|
|
336
|
+
rootIndexJob = null
|
|
337
|
+
return rootIndexCache
|
|
338
|
+
})()
|
|
339
|
+
}
|
|
340
|
+
return rootIndexJob
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function handleRootRequest(req, res) {
|
|
344
|
+
const conn = resolveConnection(ctx)
|
|
345
|
+
const ba = resolveBrowserAuth(ctx)
|
|
346
|
+
let url
|
|
347
|
+
try { url = new URL(req.url ?? '/', 'http://x') } catch { url = new URL('/', 'http://x') }
|
|
348
|
+
if (ba) {
|
|
349
|
+
if (url.searchParams.has('token') && typeof ba.authorizeIndex === 'function') {
|
|
350
|
+
// 带 token:走官方握手(匹配 → 303 + Set-Cookie;无效 → 官方 401),浏览器自动跟随
|
|
351
|
+
const ok = ba.authorizeIndex(req, res)
|
|
352
|
+
if (!ok) return
|
|
353
|
+
// ok===true:该请求已可授权,继续 serve index
|
|
354
|
+
} else if (typeof ba.isAuthenticated === 'function' && !ba.isAuthenticated(req)) {
|
|
355
|
+
// 未授权的裸地址访问:自动登录——302 到带 token 的地址,
|
|
356
|
+
// 浏览器自动完成官方 303 + Set-Cookie 后回到裸 /,全程无感。
|
|
357
|
+
const host = req.headers.host || `127.0.0.1:${port || 3080}`
|
|
358
|
+
const scheme = requestScheme(req)
|
|
359
|
+
const target = typeof conn.authenticatedUrl === 'function'
|
|
360
|
+
? conn.authenticatedUrl(`${scheme}://${host}/`)
|
|
361
|
+
: (typeof ba.authenticatedUrl === 'function' ? ba.authenticatedUrl(`${scheme}://${host}/`) : null)
|
|
362
|
+
// 关键:跳转地址一律降级为「相对地址」(只保留 ?token=…)。
|
|
363
|
+
// 原因是外网访问经 nginx / frp 反代时,本进程只看到明文 HTTP,
|
|
364
|
+
// 无法可靠推断浏览器侧的协议:若发出 http:// 绝对地址,反代会再
|
|
365
|
+
// 301 回 https,且 token 在这一次跨协议跳转中被丢掉,握手永远完不成
|
|
366
|
+
// (表现为外网打不开、/api 一直 401)。相对地址由浏览器按当前
|
|
367
|
+
// 协议与 authority 自行解析,http/https/反代前缀全都正确。
|
|
368
|
+
const relative = relativeTokenLocation(target)
|
|
369
|
+
if (relative) {
|
|
370
|
+
res.writeHead(302, { location: relative, 'cache-control': 'no-store' })
|
|
371
|
+
res.end()
|
|
372
|
+
return
|
|
373
|
+
}
|
|
374
|
+
// 取不到 token:不能回 302 到自身(会导致浏览器无限重定向循环),
|
|
375
|
+
// 明确回 401,交回官方语义。
|
|
376
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
377
|
+
res.end('dsh web authentication required; reopen the URL printed by dsh web.\n')
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
// 已授权 / 老版 dsh 无认证 / 取不到 browserAuth:交回官方 index owner。
|
|
382
|
+
// token 可用时 ensureRootIndex 会带回完整 index;否则保持 null,由下方回退。
|
|
383
|
+
const idx = await ensureRootIndex()
|
|
384
|
+
if (idx) {
|
|
385
|
+
res.writeHead(200, idx.headers)
|
|
386
|
+
res.end(idx.body)
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
if (ba) {
|
|
390
|
+
// 已确证存在认证器却仍拿不到 index:说明确实需要认证,明确回 401,
|
|
391
|
+
// 而不是把 503「启动中」这种误导性状态返回给手机端。
|
|
392
|
+
res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' })
|
|
393
|
+
res.end('dsh web authentication required; reopen the URL printed by dsh web.\n')
|
|
394
|
+
return
|
|
395
|
+
}
|
|
396
|
+
res.writeHead(503, { 'content-type': 'text/plain; charset=utf-8' })
|
|
397
|
+
res.end('dsh web is starting, please try again in a moment.')
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const rootHandler = (req, res) => {
|
|
401
|
+
void handleRootRequest(req, res).catch(() => {
|
|
402
|
+
try { if (!res.headersSent) { res.writeHead(500); res.end() } } catch { /* ignore */ }
|
|
403
|
+
})
|
|
404
|
+
}
|
|
405
|
+
registerRootRoute()
|
|
406
|
+
|
|
131
407
|
const timer = setInterval(() => {
|
|
132
408
|
const now = Date.now()
|
|
133
409
|
for (const [id, info] of devices) {
|
|
@@ -140,11 +416,40 @@ export function apply(ctx) {
|
|
|
140
416
|
const patchFile = patchFilePath(ctx)
|
|
141
417
|
void autoRestoreExternal(external, patchFile, ctx.webServer.port || 3080)
|
|
142
418
|
}
|
|
419
|
+
|
|
420
|
+
// ---- 端口自适应 ----
|
|
421
|
+
// frpc 代理的 localPort 必须始终等于当前真实监听端口,否则流量会打到无人监听
|
|
422
|
+
// 的端口上,表现为外网域名 502 Bad Gateway(frpc 日志:connect to local
|
|
423
|
+
// service ... refused)。端口可能因 --port 变更、被占用后回退到 0(OS 分配)、
|
|
424
|
+
// 或 profile patch 改动而变化,因此这里持续跟随,变化即重新绑定隧道。
|
|
425
|
+
let lastKnownPort = ctx.webServer.port || 0
|
|
426
|
+
const portWatcher = setInterval(() => {
|
|
427
|
+
const now = ctx.webServer.port || 0
|
|
428
|
+
if (!now || now === lastKnownPort) return
|
|
429
|
+
const from = lastKnownPort
|
|
430
|
+
lastKnownPort = now
|
|
431
|
+
const persisted = loadPersistedState()
|
|
432
|
+
// 仅在隧道确实在跑、且有绑定码可复用时才自动重绑(避免无谓打中转服务器)。
|
|
433
|
+
if (external.proc && external.proc.exitCode === null && persisted && persisted.bindCode) {
|
|
434
|
+
void startExternal(external, {
|
|
435
|
+
bindCode: persisted.bindCode,
|
|
436
|
+
serverBase: persisted.serverBase || undefined,
|
|
437
|
+
}, patchFilePath(ctx), now).catch(() => { /* 状态由 startExternal 内部记录 */ })
|
|
438
|
+
try {
|
|
439
|
+
ctx.logger?.info?.(`[dsh-mobile-remote] 监听端口 ${from} → ${now},外网隧道已自适应重绑`)
|
|
440
|
+
} catch { /* ignore */ }
|
|
441
|
+
}
|
|
442
|
+
}, 15_000)
|
|
443
|
+
|
|
143
444
|
return () => {
|
|
144
445
|
clearInterval(timer)
|
|
145
446
|
clearInterval(dshTimer)
|
|
447
|
+
clearInterval(portWatcher)
|
|
146
448
|
stopFrpc(external)
|
|
147
449
|
for (const dispose of disposers) dispose()
|
|
450
|
+
// 裸地址直达(/ 接管)路由的清理
|
|
451
|
+
rootDisposed = true
|
|
452
|
+
if (rootRouteDisposer) { try { rootRouteDisposer() } catch { /* ignore */ } rootRouteDisposer = null }
|
|
148
453
|
}
|
|
149
454
|
}, 'dsh-mobile-remote: api routes')
|
|
150
455
|
}
|
|
@@ -654,72 +959,21 @@ async function queryServerStatus(base, bindCode) {
|
|
|
654
959
|
return data
|
|
655
960
|
}
|
|
656
961
|
|
|
657
|
-
/** 当前 profile 的 cordis.patch.yml 绝对路径。 */
|
|
658
|
-
function patchFilePath(ctx) {
|
|
659
|
-
// DSH_HOME 环境变量优先,否则 ~/.dsh;profile 名为 DSH_PROFILE 或默认 web。
|
|
660
|
-
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
661
|
-
const profile = process.env.DSH_PROFILE || 'web'
|
|
662
|
-
return join(home, 'profiles', profile, 'cordis.patch.yml')
|
|
663
|
-
}
|
|
664
|
-
|
|
665
962
|
/**
|
|
666
|
-
*
|
|
667
|
-
*
|
|
668
|
-
*
|
|
963
|
+
* 开启 / 关闭远程控制:把受管区域整体重写进 profile 的 cordis.patch.yml。
|
|
964
|
+
* 必须同时写两块,缺一不可:
|
|
965
|
+
* 1) webserver.host = '0.0.0.0' → 手机能连上(官方 CLI 拒绝 --host 0.0.0.0,
|
|
966
|
+
* 只有 patch 层能覆盖)。
|
|
967
|
+
* 2) connection.trustedHosts = ['192.168.x.x:port', ...] → 手机的 /api 请求
|
|
968
|
+
* 能通过 dsh 的 Host/Origin 信任围栏,否则页面能打开但功能全 403。
|
|
969
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx
|
|
970
|
+
* @param {boolean} enabled
|
|
669
971
|
*/
|
|
670
|
-
function readPatchFile(ctx) {
|
|
671
|
-
const file = patchFilePath(ctx)
|
|
672
|
-
const content = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
673
|
-
return { file, content, lines: content.split(/\r?\n/) }
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
/** webserver 远程控制覆盖块的行区间(含分隔注释)。不存在返回 null。 */
|
|
677
|
-
function findRemoteBlock(lines) {
|
|
678
|
-
const start = lines.findIndex((l) => l.includes('# --- dsh-mobile-remote managed'))
|
|
679
|
-
if (start === -1) return null
|
|
680
|
-
const end = lines.findIndex((l, i) => i > start && l.includes('# --- end dsh-mobile-remote managed'))
|
|
681
|
-
if (end === -1) return { start, end: lines.length }
|
|
682
|
-
return { start, end: end + 1 }
|
|
683
|
-
}
|
|
684
|
-
|
|
685
|
-
/** 检查当前 patch 中是否已启用远程控制。 */
|
|
686
|
-
function isPatchEnabled(ctx) {
|
|
687
|
-
const { lines } = readPatchFile(ctx)
|
|
688
|
-
const block = findRemoteBlock(lines)
|
|
689
|
-
if (!block) return false
|
|
690
|
-
return lines.slice(block.start, block.end).some((l) => l.includes("host: '0.0.0.0'"))
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
/** 追加 / 移除 webserver host 覆盖块。 */
|
|
694
972
|
function setPatchEnabled(ctx, enabled) {
|
|
695
|
-
const
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
// 列表块拼接会形成无 `---` 分隔符的多文档流,js-yaml 解析报
|
|
700
|
-
// "end of the stream or a document separator is expected",导致 dsh 服务启动即崩溃。
|
|
701
|
-
out = out.filter((l) => l.trim() !== '[]')
|
|
702
|
-
|
|
703
|
-
if (block) {
|
|
704
|
-
out.splice(block.start, block.end - block.start)
|
|
705
|
-
}
|
|
706
|
-
if (enabled) {
|
|
707
|
-
const blockLines = [
|
|
708
|
-
'',
|
|
709
|
-
'# --- dsh-mobile-remote managed (auto-generated; do not edit) ---',
|
|
710
|
-
'- id: webserver',
|
|
711
|
-
' config:',
|
|
712
|
-
" host: '0.0.0.0'",
|
|
713
|
-
' port: !!js ctx.webStartup.port ?? ' + (ctx.webServer.port || 3080),
|
|
714
|
-
'# --- end dsh-mobile-remote managed ---',
|
|
715
|
-
'',
|
|
716
|
-
]
|
|
717
|
-
out = [...out, ...blockLines]
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
|
|
721
|
-
writeFileSync(file, next, 'utf8')
|
|
722
|
-
return { file, enabled }
|
|
973
|
+
const port = ctx.webServer.port || 3080
|
|
974
|
+
// 局域网各网卡地址都要放行(手机与主机可能不在同一网段时命中的是不同网卡)。
|
|
975
|
+
const authorities = collectTrustedAuthorities({ lanAddresses: getLanAddresses(), port })
|
|
976
|
+
return writeManagedPatch(ctx, { enabled, port, authorities })
|
|
723
977
|
}
|
|
724
978
|
|
|
725
979
|
/** GET /__dsh_remote/status */
|
|
@@ -729,6 +983,8 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
729
983
|
const remoteEnabled = ctx.webServer.host === '0.0.0.0'
|
|
730
984
|
// 外网 frpc 是否仍在运行
|
|
731
985
|
const extRunning = !!external.proc && external.proc.exitCode === null
|
|
986
|
+
// 手机访问地址统一附加新版 dsh 的浏览器会话 token(无认证时返回原 URL)
|
|
987
|
+
const authUrl = (u) => withAuthUrl(ctx, u)
|
|
732
988
|
// 已持久化的绑定信息(重启后自动恢复用,前端据此回填表单)
|
|
733
989
|
const persisted = loadPersistedState()
|
|
734
990
|
// 活跃设备详情列表(按最近活跃倒序)
|
|
@@ -782,11 +1038,13 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
782
1038
|
lanAddresses: lan,
|
|
783
1039
|
lanInterfaces: getLanInterfaces(),
|
|
784
1040
|
remoteEnabled,
|
|
785
|
-
patchEnabled:
|
|
1041
|
+
patchEnabled: isManagedPatchEnabled(ctx),
|
|
786
1042
|
deviceCount: devices.size,
|
|
787
1043
|
devices: deviceList,
|
|
788
1044
|
patchFile: patchFilePath(ctx),
|
|
789
|
-
url: lan.length ? `http://${lan[0]}:${ctx.webServer.port}` : null,
|
|
1045
|
+
url: lan.length ? authUrl(`http://${lan[0]}:${ctx.webServer.port}`) : null,
|
|
1046
|
+
// 全部局域网 IP 的「手机可访问」地址(已带新版 dsh 认证 token;无认证时即裸地址)
|
|
1047
|
+
lanUrls: lan.map((ip) => authUrl(`http://${ip}:${ctx.webServer.port}`)),
|
|
790
1048
|
// 本机运行时信息(诊断用,设置页展示更详细的数据)
|
|
791
1049
|
runtime: runtimeInfo(),
|
|
792
1050
|
uptime: Math.floor(process.uptime()),
|
|
@@ -801,7 +1059,7 @@ function handleStatus(ctx, devices, external, tracker, dshCache) {
|
|
|
801
1059
|
enabled: extRunning,
|
|
802
1060
|
running: extRunning,
|
|
803
1061
|
status: external.status || 'idle',
|
|
804
|
-
url: extRunning ? external.url : null,
|
|
1062
|
+
url: extRunning && external.url ? authUrl(external.url) : null,
|
|
805
1063
|
domain: external.domain,
|
|
806
1064
|
tunnelPort: external.tunnelPort,
|
|
807
1065
|
frpcVersion: external.frpcVersion || null,
|
package/lib/patch.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// dsh-mobile-remote — profile patch(cordis.patch.yml)受管区域读写(host 侧)。
|
|
2
|
+
//
|
|
3
|
+
// 背景(对 dsh 0.1.7 / 0.2.x 源码的实际行为):
|
|
4
|
+
// 官方 CLI 出于安全硬编码拒绝 `--host 0.0.0.0`(见 dsh-web-app/lib/startup.js),
|
|
5
|
+
// 但 profile 的 patch 层完整支持覆盖 `webserver.host`。因此本插件用 patch 打开
|
|
6
|
+
// 局域网监听。而 dsh 的 connection 服务另有一道独立的 **Host/Origin 信任围栏**
|
|
7
|
+
// (isTrustedApiRequest):非回环 authority 只有在 `connection.trustedHosts`
|
|
8
|
+
// 里才会放行,否则手机的 /api 请求一律 403(页面能打开但一直转圈/报错)。
|
|
9
|
+
//
|
|
10
|
+
// 所以"开启远程控制"必须同时写两块:
|
|
11
|
+
// 1) `- id: webserver` → host: '0.0.0.0'(让手机能连上)
|
|
12
|
+
// 2) `- id: connection` → trustedHosts: ['192.168.x.x:port', ...](让手机能调 API)
|
|
13
|
+
//
|
|
14
|
+
// 两块写在同一个受管区域内,便于整体增删,也避免多次追加造成互相冲突。
|
|
15
|
+
// 依赖零第三方 YAML 库,仅做精确文本匹配。
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import { homedir, networkInterfaces } from 'node:os'
|
|
20
|
+
|
|
21
|
+
/** 受管区域起始行标记(含 'dsh-mobile-remote managed' 子串)。 */
|
|
22
|
+
export const MANAGED_START = '# --- dsh-mobile-remote managed (auto-generated; do not edit) ---'
|
|
23
|
+
|
|
24
|
+
/** 受管区域结束行标记。 */
|
|
25
|
+
export const MANAGED_END = '# --- end dsh-mobile-remote managed ---'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 求 profile 的 patch 文件路径。
|
|
29
|
+
* DSH_PROFILE / DSH_PROFILE_DIR 优先,其次 DSH_HOME,最后 ~/.dsh。
|
|
30
|
+
* @param {import('@deepseek-ai/cordis').Context} [ctx]
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
export function patchFilePath(_ctx) {
|
|
34
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
35
|
+
const dir = process.env.DSH_PROFILE_DIR
|
|
36
|
+
const profile = process.env.DSH_PROFILE || 'web'
|
|
37
|
+
// 宿主给了 profile 目录就直接用,避免 DSH_PROFILE 与实际运行 profile 不一致。
|
|
38
|
+
if (dir) return join(dir, 'cordis.patch.yml')
|
|
39
|
+
return join(home, 'profiles', profile, 'cordis.patch.yml')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 读取 patch 文件内容与行数组。 */
|
|
43
|
+
export function readPatchFile(ctx) {
|
|
44
|
+
const file = patchFilePath(ctx)
|
|
45
|
+
const content = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
46
|
+
return { file, content, lines: content.split(/\r?\n/) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 受管区域的行区间 [start, end)(含分隔注释)。不存在返回 null。
|
|
51
|
+
* @param {string[]} lines
|
|
52
|
+
* @returns {{start: number, end: number}|null}
|
|
53
|
+
*/
|
|
54
|
+
export function findManagedBlock(lines) {
|
|
55
|
+
const start = lines.findIndex((l) => l.includes('# --- dsh-mobile-remote managed'))
|
|
56
|
+
if (start === -1) return null
|
|
57
|
+
const end = lines.findIndex((l, i) => i > start && l.includes('# --- end dsh-mobile-remote managed'))
|
|
58
|
+
if (end === -1) return { start, end: lines.length }
|
|
59
|
+
return { start, end: end + 1 }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 归一化成一个「裸 authority」(host 或 host:port),使其能通过 dsh 的
|
|
64
|
+
* assertTrustedAuthority 校验:必须是 WHATWG 解析后保持不变的规范形式。
|
|
65
|
+
* 形如 http://1.2.3.4:80/ 的输入、带路径/大小写/默认端口的输入都会被收敛。
|
|
66
|
+
* @param {string} raw
|
|
67
|
+
* @returns {string|null} 规范 authority,无法解析时返回 null
|
|
68
|
+
*/
|
|
69
|
+
export function normalizeAuthority(raw) {
|
|
70
|
+
if (typeof raw !== 'string') return null
|
|
71
|
+
let value = raw.trim()
|
|
72
|
+
if (!value) return null
|
|
73
|
+
// 去掉可能出现的协议与路径,只保留 authority 部分。
|
|
74
|
+
value = value.replace(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//, '')
|
|
75
|
+
value = value.split('/')[0].split('?')[0].split('#')[0]
|
|
76
|
+
// 用户信息禁止出现在 trustedHosts(会授权内嵌主机名),直接拒绝。
|
|
77
|
+
if (value.includes('@')) return null
|
|
78
|
+
let url
|
|
79
|
+
try {
|
|
80
|
+
url = new URL(`http://${value}`)
|
|
81
|
+
} catch {
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
if (!url.hostname) return null
|
|
85
|
+
const host = url.hostname.toLowerCase()
|
|
86
|
+
const port = url.port
|
|
87
|
+
// 端口只在原始字符串显式写出时才保留(:80/:443 对裸 authority 无意义且易被改写)。
|
|
88
|
+
return port ? `${host}:${port}` : host
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 汇总需要放行的 authority 列表(去重、保持顺序、仅保留规范形式)。
|
|
93
|
+
* @param {{lanAddresses?: string[], port?: number, domains?: string[], extra?: string[]}} input
|
|
94
|
+
* @returns {string[]}
|
|
95
|
+
*/
|
|
96
|
+
export function collectTrustedAuthorities(input = {}) {
|
|
97
|
+
const { lanAddresses = [], port, domains = [], extra = [] } = input
|
|
98
|
+
const out = []
|
|
99
|
+
const push = (v) => { if (v && !out.includes(v)) out.push(v) }
|
|
100
|
+
// 局域网 IP:DSH 的围栏对回环之外的 authority 要求显式声明。
|
|
101
|
+
for (const ip of lanAddresses) push(normalizeAuthority(port ? `${ip}:${port}` : ip))
|
|
102
|
+
// 外网域名:不带端口(经 frp / 反向代理时浏览器 Host 通常无端口)。
|
|
103
|
+
for (const d of domains) push(normalizeAuthority(d))
|
|
104
|
+
// 显式附加项(用户自定义 / 兼容旧的运行时来源)。
|
|
105
|
+
for (const e of extra) push(normalizeAuthority(e))
|
|
106
|
+
return out
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 生成受管区域的文本行。
|
|
111
|
+
* @param {{enabled: boolean, port: number, authorities?: string[]}} opts
|
|
112
|
+
* @returns {string[]}
|
|
113
|
+
*/
|
|
114
|
+
export function renderManagedBlock(opts) {
|
|
115
|
+
const { enabled, port, authorities = [] } = opts
|
|
116
|
+
if (!enabled) return []
|
|
117
|
+
const lines = [
|
|
118
|
+
'',
|
|
119
|
+
MANAGED_START,
|
|
120
|
+
'- id: webserver',
|
|
121
|
+
' config:',
|
|
122
|
+
" host: '0.0.0.0'",
|
|
123
|
+
` port: !!js ctx.webStartup.port ?? ${port}`,
|
|
124
|
+
]
|
|
125
|
+
if (authorities.length) {
|
|
126
|
+
lines.push('- id: connection')
|
|
127
|
+
lines.push(' config:')
|
|
128
|
+
lines.push(` trustedHosts: [${authorities.map((a) => `'${a}'`).join(', ')}]`)
|
|
129
|
+
}
|
|
130
|
+
lines.push(MANAGED_END, '')
|
|
131
|
+
return lines
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 从受管区域里解析出已有的 trustedHosts authority(供增量更新)。 */
|
|
135
|
+
export function readManagedAuthorities(lines, block) {
|
|
136
|
+
if (!block) return []
|
|
137
|
+
const out = []
|
|
138
|
+
for (let i = block.start; i < Math.min(block.end, lines.length); i++) {
|
|
139
|
+
const m = /^\s*trustedHosts:\s*\[(.*)\]\s*$/.exec(lines[i])
|
|
140
|
+
if (!m) continue
|
|
141
|
+
for (const part of m[1].split(',')) {
|
|
142
|
+
const a = normalizeAuthority(part.replace(/['"]/g, '').trim())
|
|
143
|
+
if (a && !out.includes(a)) out.push(a)
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 写入受管区域(整体替换,幂等)。
|
|
151
|
+
* 关闭时整块移除;开启时写入 webserver 块,并在有 authority 时附带 connection 块。
|
|
152
|
+
*
|
|
153
|
+
* authorities 语义(既避免"探测失败把已放行的域名清掉",也避免"空数组被误当成沿用"):
|
|
154
|
+
* - 未传(undefined) → 沿用文件中已有的 authority;
|
|
155
|
+
* - 传了数组(含空数组) → 以传入值为准,但与文件中已有的合并去重。这样开启开关时
|
|
156
|
+
* 即使局域网探测失败,也不会丢掉外网隧道此前写入的域名。
|
|
157
|
+
* @param {import('@deepseek-ai/cordis').Context} [ctx]
|
|
158
|
+
* @param {{enabled: boolean, port?: number, authorities?: string[]}} opts
|
|
159
|
+
* @returns {{file: string, enabled: boolean, authorities: string[]}}
|
|
160
|
+
*/
|
|
161
|
+
export function writeManagedPatch(ctx, opts) {
|
|
162
|
+
const { file, lines } = readPatchFile(ctx)
|
|
163
|
+
const block = findManagedBlock(lines)
|
|
164
|
+
const port = opts.port || 3080
|
|
165
|
+
const existing = readManagedAuthorities(lines, block)
|
|
166
|
+
const authorities = Array.isArray(opts.authorities)
|
|
167
|
+
? [...new Set([...opts.authorities, ...existing])]
|
|
168
|
+
: existing
|
|
169
|
+
|
|
170
|
+
let out = lines.slice()
|
|
171
|
+
// 移除官方模板的空数组占位行 `[]`:它单独构成一个 YAML 文档,与追加的列表块
|
|
172
|
+
// 拼接会形成无 `---` 分隔符的多文档流,js-yaml 解析报
|
|
173
|
+
// "end of the stream or a document separator is expected",导致 dsh 服务启动即崩溃。
|
|
174
|
+
out = out.filter((l) => l.trim() !== '[]')
|
|
175
|
+
// 行号会因上面的过滤而偏移,重新定位受管区域。
|
|
176
|
+
const block2 = findManagedBlock(out)
|
|
177
|
+
if (block2) out.splice(block2.start, block2.end - block2.start)
|
|
178
|
+
|
|
179
|
+
if (opts.enabled) {
|
|
180
|
+
out = [...out, ...renderManagedBlock({ enabled: true, port, authorities })]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
|
|
184
|
+
writeFileSync(file, next, 'utf8')
|
|
185
|
+
return { file, enabled: !!opts.enabled, authorities }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 确保某个 authority(通常是外网域名)加入受管区域的 connection.trustedHosts。
|
|
190
|
+
* 与旧实现不同:不再依赖 `ctx.webRuntime.trustedHosts`(该字段在 dsh 0.1.7/0.2.x
|
|
191
|
+
* 不存在,表达式会求值成对象而被 schema 拒绝),改为写出真实的字符串数组。
|
|
192
|
+
* @param {string} patchFile
|
|
193
|
+
* @param {string} authority - 裸 authority(host 或 host:port)
|
|
194
|
+
* @returns {boolean} 是否已确保存在
|
|
195
|
+
*/
|
|
196
|
+
export function ensureTrustedAuthority(patchFile, authority) {
|
|
197
|
+
const target = normalizeAuthority(authority)
|
|
198
|
+
if (!target) return false
|
|
199
|
+
const content = existsSync(patchFile) ? readFileSync(patchFile, 'utf8') : ''
|
|
200
|
+
const lines = content.split(/\r?\n/)
|
|
201
|
+
const block = findManagedBlock(lines)
|
|
202
|
+
if (block) {
|
|
203
|
+
const existing = readManagedAuthorities(lines, block)
|
|
204
|
+
if (existing.includes(target)) return true
|
|
205
|
+
}
|
|
206
|
+
const merged = block ? readManagedAuthorities(lines, block) : []
|
|
207
|
+
if (!merged.includes(target)) merged.push(target)
|
|
208
|
+
// 直接整体重写受管区域:保留原 webserver 开关状态(区域内是否存在 host: '0.0.0.0')。
|
|
209
|
+
const inner = block ? lines.slice(block.start, block.end) : []
|
|
210
|
+
const enabled = inner.some((l) => l.includes("host: '0.0.0.0'"))
|
|
211
|
+
const portMatch = inner.map((l) => /port:\s*!!js[^\d]*(\d+)/.exec(l)).find(Boolean)
|
|
212
|
+
const port = portMatch ? Number(portMatch[1]) : 3080
|
|
213
|
+
let out = lines.slice()
|
|
214
|
+
out = out.filter((l) => l.trim() !== '[]')
|
|
215
|
+
const block2 = findManagedBlock(out)
|
|
216
|
+
if (block2) out.splice(block2.start, block2.end - block2.start)
|
|
217
|
+
out = [...out, ...renderManagedBlock({ enabled, port, authorities: merged })]
|
|
218
|
+
const next = out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\s*$/, '\n')
|
|
219
|
+
writeFileSync(patchFile, next, 'utf8')
|
|
220
|
+
return true
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** 受管区域内是否已开启远程控制(host: '0.0.0.0')。 */
|
|
224
|
+
export function isManagedPatchEnabled(ctx) {
|
|
225
|
+
const { lines } = readPatchFile(ctx)
|
|
226
|
+
const block = findManagedBlock(lines)
|
|
227
|
+
if (!block) return false
|
|
228
|
+
return lines.slice(block.start, block.end).some((l) => l.includes("host: '0.0.0.0'"))
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** 读取局域网 IPv4(非回环)地址列表。 */
|
|
232
|
+
export function lanAddresses() {
|
|
233
|
+
const out = []
|
|
234
|
+
for (const list of Object.values(networkInterfaces() || {})) {
|
|
235
|
+
for (const ni of list || []) {
|
|
236
|
+
if (!ni || ni.internal) continue
|
|
237
|
+
// Node 18+ 的 family 是 'IPv4',旧版是数字 4,两种都要兼容。
|
|
238
|
+
const family = typeof ni.family === 'string' ? ni.family : `IPv${ni.family}`
|
|
239
|
+
if (family !== 'IPv4') continue
|
|
240
|
+
if (!out.includes(ni.address)) out.push(ni.address)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return out
|
|
244
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@feiyang666/dsh-mobile-remote",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "DeepSeek Harness mobile remote-control plugin: LAN/external remote control (QR / on-off toggle / remote-access password gate), rich telemetry (expandable device details, system CPU/memory/load, DSH app state: version/sessions/workspaces/plugins/models), real external-tunnel status detection (frpc token / reconnect diagnostics), mobile-first UI polish (composer image upload entry via system picker, settings single-column layout) and faster remote loading.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek",
|
|
@@ -65,8 +65,8 @@
|
|
|
65
65
|
],
|
|
66
66
|
"scripts": {
|
|
67
67
|
"pack:list": "npm pack --dry-run",
|
|
68
|
-
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/external.js && node --check lib/ws-client.js && node --check lib/password.js && node --check lib/qrcode.js",
|
|
69
|
-
"prepublishOnly": "node --check lib/index.js && node --check lib/client.js && node --check lib/external.js && node --check lib/ws-client.js && node --check lib/password.js && node --check lib/qrcode.js",
|
|
68
|
+
"check": "node --check lib/index.js && node --check lib/client.js && node --check lib/external.js && node --check lib/ws-client.js && node --check lib/password.js && node --check lib/patch.js && node --check lib/qrcode.js",
|
|
69
|
+
"prepublishOnly": "node --check lib/index.js && node --check lib/client.js && node --check lib/external.js && node --check lib/ws-client.js && node --check lib/password.js && node --check lib/patch.js && node --check lib/qrcode.js",
|
|
70
70
|
"pack": "npm pack"
|
|
71
71
|
},
|
|
72
72
|
"peerDependencies": {
|