dsh-mesh-access 0.0.0-stage → 1.0.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 +57 -0
- package/INSTALL.en.md +96 -0
- package/INSTALL.md +97 -0
- package/LICENSE +21 -0
- package/README.en.md +115 -0
- package/README.md +131 -2
- package/assets/icon-180.png +0 -0
- package/assets/screenshot-1.png +0 -0
- package/cordis.patch.yml +8 -0
- package/docs/RELEASE.md +63 -0
- package/docs/architecture.md +91 -0
- package/icon.svg +16 -0
- package/lib/client.js +229 -0
- package/lib/index.js +305 -0
- package/package.json +72 -4
- package/screenshots.json +3 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# 更新日志
|
|
2
|
+
|
|
3
|
+
## 1.0.0 — 改名(原名 dsh-phone-tunnel)
|
|
4
|
+
|
|
5
|
+
插件和「手机」没关系:它做的是把组网地址上的请求转给本进程。所以改了名,顺便把定位放宽。
|
|
6
|
+
|
|
7
|
+
- **包名 / 仓库名**:`dsh-phone-tunnel` → `dsh-mesh-access`(npm 上的旧名已 `deprecate`,仍可按旧名装到最后那版 0.1.5);
|
|
8
|
+
- 面板文案、「不只手机」章节、npm 描述、市场条目描述都改成「组网里的任何设备」;
|
|
9
|
+
- API 路径 `/api/dsh-phone-tunnel/*` → `/api/dsh-mesh-access/*`;
|
|
10
|
+
- 挂载 id `mesh` → `mesh-access`;
|
|
11
|
+
- 命名空间变了,所以版本从 1.0.0 重新起(不是 0.1.6)。
|
|
12
|
+
|
|
13
|
+
从 0.1.5 升级:先 `dsh plugin --profile web remove dsh-phone-tunnel`,再 `add dsh-mesh-access@1.0.0`,然后重启 `dsh web`。
|
|
14
|
+
|
|
15
|
+
## 0.1.5
|
|
16
|
+
|
|
17
|
+
- **把定位从「手机」放宽到「组网里的任何设备」**:手机、平板、另一台电脑、带图形界面的 NAS(DSM / QTS / TrueNAS Scale)、电视盒子,只要能连到组网 IP 就能打开,对方设备**不需要装任何东西**;
|
|
18
|
+
- 面板文案跟着改:「手机地址」→「访问地址」,说明里写清「组网里的任何设备」;
|
|
19
|
+
- npm 描述与市场条目描述同步放宽,关键词补 `nas` / `remote-desktop`。
|
|
20
|
+
|
|
21
|
+
## 0.1.4
|
|
22
|
+
|
|
23
|
+
- 面板的请求改成**可中止**(`AbortController`):动作请求 20 秒、状态轮询 6 秒,超时就 abort,不会再把界面吊死;
|
|
24
|
+
- 加了**看门狗**:按钮「处理中…」超过 22 秒会自动解除并提示,配合「刷新」按钮自愈;
|
|
25
|
+
- 说明一下:这两个数字是给「进程正忙」留的余量 —— DSH 正在推流生成回答时,同一个 Node 进程处理面板请求会明显变慢,看起来像卡住。
|
|
26
|
+
|
|
27
|
+
## 0.1.3
|
|
28
|
+
|
|
29
|
+
- **修「点第二次就卡死」**:`start()` 原来会 `await server.close()`,而 `close()` 要等所有连接结束 —— DSH 的实时通道是长连接,于是永远等不到。现在同端口重复点击直接返回现状,并发的点击单飞;
|
|
30
|
+
- 关闭改成「先拒绝新连接 → `closeAllConnections()` → 强制销毁自管 socket → 最多等 500ms」,不再阻塞;
|
|
31
|
+
- 面板:反代运行时「③ 回到上面开端口」变「③ 端口已开」并禁用;动作请求加 20 秒超时兜底,不会再永远「处理中…」。
|
|
32
|
+
|
|
33
|
+
## 0.1.2
|
|
34
|
+
|
|
35
|
+
- 面板直接给出**带 token 的完整地址**(借用 `connection` 服务的 `authenticatedUrl()`),复制到手机点开即可,不必再去终端里找 token。
|
|
36
|
+
- 「第一次用星空组网?」改成引导式三步按钮:注册 / 下载客户端 / 回到上面开端口。邀请链接只放在按钮的 `href` 里,不再把 URL 明文印在页面上。
|
|
37
|
+
- `inject` 增加 `connection`;`proxy.urlPlain` 保留不带 token 的裸地址。
|
|
38
|
+
|
|
39
|
+
## 0.1.1 — 2026-11-07
|
|
40
|
+
|
|
41
|
+
文档语言调整,功能与 0.1.0 完全一致。
|
|
42
|
+
|
|
43
|
+
- `README.md`、`INSTALL.md`、`CHANGELOG.md`、`docs/architecture.md` 改为**中文**(这个包主要面向中文用户);
|
|
44
|
+
英文原文保留在 `README.en.md`、`INSTALL.en.md`,两个 README 开头互相跳转。
|
|
45
|
+
- 修掉导航行被反引号包住、在 GitHub 上渲染成代码而点不动的问题。
|
|
46
|
+
- 包的 `files` 白名单显式列出四个文档文件;构建产物 `*.tgz` 不再进仓库。
|
|
47
|
+
|
|
48
|
+
## 0.1.0 — 2026-11-07
|
|
49
|
+
|
|
50
|
+
第一个版本。已发布到 npm:`dsh-mesh-access@0.1.0`。
|
|
51
|
+
|
|
52
|
+
- **宿主半边**:组网网卡探测(`192.168.*` 上的 `StarVPN`、`utun*`、`tap*`、`tun*`、`wg*`、`gpd*`、
|
|
53
|
+
`ppp*`、`ipsec*`)、进程内反代并改写 `Host`、WebSocket / upgrade 转发、
|
|
54
|
+
`GET /api/dsh-mesh-access/status`、`POST /api/dsh-mesh-access/proxy`。
|
|
55
|
+
- **客户端半边**:`sidebar.panellist` + `main` 两个槽位的面板(状态、开/关端口、手机地址与复制、
|
|
56
|
+
首次使用星空组网的提示),每 5 秒轮询一次。
|
|
57
|
+
- **没有构建步骤**:两边都是普通的 ESM / 浏览器 JS,所以发布出去的包就是源码本身。
|
package/INSTALL.en.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# Install
|
|
2
|
+
|
|
3
|
+
[English](INSTALL.en.md) | [中文](INSTALL.md)
|
|
4
|
+
|
|
5
|
+
## Requirement
|
|
6
|
+
|
|
7
|
+
- DSH `>= 0.2.1-rc.2` (`dsh --version`)
|
|
8
|
+
- Node `>= 22` (whatever DSH already runs on)
|
|
9
|
+
- An initialized `web` profile (`~/.dsh/profiles/web` exists — DSH creates it on first `dsh web`)
|
|
10
|
+
|
|
11
|
+
## From npm (one line)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
dsh plugin --profile web add dsh-mesh-access
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Current: `dsh-mesh-access@0.1.3` (published 2026-11-07).
|
|
18
|
+
|
|
19
|
+
> If your machine points npm at a China mirror (`registry.npmmirror.com`), the mirror can lag
|
|
20
|
+
> behind or serve a partially synced record, and pnpm then fails with
|
|
21
|
+
> `numeric field was not a number: when getting size for package`. Add the registry explicitly:
|
|
22
|
+
>
|
|
23
|
+
> ```bash
|
|
24
|
+
> dsh plugin --profile web add dsh-mesh-access@0.1.3 --registry=https://registry.npmjs.org
|
|
25
|
+
> ```
|
|
26
|
+
|
|
27
|
+
## From the release tarball (no npm account, any registry)
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
dsh plugin --profile web add \
|
|
31
|
+
https://github.com/SilverKurali/dsh-mesh-access/releases/latest/download/dsh-mesh-access.tgz
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## From a local checkout
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
git clone https://github.com/SilverKurali/dsh-mesh-access
|
|
38
|
+
dsh plugin --profile web add link:"$PWD/dsh-mesh-access"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`link:` installs the directory itself; pnpm prints a peer-resolution warning that is expected for
|
|
42
|
+
link installs and harmless here (the plugin has no peer for `@deepseek-ai/dsh` beyond the host).
|
|
43
|
+
|
|
44
|
+
## From a git URL
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
dsh plugin --profile web add git+https://github.com/SilverKurali/dsh-mesh-access.git
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Verify
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
# 1. the profile now lists the plugin
|
|
54
|
+
grep -n 'dsh-mesh-access' ~/.dsh/profiles/web/package.json
|
|
55
|
+
|
|
56
|
+
# 2. restart the profile, then ask the host directly
|
|
57
|
+
curl -s http://127.0.0.1:3080/api/dsh-mesh-access/status | head -20
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A healthy answer looks like:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"ok": true,
|
|
65
|
+
"host": { "upstream": "127.0.0.1", "port": 3080 },
|
|
66
|
+
"mesh": [{ "iface": "StarVPN", "address": "192.168.188.1", "netmask": "255.255.255.0" }],
|
|
67
|
+
"proxy": { "running": false, "port": 3081, "url": null }
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then open the **组网访问** panel and press *在组网地址上开端口*, or:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
curl -s -X POST -H 'content-type: application/json' \
|
|
75
|
+
-d '{"action":"start","port":3081}' \
|
|
76
|
+
http://127.0.0.1:3080/api/dsh-mesh-access/proxy
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Uninstall
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
dsh plugin --profile web remove dsh-mesh-access
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Nothing is written outside the profile directory and `~/.dsh`; there is no service, no autostart,
|
|
86
|
+
no firewall rule. Stopping the proxy (or restarting the profile) closes the mesh port.
|
|
87
|
+
|
|
88
|
+
## Troubleshooting
|
|
89
|
+
|
|
90
|
+
| Symptom | Cause and fix |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `没找到组网网卡` | The mesh client is not connected, or the interface is named differently. Run `ip -br addr` / `ifconfig` and set `interfacePattern` to match, and check `addressPrefix`. |
|
|
93
|
+
| Panel shows the proxy running but the phone times out | Something between the phone and this host drops it — on Linux check `firewall-cmd --zone=<mesh zone> --list-ports`, on Windows the inbound rule for the port. |
|
|
94
|
+
| Phone gets the page but every API call fails | The page was opened on a stale token/cookie; open a fresh `?token=` URL once. |
|
|
95
|
+
| `listen EADDRINUSE` | Another process owns the port (a previous `dsh web` or the kit's script). Stop it, or start this proxy on a different port. |
|
|
96
|
+
| Port opens, phone sees `401` | Expected: no cookie yet. `?token=` once. |
|
package/INSTALL.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# 安装
|
|
2
|
+
|
|
3
|
+
[中文](INSTALL.md) | [English](INSTALL.en.md)
|
|
4
|
+
|
|
5
|
+
## 前置条件
|
|
6
|
+
|
|
7
|
+
- DSH `>= 0.2.1-rc.2`(`dsh --version`)
|
|
8
|
+
- Node `>= 22`(DSH 本身就在用的那个即可)
|
|
9
|
+
- 已经初始化过 `web` profile(`~/.dsh/profiles/web` 存在 —— 第一次 `dsh web` 时 DSH 会创建)
|
|
10
|
+
|
|
11
|
+
## 一、从 npm 装(一条命令)
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
dsh plugin --profile web add dsh-mesh-access
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
最新版:`dsh-mesh-access@0.1.3`(2026-11-07 发布)。
|
|
18
|
+
|
|
19
|
+
> **国内镜像的坑**:如果你的 npm 指向 `registry.npmmirror.com`,镜像可能只同步了一半元数据,
|
|
20
|
+
> pnpm 会报
|
|
21
|
+
> `numeric field was not a number: when getting size for package`。
|
|
22
|
+
> 这时显式给官方源:
|
|
23
|
+
>
|
|
24
|
+
> ```bash
|
|
25
|
+
> dsh plugin --profile web add dsh-mesh-access@0.1.3 --registry=https://registry.npmjs.org
|
|
26
|
+
> ```
|
|
27
|
+
|
|
28
|
+
## 二、从 Release 的 tgz 装(不需要 npm 账号,任何镜像都行)
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
dsh plugin --profile web add \
|
|
32
|
+
https://github.com/SilverKurali/dsh-mesh-access/releases/latest/download/dsh-mesh-access.tgz
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 三、从本地检出装(改代码时用)
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
git clone https://github.com/SilverKurali/dsh-mesh-access
|
|
39
|
+
dsh plugin --profile web add link:"$PWD/dsh-mesh-access"
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`link:` 装的是目录本身。pnpm 会打印一条 peer 依赖解析的警告,`link:` 装法必然出现,
|
|
43
|
+
这里无害(本插件对 `@deepseek-ai/dsh` 没有运行时依赖,只是声明了 peer)。
|
|
44
|
+
|
|
45
|
+
## 四、从 git 地址装
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
dsh plugin --profile web add git+https://github.com/SilverKurali/dsh-mesh-access.git
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 验证
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# 1. profile 里已经有这个插件
|
|
55
|
+
grep -n 'dsh-mesh-access' ~/.dsh/profiles/web/package.json
|
|
56
|
+
|
|
57
|
+
# 2. 重启 profile,然后直接问宿主
|
|
58
|
+
curl -s http://127.0.0.1:3080/api/dsh-mesh-access/status | head -20
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
正常的样子:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"ok": true,
|
|
66
|
+
"host": { "upstream": "127.0.0.1", "port": 3080 },
|
|
67
|
+
"mesh": [{ "iface": "StarVPN", "address": "192.168.188.1", "netmask": "255.255.255.0" }],
|
|
68
|
+
"proxy": { "running": false, "port": 3081, "url": null }
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
然后打开**「组网访问」**面板点「在组网地址上开端口」,或者:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
curl -s -X POST -H 'content-type: application/json' \
|
|
76
|
+
-d '{"action":"start","port":3081}' \
|
|
77
|
+
http://127.0.0.1:3080/api/dsh-mesh-access/proxy
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## 卸载
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
dsh plugin --profile web remove dsh-mesh-access
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
除了 profile 目录和 `~/.dsh` 之外什么都没写:没有系统服务、没有开机自启、没有防火墙规则。
|
|
87
|
+
关掉反代(或重启 profile)组网端口就关了。
|
|
88
|
+
|
|
89
|
+
## 故障排查
|
|
90
|
+
|
|
91
|
+
| 现象 | 原因与处理 |
|
|
92
|
+
|---|---|
|
|
93
|
+
| 面板显示「没找到组网网卡」 | 组网客户端没连上,或者网卡名字不在默认正则里。用 `ip -br addr` 或 `ifconfig` 看真实名字,改配置里的 `interfacePattern`,并检查 `addressPrefix`。 |
|
|
94
|
+
| 面板说反代在跑,但手机超时 | 手机到这台机器之间被拦了。Linux 上查 `firewall-cmd --zone=<组网域> --list-ports`,Windows 上查该端口的入站规则。 |
|
|
95
|
+
| 手机能打开页面,但每个接口都失败 | 页面用的是过期 token/cookie。重新点一次带 `?token=` 的地址。 |
|
|
96
|
+
| `listen EADDRINUSE` | 端口被别的进程占了(上一次的 `dsh web`,或者 kit 脚本起的实例)。先停掉它,或换个端口起反代。 |
|
|
97
|
+
| 端口开了,手机看到 `401` | 正常:还没有 cookie。用带 `?token=` 的地址访问一次。 |
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 SilverKurali
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# dsh-mesh-access
|
|
2
|
+
|
|
3
|
+
Expose the **running** DeepSeek Harness web UI on a mesh/VPN interface — from inside the server,
|
|
4
|
+
without a second instance, without SSH, without touching the router.
|
|
5
|
+
|
|
6
|
+
[English](#what-it-does) | [中文](README.md)
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
## What it does
|
|
11
|
+
|
|
12
|
+
`dsh web --host` binds exactly one concrete interface address, and it rejects wildcards. So a
|
|
13
|
+
server that serves `127.0.0.1:3080` is invisible to your mesh, and a TAP (`StarVPN`) or `utun`
|
|
14
|
+
interface created *after* boot is not where it listens.
|
|
15
|
+
|
|
16
|
+
This plugin adds a mesh port **inside the running process**:
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
any device ──▶ 192.168.188.1:3081 ──▶ (plugin proxy) ──▶ 127.0.0.1:3080 (the same dsh you are using)
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
One instance. One session list. No `task-board ledger is already owned by …` warning, no
|
|
23
|
+
half-synced state between two servers.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
dsh plugin --profile web add dsh-mesh-access
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
No npm / behind a China mirror? Use the release tarball:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
dsh plugin --profile web add \
|
|
35
|
+
https://github.com/SilverKurali/dsh-mesh-access/releases/latest/download/dsh-mesh-access.tgz
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
From a checkout: `dsh plugin --profile web add link:/absolute/path/to/dsh-mesh-access`.
|
|
39
|
+
Full matrix (npm / tarball / checkout / git URL), verification and troubleshooting: [INSTALL.en.md](INSTALL.en.md).
|
|
40
|
+
|
|
41
|
+
Restart the profile. A **组网访问 / Mesh access** entry appears in the left rail:
|
|
42
|
+
|
|
43
|
+
| Block | What it shows |
|
|
44
|
+
|---|---|
|
|
45
|
+
| 当前状态 | proxy state, DSH's own listen port, how many mesh interfaces were found |
|
|
46
|
+
| 动作 | open the mesh port / close it / refresh |
|
|
47
|
+
| 手机地址 | the URL to open on the phone, with a copy button |
|
|
48
|
+
| 第一次用星空组网? | the referral sign-up link and the member-account rule |
|
|
49
|
+
|
|
50
|
+
## HTTP surface
|
|
51
|
+
|
|
52
|
+
The panel is a view over two routes kept deliberately small:
|
|
53
|
+
|
|
54
|
+
```
|
|
55
|
+
GET /api/dsh-mesh-access/status → { host:{upstream,port}, interfaces[], mesh[], proxy{...}, referral }
|
|
56
|
+
POST /api/dsh-mesh-access/proxy → {action:'start'|'stop', port?}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`curl -s localhost:3090/api/dsh-mesh-access/status` is enough to debug without a browser.
|
|
60
|
+
|
|
61
|
+
## Configuration
|
|
62
|
+
|
|
63
|
+
Optional, written in the profile's patch layer; every key has a default:
|
|
64
|
+
|
|
65
|
+
```yaml
|
|
66
|
+
- id: mesh
|
|
67
|
+
name: dsh-mesh-access
|
|
68
|
+
config:
|
|
69
|
+
interfacePattern: '^(StarVPN|utun\d+|tap\d+|tun\d+|wg\d+|gpd\d+|ppp\d+|ipsec\d+)$'
|
|
70
|
+
addressPrefix: '192.168.'
|
|
71
|
+
proxyPort: 3081
|
|
72
|
+
upstream: 127.0.0.1
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`addressPrefix` exists so a public address on a `tun*` interface is never mistaken for a mesh
|
|
76
|
+
address.
|
|
77
|
+
|
|
78
|
+
## Why the cookie survives
|
|
79
|
+
|
|
80
|
+
A request with `?token=` answers `303` and sets `dsh-auth-*` (`HttpOnly`, `SameSite=Strict`,
|
|
81
|
+
`Max-Age=2592000`). The cookie payload embeds the **authority it was issued for**, signed with a
|
|
82
|
+
secret kept in a persistent credential record — not a per-process random — so it outlives a
|
|
83
|
+
restart. The proxy rewrites `Host` to the upstream authority on every request, which keeps the
|
|
84
|
+
baked-in authority equal to what the server expects, which is why the same cookie keeps working
|
|
85
|
+
through the mesh port.
|
|
86
|
+
|
|
87
|
+
Corollary: change the mesh IP or the port and you must open a fresh token URL once.
|
|
88
|
+
|
|
89
|
+
## Limitations
|
|
90
|
+
|
|
91
|
+
- It cannot move *its own* server's listener; a plugin runs inside a process that is already
|
|
92
|
+
listening. The mesh port is a proxy, not a second bind of the primary server.
|
|
93
|
+
- Only IPv4 mesh addresses are considered.
|
|
94
|
+
- Still requires the phone to reach the mesh address — the tunnel's own quality applies. On
|
|
95
|
+
StarVPN's relay path expect ~0.4 MB/s and occasional multi-second RTT spikes, which can stall a
|
|
96
|
+
long streaming answer; reload resumes it.
|
|
97
|
+
|
|
98
|
+
## Not just phones
|
|
99
|
+
|
|
100
|
+
The name says phone, the mechanism says otherwise: the plugin forwards requests that arrive on a
|
|
101
|
+
mesh address to the port this process already serves. Anything that can open a web page on the
|
|
102
|
+
mesh works — a phone or tablet, another Windows/macOS/Linux laptop, a NAS with a GUI
|
|
103
|
+
(Synology DSM, QNAP QTS, TrueNAS Scale), even a TV stick's browser.
|
|
104
|
+
|
|
105
|
+
Requirements are only: same mesh, a route to the mesh IP, and a browser. **Nothing** has to be
|
|
106
|
+
installed on the other device, and no SSH is involved.
|
|
107
|
+
|
|
108
|
+
## Companion
|
|
109
|
+
|
|
110
|
+
The shell-script edition (Linux/macOS/Windows launchers that start a *second* dsh instance bound
|
|
111
|
+
to the mesh IP, with firewall rules and QR codes) is
|
|
112
|
+
[dsh-mesh-kit](https://github.com/SilverKurali/dsh-mesh-kit). Use this plugin when you want one
|
|
113
|
+
instance; use that kit when DSH is not running yet, or on a machine without a Node plugin install.
|
|
114
|
+
|
|
115
|
+
MIT.
|
package/README.md
CHANGED
|
@@ -1,3 +1,132 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-mesh-access
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
把**正在运行**的 DeepSeek Harness 网页界面挂到组网网卡上 —— 在服务进程内部完成,
|
|
4
|
+
不需要第二个实例,不需要 SSH,也不用碰路由器。
|
|
5
|
+
|
|
6
|
+
[中文](#它做什么) | [English](README.en.md)
|
|
7
|
+
|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
## 它做什么
|
|
11
|
+
|
|
12
|
+
`dsh web --host` 只接受**一个具体的网卡地址**,并且拒绝通配。所以只监听 `127.0.0.1:3080` 的实例,
|
|
13
|
+
组网里的手机、笔记本、另一台电脑、带图形界面的 NAS 都看不见;而开机之后才出现的 TAP(星空组网的 `StarVPN`)或 `utun` 网卡,也不在它启动时的那一份地址表里。
|
|
14
|
+
|
|
15
|
+
这个插件在**已经在跑的那个进程内部**多开一个组网端口:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
任何设备 ──▶ 192.168.188.1:3081 ──▶(插件反代)──▶ 127.0.0.1:3080(你正在用的这个 dsh)
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
一个实例、一份会话列表:不会出现 `task-board ledger is already owned by …`,也不会出现两台服务状态不同步。
|
|
22
|
+
|
|
23
|
+
## 安装
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
dsh plugin --profile web add dsh-mesh-access
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
不方便用 npm,或者你的 npm 指向国内镜像?用 Release 里的 tgz:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
dsh plugin --profile web add \
|
|
33
|
+
https://github.com/SilverKurali/dsh-mesh-access/releases/latest/download/dsh-mesh-access.tgz
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
从本地目录装(改代码的人用这个):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
dsh plugin --profile web add link:/绝对路径/dsh-mesh-access
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
完整的四种装法、验证步骤、卸载与故障表见 [INSTALL.md](INSTALL.md)。
|
|
43
|
+
|
|
44
|
+
装完重启 profile,左侧导航会多出**「组网访问」**:
|
|
45
|
+
|
|
46
|
+
| 功能块 | 内容 |
|
|
47
|
+
|---|---|
|
|
48
|
+
| 当前状态 | 反代是否在跑、DSH 自己的监听端口、找到几张组网网卡 |
|
|
49
|
+
| 动作 | 在组网地址上开端口 / 关闭 / 刷新 |
|
|
50
|
+
| 访问地址 | 给组网内任何设备打开的地址(带 token,可直接复制) |
|
|
51
|
+
| 第一次用星空组网? | 邀请注册链接 + 成员账号规则 |
|
|
52
|
+
|
|
53
|
+
## HTTP 接口
|
|
54
|
+
|
|
55
|
+
面板只是两个接口的外壳,故意做得极小:
|
|
56
|
+
|
|
57
|
+
```
|
|
58
|
+
GET /api/dsh-mesh-access/status → { ok, host:{upstream,port}, interfaces[], mesh[], proxy{...}, referral }
|
|
59
|
+
POST /api/dsh-mesh-access/proxy → { action:'start' | 'stop', port? }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
不开浏览器也能排查:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
curl -s http://127.0.0.1:3080/api/dsh-mesh-access/status
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 配置(可选)
|
|
69
|
+
|
|
70
|
+
写在 profile 的 patch 层里,每一项都有默认值:
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
- id: mesh
|
|
74
|
+
name: dsh-mesh-access
|
|
75
|
+
config:
|
|
76
|
+
interfacePattern: '^(StarVPN|utun\d+|tap\d+|tun\d+|wg\d+|gpd\d+|ppp\d+|ipsec\d+)$'
|
|
77
|
+
addressPrefix: '192.168.'
|
|
78
|
+
proxyPort: 3081
|
|
79
|
+
upstream: 127.0.0.1
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`addressPrefix` 的作用是别把 `tun*` 上的公网地址当成组网地址。
|
|
83
|
+
|
|
84
|
+
## 为什么 cookie 能一直用
|
|
85
|
+
|
|
86
|
+
带 `?token=` 的请求返回 `303` 并种下 `dsh-auth-*`(`HttpOnly`、`SameSite=Strict`、`Max-Age=2592000`,
|
|
87
|
+
即 30 天)。cookie 载荷里写死了**签发时的 authority**:
|
|
88
|
+
|
|
89
|
+
```json
|
|
90
|
+
{"version":1,"authority":"192.168.188.1:3081","issuedAt":…,"expiresAt":…}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
签名密钥存在**持久的凭据记录**里(不是每进程随机),所以重启也在。反代每次请求都把 `Host`
|
|
94
|
+
改写成上游 authority,于是 cookie 里的 authority 与服务端期望的一致 —— 这就是换个端口访问还能继续用
|
|
95
|
+
同一份 cookie 的原因。
|
|
96
|
+
|
|
97
|
+
反过来也成立:**换了组网 IP 或端口,就得重新点一次 token 链接。**
|
|
98
|
+
|
|
99
|
+
## 限制
|
|
100
|
+
|
|
101
|
+
- 插件动不了**自己进程**的监听地址。组网端口是反代,不是把主服务再绑一次。
|
|
102
|
+
- 只考虑 IPv4 组网地址。
|
|
103
|
+
- 仍然依赖隧道本身的质量。星空组网走中继时实测单向约 0.4 MB/s,`[SN-RTT]` 会抖到几秒,
|
|
104
|
+
长回答可能中途停住,刷新一下能接回来。
|
|
105
|
+
- 同一份 `~/.dsh` 只允许一个实例持有 task-board 锁;这是刻意的,本插件就是为避开它而存在。
|
|
106
|
+
|
|
107
|
+
## 不只手机
|
|
108
|
+
|
|
109
|
+
名字叫 phone,但插件本身跟“手机”没有关系 —— 它做的是**把组网地址上的请求转给本进程**。
|
|
110
|
+
组网里任何能打开网页的设备都能用:
|
|
111
|
+
|
|
112
|
+
| 设备 | 用法 |
|
|
113
|
+
|---|---|
|
|
114
|
+
| 手机 / 平板 | 浏览器打开那条带 token 的地址,可加到桌面当 App |
|
|
115
|
+
| 另一台电脑(Win / macOS / Linux) | 同一个地址,直接开;不用装 DSH,也不用装这个插件 |
|
|
116
|
+
| 带图形界面的 NAS(群晖 DSM、威联通 QTS、TrueNAS Scale 等) | NAS 上的浏览器打开即可 |
|
|
117
|
+
| 电视盒子 / 平板上的浏览器 | 同理,只要能连到 `192.168.188.1` |
|
|
118
|
+
|
|
119
|
+
条件是三条:它在**同一个星空组网**里、能路由到组网 IP、浏览器能发 HTTP。
|
|
120
|
+
**不需要**在对方设备上装任何东西,也不需要 SSH。
|
|
121
|
+
|
|
122
|
+
## 配套
|
|
123
|
+
|
|
124
|
+
命令行版(在 DSH 还没启动、或者机器上没有 Node 插件环境时使用:
|
|
125
|
+
起**第二个**实例绑到组网 IP,附带防火墙规则与二维码):
|
|
126
|
+
[dsh-mesh-kit](https://github.com/SilverKurali/dsh-mesh-kit),支持 Linux / macOS / Windows。
|
|
127
|
+
|
|
128
|
+
设计取舍与实测数据见 [docs/architecture.md](docs/architecture.md)。
|
|
129
|
+
|
|
130
|
+
## 许可
|
|
131
|
+
|
|
132
|
+
MIT。
|
|
Binary file
|
|
Binary file
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# dsh-mesh-access — bundle patch
|
|
2
|
+
#
|
|
3
|
+
# Mounts the mesh-access plugin into the web profile. Install with:
|
|
4
|
+
# dsh plugin --profile web add dsh-mesh-access
|
|
5
|
+
# dsh plugin --profile web add link:/absolute/path/to/dsh-mesh-access # local dev
|
|
6
|
+
- insert:
|
|
7
|
+
- id: mesh-access
|
|
8
|
+
name: dsh-mesh-access
|
package/docs/RELEASE.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# 发布清单
|
|
2
|
+
|
|
3
|
+
每改一次代码,下面四块**必须一起动**,否则就会出现「npm 上是新版、Release 还是旧版、文档写着上上个版本」这种漂移。
|
|
4
|
+
|
|
5
|
+
## 1. 版本号
|
|
6
|
+
|
|
7
|
+
- `package.json` 的 `version`
|
|
8
|
+
- `CHANGELOG.md` 顶部加一节
|
|
9
|
+
- `INSTALL.md` / `INSTALL.en.md` 里出现的 `dsh-mesh-access@x.y.z` 命令
|
|
10
|
+
- `README.md` / `README.en.md` 里若有版本号(能不带就不带,用 `dsh-mesh-access` 拉 latest)
|
|
11
|
+
|
|
12
|
+
## 2. npm
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm publish --registry=https://registry.npmjs.org
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
⚠️ `+ dsh-mesh-access@x.y.z` 只代表**元数据**提交成功,tarball 可能还要几分钟才可下载。必须探到 200 才算发完:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
curl -sI https://registry.npmjs.org/dsh-mesh-access/-/dsh-mesh-access-<版本>.tgz | head -1 # 期望 200
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
再看一眼 dist-tags 与 shasum 对不对:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
curl -s https://registry.npmjs.org/dsh-mesh-access | python3 -c "import json,sys;d=json.load(sys.stdin);v=d['versions'][d['dist-tags']['latest']];print(d['dist-tags']['latest'], v['dist']['shasum'])"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
2026-11-07 那次实测:404 持续了约 6 分钟才变 200。这期间 `npm install` 会报 `npm error 404`,看着像发布失败。
|
|
31
|
+
|
|
32
|
+
## 3. GitHub Release
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm pack --registry=https://registry.npmjs.org
|
|
36
|
+
mv dsh-mesh-access-<版本>.tgz dsh-mesh-access.tgz # 附件名不带版本号
|
|
37
|
+
git -c tag.gpgSign=false tag -a v<版本> -m "<摘要>"
|
|
38
|
+
git -c http.version=HTTP/1.1 push origin refs/tags/v<版本>
|
|
39
|
+
gh release create v<版本> dsh-mesh-access.tgz --title "v<版本> — <摘要>" --notes-file /tmp/rel-notes.md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
附件名**必须不带版本号**,因为文档里用的是稳定地址
|
|
43
|
+
`https://github.com/SilverKurali/dsh-mesh-access/releases/latest/download/dsh-mesh-access.tgz`。
|
|
44
|
+
发完验一次:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
curl -sIL .../releases/latest/download/dsh-mesh-access.tgz | grep -i '^HTTP'
|
|
48
|
+
tar -xzOf dsh-mesh-access.tgz package/package.json | grep version
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## 4. 装一次(别信代码,信安装)
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
rm -rf ~/.dsh/profiles/reltest && cp -a ~/.dsh/profiles/headless ~/.dsh/profiles/reltest
|
|
55
|
+
dsh plugin --profile reltest add dsh-mesh-access@<版本> --registry=https://registry.npmjs.org
|
|
56
|
+
# 起临时实例 → GET /api/dsh-mesh-access/status → POST proxy start → curl 期望 401
|
|
57
|
+
rm -rf ~/.dsh/profiles/reltest
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## 5. 市场条目
|
|
61
|
+
|
|
62
|
+
`store/dsh-mesh-access.yml` 只在**分类、描述、图标、tarball 地址**变化时才需要改;`npm:` 键由 registry 自动采集,**不要手写**。
|
|
63
|
+
提交给 `awesome-dsh-plugin` 的 PR 由仓库所有者本人开,AI 只备料。
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# 设计说明
|
|
2
|
+
|
|
3
|
+
## 约束一:`dsh web` 只绑一个地址
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
--host <host> 绑定地址:本机某张网卡上的一个具体 IPv4/IPv6 字面量;
|
|
7
|
+
通配地址会被拒绝
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
由此产生三个后果:
|
|
11
|
+
|
|
12
|
+
- `0.0.0.0` **不是选项**,这是设计如此。用 socat/iptables 把它补回来不在范围内。
|
|
13
|
+
- 开机时启动的服务,绑的是开机那一刻存在的地址。之后才注册的 TAP(`StarVPN`)不在那一份里。
|
|
14
|
+
在 Linux 上这件事曾经让**别的**守护进程连续几个月打印
|
|
15
|
+
`error: Bind to port 22 on 192.168.188.1 failed: Cannot assign requested address`,
|
|
16
|
+
而服务本身照样起来了、只是没有组网地址 —— 这正是 sshd 那个 guard 单元存在的原因,
|
|
17
|
+
也是这些脚本每次运行都重新解析网卡的原因。
|
|
18
|
+
- 想在两个地址上可达,只有两条路:**两个进程**(拓扑 A)或者**一个进程加前面一层反代**(拓扑 B)。
|
|
19
|
+
|
|
20
|
+
## 约束二:token / cookie 这道栅栏
|
|
21
|
+
|
|
22
|
+
访问 `/` 时是这样判的:
|
|
23
|
+
|
|
24
|
+
| 请求 | 响应 |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `GET /` 无 cookie | `401 dsh web authentication required` |
|
|
27
|
+
| `GET /?token=<t>` | `303 See Other`、`location: ./`、`set-cookie: dsh-auth-…` |
|
|
28
|
+
| `GET /` 带该 cookie | `200` |
|
|
29
|
+
| `/api/*`,`Host` 既不是服务器自己也不在 `--trusted-host` 里 | `403` |
|
|
30
|
+
|
|
31
|
+
cookie 载荷里写死了**它是为哪个 authority 签发的**:
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
{"version":1,"authority":"192.168.188.1:3081","issuedAt":…,"expiresAt":…}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
并用 HMAC-SHA256 对一个存在**持久凭据记录**里的密钥签名(不是每进程随机),
|
|
38
|
+
所以 cookie 能活过重启,而 token 不能。
|
|
39
|
+
|
|
40
|
+
这一条直接决定了反代怎么做:**cookie 里的 authority 必须等于浏览器看到的 origin。**
|
|
41
|
+
于是——
|
|
42
|
+
|
|
43
|
+
- 反代**另一个**实例:把 `Host` 改写成上游(`--target`);
|
|
44
|
+
- 反代**同一个**实例:必须保留浏览器的 `Host`(`--keep-host`),并且实例本身要带
|
|
45
|
+
`--public-url http://<组网IP>:<端口>/ --trusted-host <组网IP>:<端口>` 启动。
|
|
46
|
+
|
|
47
|
+
## 约束三:一个 home 目录只能有一份 ledger
|
|
48
|
+
|
|
49
|
+
第二个 `dsh web` 指向同一个 `~/.dsh` 时能正常启动,但会报:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
ui-task-board … Error: task-board ledger is already owned by process <pid>
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
其它一切正常。两个实例还共享 `~/.dsh/sessions/`,所以会话**列表**是共用的、而**进程状态**不是:
|
|
56
|
+
同一个会话在两边同时打开就是自找麻烦。拓扑 A 里这是刻意的 —— 桌面那个实例是 DSH 自己拉起来的,
|
|
57
|
+
没法简单搬家 —— 也正是拓扑 B 的主要理由。
|
|
58
|
+
|
|
59
|
+
## 实测(中继路径,`path=SN`)
|
|
60
|
+
|
|
61
|
+
```
|
|
62
|
+
ping -c 40 -i 0.25 192.168.188.3
|
|
63
|
+
40 packets transmitted, 40 received, 0% packet loss
|
|
64
|
+
rtt min/avg/max/mdev = 57.540/66.685/78.079/4.580 ms
|
|
65
|
+
|
|
66
|
+
# 单流,dd | nc 穿过隧道
|
|
67
|
+
连续 20 秒约 400 KB/s,共 7.9 MB,无停顿
|
|
68
|
+
|
|
69
|
+
# 同一时段的客户端日志
|
|
70
|
+
[SN-RTT] Supernode 业务包往返偏慢: peerIP=192.168.188.3 rtt=1.054s path=SN
|
|
71
|
+
[SN-RTT] … rtt=3.481s …
|
|
72
|
+
[SN-RTT] … rtt=5.735s …
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
解读:隧道**能用**,但业务包 RTT 会抖到秒级,而 ICMP 一直很平。所以长连接(界面背后的 SSE / 长轮询)
|
|
76
|
+
偶尔会卡住,刷新就能重建。这不是配置能解决的问题 —— 它就是中继本身。
|
|
77
|
+
唯一的杠杆是打通直连(`transport=P2P` 加上可用的 STUN 路径)或者买「TCP 穿透」,
|
|
78
|
+
而这取决于两张网络能不能互相看见。
|
|
79
|
+
|
|
80
|
+
## 为什么当初先写脚本、后来又做成插件
|
|
81
|
+
|
|
82
|
+
`dsh` 的 profile 就是一叠 npm 插件包(`dsh plugin --profile web add <包>`),所以这件事**确实**可以由插件来做。
|
|
83
|
+
但脚本做的那几件事,插件永远做不到:
|
|
84
|
+
|
|
85
|
+
- 自己挑网卡、把**服务进程的监听**(重新)绑上去 —— 地址是 `dsh web` 自己的启动参数;
|
|
86
|
+
- 持有系统级的东西(systemd / launchd / Windows 防火墙规则);
|
|
87
|
+
- 在 DSH **没运行**的时候还在。
|
|
88
|
+
|
|
89
|
+
而插件能做、并且严格优于脚本的,正是拓扑 B 的进程内版本:插件在自己的进程里开一个 `http` 服务绑到组网 IP,
|
|
90
|
+
反代到 profile 本来就在服务的那个端口,于是手机和桌面共用同一个实例、没有外部零件。
|
|
91
|
+
这就是现在这个包;`dsh-mesh-kit` 里的 `tools/mesh-proxy.mjs` 是同一套逻辑的原型。
|
package/icon.svg
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
|
2
|
+
<defs>
|
|
3
|
+
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
|
4
|
+
<stop offset="0" stop-color="#4d8dff"/>
|
|
5
|
+
<stop offset="1" stop-color="#7b5cff"/>
|
|
6
|
+
</linearGradient>
|
|
7
|
+
</defs>
|
|
8
|
+
<rect x="2" y="2" width="60" height="60" rx="15" fill="url(#g)"/>
|
|
9
|
+
<g fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round">
|
|
10
|
+
<circle cx="32" cy="32" r="17"/>
|
|
11
|
+
<path d="M15 32h34"/>
|
|
12
|
+
<path d="M32 15c6 6.5 6 27.5 0 34"/>
|
|
13
|
+
<path d="M32 15c-6 6.5-6 27.5 0 34"/>
|
|
14
|
+
</g>
|
|
15
|
+
<circle cx="32" cy="32" r="3.6" fill="#fff"/>
|
|
16
|
+
</svg>
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mesh-access —— 浏览器半边。
|
|
3
|
+
*
|
|
4
|
+
* 手写 loader 模块,不需要 TS / JSX / 打包器:DSH 的 client loader 只要求
|
|
5
|
+
* `window.__ModuleLoader__.load({ id, factory })` 里返回 `{ apply, inject }`。
|
|
6
|
+
* 组件用 react.createElement 拼,避免引入构建链。
|
|
7
|
+
*/
|
|
8
|
+
window.__ModuleLoader__.load({
|
|
9
|
+
id: 'dsh-mesh-access',
|
|
10
|
+
factory: (require) => {
|
|
11
|
+
var module = { exports: {} }
|
|
12
|
+
var exports = module.exports
|
|
13
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
14
|
+
|
|
15
|
+
const react = require('react')
|
|
16
|
+
const h = react.createElement
|
|
17
|
+
|
|
18
|
+
const PANEL_ID = 'mesh'
|
|
19
|
+
const S = {
|
|
20
|
+
wrap: { padding: '24px 28px', maxWidth: 860, color: 'inherit', font: 'inherit' },
|
|
21
|
+
h1: { fontSize: 20, fontWeight: 600, margin: '0 0 4px' },
|
|
22
|
+
sub: { opacity: 0.62, fontSize: 13, margin: '0 0 20px' },
|
|
23
|
+
card: { border: '1px solid rgba(127,127,127,.28)', borderRadius: 12, padding: '16px 18px', marginBottom: 14 },
|
|
24
|
+
row: { display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
|
25
|
+
label: { fontSize: 12, letterSpacing: '.02em', opacity: 0.55, textTransform: 'uppercase', marginBottom: 8 },
|
|
26
|
+
mono: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 13 },
|
|
27
|
+
btn: { padding: '7px 14px', borderRadius: 8, border: '1px solid rgba(127,127,127,.4)', background: 'transparent', color: 'inherit', cursor: 'pointer', fontSize: 13 },
|
|
28
|
+
link: {
|
|
29
|
+
padding: '6px 14px',
|
|
30
|
+
borderRadius: 8,
|
|
31
|
+
background: 'transparent',
|
|
32
|
+
border: '1px solid var(--vscode-panel-border, #3a3f4b)',
|
|
33
|
+
color: 'var(--vscode-textLink-foreground, #6cb6ff)',
|
|
34
|
+
textDecoration: 'none',
|
|
35
|
+
fontSize: 13,
|
|
36
|
+
cursor: 'pointer',
|
|
37
|
+
display: 'inline-block',
|
|
38
|
+
},
|
|
39
|
+
btnPrimary: { padding: '7px 14px', borderRadius: 8, border: '1px solid rgba(127,127,127,.4)', background: 'rgba(127,127,127,.16)', color: 'inherit', cursor: 'pointer', fontSize: 13, fontWeight: 600 },
|
|
40
|
+
pill: (on) => ({ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 12, padding: '3px 10px', borderRadius: 999, border: '1px solid rgba(127,127,127,.35)', opacity: on ? 1 : 0.6 }),
|
|
41
|
+
dot: (on) => ({ width: 7, height: 7, borderRadius: 999, background: on ? '#3fb950' : '#8b949e' }),
|
|
42
|
+
err: { color: '#f85149', fontSize: 13, marginTop: 8 },
|
|
43
|
+
hint: { fontSize: 12, opacity: 0.6, marginTop: 10, lineHeight: 1.6 },
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 带硬超时的 fetch:超时就 abort,绝不让调用方永远挂着。
|
|
47
|
+
* 面板上「处理中…」卡死过一次(请求发出去了但响应没回来),
|
|
48
|
+
* 所以这里用 AbortController 兜底,而不是只 Promise.race。 */
|
|
49
|
+
const fetchJson = (url, options = {}, ms = 8000) => {
|
|
50
|
+
const ctrl = new AbortController()
|
|
51
|
+
const timer = setTimeout(() => ctrl.abort(), ms)
|
|
52
|
+
return fetch(url, { ...options, credentials: 'same-origin', signal: ctrl.signal })
|
|
53
|
+
.then((r) => r.json())
|
|
54
|
+
.finally(() => clearTimeout(timer))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const api = {
|
|
58
|
+
status: () => fetchJson('/api/dsh-mesh-access/status', {}, 6000),
|
|
59
|
+
action: (action, port) =>
|
|
60
|
+
fetchJson('/api/dsh-mesh-access/proxy', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
headers: { 'content-type': 'application/json' },
|
|
63
|
+
body: JSON.stringify({ action, port }),
|
|
64
|
+
}, 20000),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function Icon() {
|
|
68
|
+
return h(
|
|
69
|
+
'svg',
|
|
70
|
+
{ width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round' },
|
|
71
|
+
h('circle', { cx: 12, cy: 12, r: 9 }),
|
|
72
|
+
h('path', { d: 'M3 12h18M12 3c2.6 2.4 2.6 15.6 0 18M12 3c-2.6 2.4-2.6 15.6 0 18' }),
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function Card({ title, children }) {
|
|
77
|
+
return h('div', { style: S.card }, h('div', { style: S.label }, title), children)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function Panel() {
|
|
81
|
+
const [state, setState] = react.useState(null)
|
|
82
|
+
const [busy, setBusy] = react.useState(false)
|
|
83
|
+
const [err, setErr] = react.useState(null)
|
|
84
|
+
const [copied, setCopied] = react.useState(false)
|
|
85
|
+
|
|
86
|
+
const refresh = react.useCallback(() => {
|
|
87
|
+
api
|
|
88
|
+
.status()
|
|
89
|
+
.then((s) => {
|
|
90
|
+
setState(s)
|
|
91
|
+
setErr(s && s.ok === false ? s.error ?? '未知错误' : null)
|
|
92
|
+
})
|
|
93
|
+
.catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
94
|
+
}, [])
|
|
95
|
+
|
|
96
|
+
react.useEffect(() => {
|
|
97
|
+
refresh()
|
|
98
|
+
const t = setInterval(refresh, 5000)
|
|
99
|
+
return () => clearInterval(t)
|
|
100
|
+
}, [refresh])
|
|
101
|
+
|
|
102
|
+
const run = (action) => {
|
|
103
|
+
setBusy(true)
|
|
104
|
+
setErr(null)
|
|
105
|
+
const started = Date.now()
|
|
106
|
+
const watch = setInterval(() => {
|
|
107
|
+
if (Date.now() - started < 22000) return
|
|
108
|
+
clearInterval(watch)
|
|
109
|
+
setBusy(false)
|
|
110
|
+
setErr('这次请求超过 22 秒没有回音,已自动解除;如果端口状态和预期不符,点一下「刷新」')
|
|
111
|
+
}, 1000)
|
|
112
|
+
api.action(action)
|
|
113
|
+
.then((s) => {
|
|
114
|
+
if (s && s.ok === false) setErr(s.error ?? '操作失败')
|
|
115
|
+
else setState(s)
|
|
116
|
+
})
|
|
117
|
+
.catch((e) => setErr(String(e && e.message ? e.message : e)))
|
|
118
|
+
.finally(() => {
|
|
119
|
+
clearInterval(watch)
|
|
120
|
+
setBusy(false)
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const copy = (text) => {
|
|
125
|
+
const done = () => {
|
|
126
|
+
setCopied(true)
|
|
127
|
+
setTimeout(() => setCopied(false), 1600)
|
|
128
|
+
}
|
|
129
|
+
if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, () => {})
|
|
130
|
+
else done()
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const mesh = (state && state.mesh) || []
|
|
134
|
+
const proxy = (state && state.proxy) || { running: false }
|
|
135
|
+
const host = (state && state.host) || {}
|
|
136
|
+
const url = proxy.url
|
|
137
|
+
const referral = (state && state.referral) || 'https://starvpn.cn/register.html?referralCode=Kurali'
|
|
138
|
+
|
|
139
|
+
return h(
|
|
140
|
+
'div',
|
|
141
|
+
{ style: S.wrap },
|
|
142
|
+
h('div', { style: S.h1 }, '组网访问'),
|
|
143
|
+
h('div', { style: S.sub }, '把当前这个 DSH 实例搬到组网网卡上,组网内任意设备直接打开,不用第二个实例、不用 SSH。'),
|
|
144
|
+
|
|
145
|
+
h(
|
|
146
|
+
Card, { title: '当前状态' },
|
|
147
|
+
h('div', { style: S.row },
|
|
148
|
+
h('span', { style: S.pill(proxy.running) }, h('span', { style: S.dot(proxy.running) }), proxy.running ? '反代运行中' : '反代未启动'),
|
|
149
|
+
h('span', { style: S.pill(Boolean(host.port)) }, h('span', { style: S.dot(Boolean(host.port)) }), host.port ? 'DSH 监听 127.0.0.1:' + host.port : 'DSH 端口未知'),
|
|
150
|
+
h('span', { style: S.pill(mesh.length > 0) }, h('span', { style: S.dot(mesh.length > 0) }), mesh.length > 0 ? '组网网卡 ' + mesh.length + ' 个' : '未发现组网网卡'),
|
|
151
|
+
),
|
|
152
|
+
h('div', { style: S.hint }, mesh.length > 0
|
|
153
|
+
? '组网:' + mesh.map((m) => m.iface + ' → ' + m.address).join(',')
|
|
154
|
+
: '没找到 StarVPN / utun / tap / tun / wg 网卡上的 192.168.* 地址。先把星空组网(或 Tailscale / WireGuard)连上再刷新。'),
|
|
155
|
+
),
|
|
156
|
+
|
|
157
|
+
h(
|
|
158
|
+
Card, { title: '动作' },
|
|
159
|
+
h('div', { style: S.row },
|
|
160
|
+
h('button', { style: S.btnPrimary, disabled: busy || proxy.running, onClick: () => run('start') }, busy ? '处理中…' : '在组网地址上开端口'),
|
|
161
|
+
h('button', { style: S.btn, disabled: busy || !proxy.running, onClick: () => run('stop') }, '关闭'),
|
|
162
|
+
h('button', { style: S.btn, disabled: busy, onClick: refresh }, '刷新'),
|
|
163
|
+
),
|
|
164
|
+
err ? h('div', { style: S.err }, err) : null,
|
|
165
|
+
h('div', { style: S.hint },
|
|
166
|
+
'开端口 = 在组网 IP 上再监听一个端口(默认 3081),把你现在的请求转给本进程的 127.0.0.1:' + (host.port || '3080') + '。',
|
|
167
|
+
'组网里的任何设备因此都是同一个实例、同一份会话列表。',
|
|
168
|
+
),
|
|
169
|
+
),
|
|
170
|
+
|
|
171
|
+
url
|
|
172
|
+
? h(
|
|
173
|
+
Card, { title: '访问地址' },
|
|
174
|
+
h('div', { style: S.row },
|
|
175
|
+
h('code', { style: { ...S.mono, wordBreak: 'break-all' } }, url),
|
|
176
|
+
h('button', { style: S.btn, onClick: () => copy(url) }, copied ? '已复制' : '复制'),
|
|
177
|
+
),
|
|
178
|
+
h('div', { style: S.hint },
|
|
179
|
+
'这条地址已经带上本进程的 token,直接点开就能进;它会种下 30 天 cookie,之后裸地址也进得去。',
|
|
180
|
+
' cookie 里的 authority 是发给这个地址的 —— 换端口或换 IP,回到这里重新复制一次。',
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
: null,
|
|
184
|
+
|
|
185
|
+
h(
|
|
186
|
+
Card, { title: '第一次用星空组网?' },
|
|
187
|
+
h('div', { style: S.hint }, '三步走,不用记 IP,也不用改任何配置。'),
|
|
188
|
+
h('div', { style: S.row },
|
|
189
|
+
h('a', { href: referral, target: '_blank', rel: 'noopener noreferrer', style: S.link }, '① 注册星空组网'),
|
|
190
|
+
h('a', { href: 'https://doc.starvpn.cn/#/download', target: '_blank', rel: 'noopener noreferrer', style: S.link }, '② 下载并安装客户端'),
|
|
191
|
+
h('button', { style: S.btn, disabled: busy || !mesh.length || proxy.running, onClick: () => run('start') }, proxy.running ? '③ 端口已开' : '③ 回到上面开端口'),
|
|
192
|
+
),
|
|
193
|
+
h('div', { style: S.hint },
|
|
194
|
+
'注册后进控制台「设备管理 → 成员列表」,给每台设备建一个成员账号(子账号),客户端用子账号登录。',
|
|
195
|
+
' 同一个账号同时登两台会互相顶掉;虚拟 IP 属于成员账号,不是在客户端里手填的。',
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function apply(ctx) {
|
|
202
|
+
const slots = ctx.slots
|
|
203
|
+
const disposers = []
|
|
204
|
+
try {
|
|
205
|
+
disposers.push(
|
|
206
|
+
slots.inject('sidebar.panellist', () =>
|
|
207
|
+
slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 60, label: () => '组网访问' }, Icon),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
disposers.push(slots.inject('main', () => slots.register({ name: 'main', key: PANEL_ID }, Panel)))
|
|
211
|
+
} catch (error) {
|
|
212
|
+
console.warn('[dsh-mesh-access] 面板注册失败:', error)
|
|
213
|
+
}
|
|
214
|
+
ctx.effect(() => () => {
|
|
215
|
+
for (const dispose of disposers.splice(0)) {
|
|
216
|
+
try {
|
|
217
|
+
dispose()
|
|
218
|
+
} catch {
|
|
219
|
+
/* ignore */
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}, 'dsh-mesh-access: ui')
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
exports.apply = apply
|
|
226
|
+
exports.inject = ['slots']
|
|
227
|
+
return module.exports
|
|
228
|
+
},
|
|
229
|
+
})
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-mesh-access — 把运行中的 DSH Web 暴露到组网网卡上(进程内反代)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么是插件:`dsh web --host` 只接受一个具体网卡地址,插件改不了自己进程的监听,
|
|
5
|
+
* 但插件可以在组网地址上再开一个监听,把请求转给自己进程已经在跑的那个端口 ——
|
|
6
|
+
* 于是手机和电脑是同一个实例、同一份会话状态。
|
|
7
|
+
*
|
|
8
|
+
* 宿主侧只做三件事:
|
|
9
|
+
* 1. 找出组网网卡(StarVPN / utun* / tap* / tun* / wg* / gpd* / ppp*)上的 IPv4
|
|
10
|
+
* 2. GET /api/dsh-mesh-access/status 看状态
|
|
11
|
+
* 3. POST /api/dsh-mesh-access/proxy 启停反代({action:'start'|'stop', port?})
|
|
12
|
+
*/
|
|
13
|
+
import os from 'node:os'
|
|
14
|
+
import http from 'node:http'
|
|
15
|
+
import net from 'node:net'
|
|
16
|
+
|
|
17
|
+
export const name = 'dsh-mesh-access'
|
|
18
|
+
export const inject = ['webServer', 'connection']
|
|
19
|
+
|
|
20
|
+
const REFERRAL = 'https://starvpn.cn/register.html?referralCode=Kurali'
|
|
21
|
+
|
|
22
|
+
/** 可调项(写在 profile 的 cordis.patch.yml 里,缺省值如下;不引入 schema 依赖):
|
|
23
|
+
*
|
|
24
|
+
* - id: mesh
|
|
25
|
+
* name: dsh-mesh-access
|
|
26
|
+
* config:
|
|
27
|
+
* interfacePattern: '^(StarVPN|utun\\d+|tap\\d+|tun\\d+|wg\\d+|gpd\\d+|ppp\\d+|ipsec\\d+)$'
|
|
28
|
+
* addressPrefix: '192.168.'
|
|
29
|
+
* proxyPort: 3081
|
|
30
|
+
* upstream: 127.0.0.1
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const asText = (v, fallback) => (typeof v === 'string' && v.length > 0 ? v : fallback)
|
|
34
|
+
const asPort = (v, fallback) => {
|
|
35
|
+
const n = Number(typeof v === 'object' && v !== null && 'value' in v ? v.value : v)
|
|
36
|
+
return Number.isInteger(n) && n > 0 && n < 65536 ? n : fallback
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** 找出组网候选地址。 */
|
|
40
|
+
function findMesh(pattern, prefix) {
|
|
41
|
+
let re
|
|
42
|
+
try {
|
|
43
|
+
re = new RegExp(pattern)
|
|
44
|
+
} catch {
|
|
45
|
+
re = /^(StarVPN|utun\d+)$/
|
|
46
|
+
}
|
|
47
|
+
const found = []
|
|
48
|
+
for (const [iface, addrs] of Object.entries(os.networkInterfaces())) {
|
|
49
|
+
for (const a of addrs ?? []) {
|
|
50
|
+
const v4 = a.family === 'IPv4' || a.family === 4
|
|
51
|
+
if (!v4 || a.internal) continue
|
|
52
|
+
if (!re.test(iface)) continue
|
|
53
|
+
if (prefix && !a.address.startsWith(prefix)) continue
|
|
54
|
+
found.push({ iface, address: a.address, netmask: a.netmask })
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return found
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function json(res, code, body) {
|
|
61
|
+
const text = JSON.stringify(body, null, 2)
|
|
62
|
+
res.writeHead(code, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
63
|
+
res.end(text)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function readBody(req, limit = 8 * 1024) {
|
|
67
|
+
return new Promise((resolve) => {
|
|
68
|
+
let size = 0
|
|
69
|
+
const chunks = []
|
|
70
|
+
req.on('data', (c) => {
|
|
71
|
+
size += c.length
|
|
72
|
+
if (size > limit) return req.destroy()
|
|
73
|
+
chunks.push(c)
|
|
74
|
+
})
|
|
75
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
76
|
+
req.on('error', () => resolve(''))
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 进程内反代:监听组网地址,把请求转给本进程的 Web 服务器。
|
|
82
|
+
* Host 改写成上游 authority —— dsh 的 /api 信任栅栏比对的是 Host,
|
|
83
|
+
* 而 cookie 里的 authority 也按这个 Host 签发,两边一致才不会互相打架。
|
|
84
|
+
*/
|
|
85
|
+
function createProxy({ listenAddress, listenPort, upstream, upstreamPort }) {
|
|
86
|
+
const target = `${upstream}:${upstreamPort}`
|
|
87
|
+
const rewrite = (headers) => {
|
|
88
|
+
const h = { ...headers }
|
|
89
|
+
h.host = target
|
|
90
|
+
h['x-forwarded-host'] = headers.host ?? ''
|
|
91
|
+
h['x-forwarded-proto'] = 'http'
|
|
92
|
+
return h
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const server = http.createServer((req, res) => {
|
|
96
|
+
const up = http.request(
|
|
97
|
+
{ host: upstream, port: upstreamPort, method: req.method, path: req.url, headers: rewrite(req.headers) },
|
|
98
|
+
(r) => {
|
|
99
|
+
res.writeHead(r.statusCode ?? 502, r.headers)
|
|
100
|
+
r.pipe(res)
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
up.on('error', (err) => {
|
|
104
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
105
|
+
res.end(`dsh-mesh-access: 上游不可达(${target}):${err.message}\n`)
|
|
106
|
+
})
|
|
107
|
+
req.pipe(up)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// WebSocket / HTTP upgrade:裸 socket 双向转发(DSH 的实时通道靠它)
|
|
111
|
+
server.on('upgrade', (req, socket, head) => {
|
|
112
|
+
const up = net.connect(upstreamPort, upstream, () => {
|
|
113
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`]
|
|
114
|
+
for (const [k, v] of Object.entries(rewrite(req.headers))) {
|
|
115
|
+
if (v === undefined) continue
|
|
116
|
+
for (const one of Array.isArray(v) ? v : [v]) lines.push(`${k}: ${one}`)
|
|
117
|
+
}
|
|
118
|
+
up.write(lines.join('\r\n') + '\r\n\r\n')
|
|
119
|
+
if (head?.length) up.write(head)
|
|
120
|
+
up.pipe(socket)
|
|
121
|
+
socket.pipe(up)
|
|
122
|
+
})
|
|
123
|
+
const bye = () => {
|
|
124
|
+
up.destroy()
|
|
125
|
+
socket.destroy()
|
|
126
|
+
}
|
|
127
|
+
up.on('error', bye)
|
|
128
|
+
socket.on('error', bye)
|
|
129
|
+
up.on('close', bye)
|
|
130
|
+
socket.on('close', bye)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
// 记下每一条连接:DSH 的实时通道是长连接,server.close() 会一直等它们,
|
|
134
|
+
// 所以关闭时必须能主动把 socket 干掉,否则 stop()/start() 会永久挂住。
|
|
135
|
+
const sockets = new Set()
|
|
136
|
+
server.on('connection', (socket) => {
|
|
137
|
+
sockets.add(socket)
|
|
138
|
+
socket.on('close', () => sockets.delete(socket))
|
|
139
|
+
})
|
|
140
|
+
server.killConnections = () => {
|
|
141
|
+
for (const socket of sockets) socket.destroy()
|
|
142
|
+
sockets.clear()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return new Promise((resolve, reject) => {
|
|
146
|
+
server.once('error', reject)
|
|
147
|
+
server.listen(listenPort, listenAddress, () => {
|
|
148
|
+
server.removeListener('error', reject)
|
|
149
|
+
server.on('error', () => {})
|
|
150
|
+
resolve(server)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function apply(ctx, config = {}) {
|
|
156
|
+
const cfg = {
|
|
157
|
+
interfacePattern: asText(config.interfacePattern, '^(StarVPN|utun\\d+|tap\\d+|tun\\d+|wg\\d+|gpd\\d+|ppp\\d+|ipsec\\d+)$'),
|
|
158
|
+
addressPrefix: typeof config.addressPrefix === 'string' ? config.addressPrefix : '192.168.',
|
|
159
|
+
proxyPort: asPort(config.proxyPort, 3081),
|
|
160
|
+
upstream: asText(config.upstream, '127.0.0.1'),
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const state = { server: null, address: null, port: null, startedAt: null, error: null }
|
|
164
|
+
|
|
165
|
+
const upstreamPort = () => ctx.webServer?.port ?? null
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 给组网地址补上本进程的 launch token。
|
|
169
|
+
* 插件拿不到 token 明文,但 connection 服务公开了 authenticatedUrl(),正是
|
|
170
|
+
* `dsh web` 打印地址时用的那一条,所以这里借用它,而不是自己造 token。
|
|
171
|
+
*/
|
|
172
|
+
const signed = (baseUrl) => {
|
|
173
|
+
if (!baseUrl) return null
|
|
174
|
+
try {
|
|
175
|
+
return ctx.connection?.authenticatedUrl?.(baseUrl) ?? baseUrl
|
|
176
|
+
} catch {
|
|
177
|
+
return baseUrl
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const snapshot = () => {
|
|
181
|
+
const mesh = findMesh(cfg.interfacePattern, cfg.addressPrefix)
|
|
182
|
+
const port = upstreamPort()
|
|
183
|
+
const running = Boolean(state.server)
|
|
184
|
+
const address = state.address ?? mesh[0]?.address ?? null
|
|
185
|
+
const listenPort = state.port ?? cfg.proxyPort
|
|
186
|
+
return {
|
|
187
|
+
ok: true,
|
|
188
|
+
now: Date.now(),
|
|
189
|
+
host: { upstream: cfg.upstream, port },
|
|
190
|
+
interfaces: Object.entries(os.networkInterfaces())
|
|
191
|
+
.flatMap(([iface, addrs]) => (addrs ?? []).filter((a) => (a.family === 'IPv4' || a.family === 4) && !a.internal).map((a) => ({ iface, address: a.address })))
|
|
192
|
+
.filter((x) => !cfg.addressPrefix || x.address.startsWith(cfg.addressPrefix)),
|
|
193
|
+
mesh,
|
|
194
|
+
proxy: {
|
|
195
|
+
running,
|
|
196
|
+
address,
|
|
197
|
+
port: listenPort,
|
|
198
|
+
target: port ? `${cfg.upstream}:${port}` : null,
|
|
199
|
+
url: running && address ? signed(`http://${address}:${listenPort}/`) : null,
|
|
200
|
+
urlPlain: running && address ? `http://${address}:${listenPort}/` : null,
|
|
201
|
+
startedAt: state.startedAt,
|
|
202
|
+
error: state.error,
|
|
203
|
+
},
|
|
204
|
+
referral: REFERRAL,
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** 关掉一个代理服务:先拒绝新连接,再强断旧连接,最多等 500ms,绝不挂死。 */
|
|
209
|
+
async function closeServer(server) {
|
|
210
|
+
if (!server) return
|
|
211
|
+
const closed = new Promise((resolve) => server.close(() => resolve()))
|
|
212
|
+
try {
|
|
213
|
+
server.closeAllConnections?.()
|
|
214
|
+
server.killConnections?.()
|
|
215
|
+
} catch {
|
|
216
|
+
/* 尽力而为 */
|
|
217
|
+
}
|
|
218
|
+
await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, 500))])
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function start(port) {
|
|
222
|
+
const target = upstreamPort()
|
|
223
|
+
if (!target) return { ok: false, error: '上游端口未知:Web 服务器还没绑定?' }
|
|
224
|
+
const mesh = findMesh(cfg.interfacePattern, cfg.addressPrefix)
|
|
225
|
+
if (mesh.length === 0) {
|
|
226
|
+
return { ok: false, error: `没找到组网网卡(正则 ${cfg.interfacePattern},前缀 ${cfg.addressPrefix || '不限'})。先把星空/Tailscale/WireGuard 连上。` }
|
|
227
|
+
}
|
|
228
|
+
const listenPort = port ?? state.port ?? cfg.proxyPort
|
|
229
|
+
// 已经在这个端口上跑着,就直接返回现状 —— 重复点「开端口」不该重启监听,
|
|
230
|
+
// 否则会先关后开,中间那一刀很容易把手机上的长连接切死。
|
|
231
|
+
if (state.server && state.port === listenPort) return { ok: true, ...snapshot() }
|
|
232
|
+
if (starting) return starting
|
|
233
|
+
starting = run(listenPort, mesh, target)
|
|
234
|
+
try {
|
|
235
|
+
return await starting
|
|
236
|
+
} finally {
|
|
237
|
+
starting = null
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
let starting = null
|
|
242
|
+
async function run(listenPort, mesh, target) {
|
|
243
|
+
if (state.server) {
|
|
244
|
+
const old = state.server
|
|
245
|
+
state.server = null
|
|
246
|
+
await closeServer(old)
|
|
247
|
+
}
|
|
248
|
+
const address = mesh[0].address
|
|
249
|
+
try {
|
|
250
|
+
state.server = await createProxy({ listenAddress: address, listenPort, upstream: cfg.upstream, upstreamPort: target })
|
|
251
|
+
state.address = address
|
|
252
|
+
state.port = listenPort
|
|
253
|
+
state.startedAt = Date.now()
|
|
254
|
+
state.error = null
|
|
255
|
+
ctx.logger?.info?.(`dsh-mesh-access: ${address}:${listenPort} → ${cfg.upstream}:${target}`)
|
|
256
|
+
return { ok: true, ...snapshot() }
|
|
257
|
+
} catch (err) {
|
|
258
|
+
state.server = null
|
|
259
|
+
state.error = String(err?.message ?? err)
|
|
260
|
+
return { ok: false, error: state.error }
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function stop() {
|
|
265
|
+
const server = state.server
|
|
266
|
+
state.server = null
|
|
267
|
+
state.startedAt = null
|
|
268
|
+
if (!server) return { ok: true, ...snapshot() }
|
|
269
|
+
await closeServer(server)
|
|
270
|
+
state.error = null
|
|
271
|
+
return { ok: true, ...snapshot() }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
ctx.effect(() => {
|
|
275
|
+
const routes = [
|
|
276
|
+
{
|
|
277
|
+
kind: 'exact',
|
|
278
|
+
path: '/api/dsh-mesh-access/status',
|
|
279
|
+
handler: (req, res) => json(res, 200, snapshot()),
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
kind: 'exact',
|
|
283
|
+
path: '/api/dsh-mesh-access/proxy',
|
|
284
|
+
handler: async (req, res) => {
|
|
285
|
+
if (req.method !== 'POST') return json(res, 405, { ok: false, error: 'POST only' })
|
|
286
|
+
let body = {}
|
|
287
|
+
try {
|
|
288
|
+
body = JSON.parse((await readBody(req)) || '{}')
|
|
289
|
+
} catch {
|
|
290
|
+
return json(res, 400, { ok: false, error: 'body 不是合法 JSON' })
|
|
291
|
+
}
|
|
292
|
+
const action = String(body.action ?? '')
|
|
293
|
+
if (action === 'start') return json(res, 200, await start(body.port ? Number(body.port) : undefined))
|
|
294
|
+
if (action === 'stop') return json(res, 200, await stop())
|
|
295
|
+
return json(res, 400, { ok: false, error: "action 只能是 'start' 或 'stop'" })
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
]
|
|
299
|
+
const disposers = routes.map((route) => ctx.webServer.register(route))
|
|
300
|
+
return () => {
|
|
301
|
+
for (const d of disposers) d()
|
|
302
|
+
void stop()
|
|
303
|
+
}
|
|
304
|
+
}, 'dsh-mesh-access: routes + proxy')
|
|
305
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mesh-access",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Expose the DSH web UI on a mesh/VPN interface (StarVPN, Tailscale, WireGuard tun) from inside the running server: in-process reverse proxy + status panel, so any device on the mesh — phone, laptop, NAS — opens it in a browser. No second instance.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"default": "./lib/index.js"
|
|
10
|
+
},
|
|
11
|
+
"./client": {
|
|
12
|
+
"default": "./lib/client.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"lib",
|
|
18
|
+
"cordis.patch.yml",
|
|
19
|
+
"icon.svg",
|
|
20
|
+
"README.md",
|
|
21
|
+
"README.en.md",
|
|
22
|
+
"INSTALL.md",
|
|
23
|
+
"INSTALL.en.md",
|
|
24
|
+
"CHANGELOG.md",
|
|
25
|
+
"LICENSE",
|
|
26
|
+
"docs",
|
|
27
|
+
"screenshots.json",
|
|
28
|
+
"assets"
|
|
29
|
+
],
|
|
30
|
+
"engines": {
|
|
31
|
+
"node": ">=22"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"@deepseek-ai/dsh": ">=0.2.1-rc.2"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"engines": {
|
|
38
|
+
"dsh": ">=0.2.1-rc.2"
|
|
39
|
+
},
|
|
40
|
+
"bundle": {
|
|
41
|
+
"patch": "./cordis.patch.yml"
|
|
42
|
+
},
|
|
43
|
+
"client": {
|
|
44
|
+
"inject": [
|
|
45
|
+
"@deepseek-ai/dsh-client-ui-renderer"
|
|
46
|
+
],
|
|
47
|
+
"platform": "web"
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"keywords": [
|
|
51
|
+
"dsh",
|
|
52
|
+
"dsh-plugin",
|
|
53
|
+
"deepseek-harness",
|
|
54
|
+
"mesh",
|
|
55
|
+
"vpn",
|
|
56
|
+
"starvpn",
|
|
57
|
+
"tailscale",
|
|
58
|
+
"wireguard",
|
|
59
|
+
"remote-access",
|
|
60
|
+
"reverse-proxy",
|
|
61
|
+
"nas",
|
|
62
|
+
"remote-desktop"
|
|
63
|
+
],
|
|
64
|
+
"license": "MIT",
|
|
65
|
+
"icon": "icon.svg",
|
|
66
|
+
"repository": {
|
|
67
|
+
"type": "git",
|
|
68
|
+
"url": "git+https://github.com/SilverKurali/dsh-mesh-access.git"
|
|
69
|
+
},
|
|
70
|
+
"homepage": "https://github.com/SilverKurali/dsh-mesh-access#readme",
|
|
71
|
+
"bugs": {
|
|
72
|
+
"url": "https://github.com/SilverKurali/dsh-mesh-access/issues"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/screenshots.json
ADDED