dsh-pocket-relay 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/README.md ADDED
@@ -0,0 +1,258 @@
1
+ <p align="center">
2
+ <img src="docs/banner.jpg" alt="DSH Pocket Relay" width="100%">
3
+ </p>
4
+
5
+ <h1 align="center">DSH Pocket Relay</h1>
6
+
7
+ <p align="center"><a href="README.en.md">English</a> | <a href="README.md">中文</a></p>
8
+
9
+ <p align="center">
10
+ <a href="https://www.npmjs.com/package/dsh-pocket-relay"><img alt="npm" src="https://img.shields.io/npm/v/dsh-pocket-relay?color=4d6bfe&label=npm"></a>
11
+ <a href="https://www.npmjs.com/package/dsh-pocket-relay"><img alt="downloads" src="https://img.shields.io/npm/dm/dsh-pocket-relay?color=4d6bfe"></a>
12
+ <a href="https://github.com/kinderao/dsh-pocket-relay/actions"><img alt="CI" src="https://github.com/kinderao/dsh-pocket-relay/actions/workflows/release.yml/badge.svg"></a>
13
+ <a href="LICENSE"><img alt="License: GPL-2.0" src="https://img.shields.io/badge/license-GPL--2.0-red.svg"></a>
14
+ <a href="https://github.com/kinderao/dsh-pocket-relay/stargazers"><img alt="GitHub stars" src="https://img.shields.io/github/stars/kinderao/dsh-pocket-relay"></a>
15
+ <a href="https://awesome-dsh-plugin.com/zh/"><img alt="Awesome DSH Plugin" src="https://awesome-dsh-plugin.com/badge.svg"></a>
16
+ </p>
17
+
18
+ > 把 **DeepSeek Harness 装进你的口袋**:局域网扫码即用;人在外面,走**你自己服务器的中继**随时随地访问——设备级认证,多台电脑共存与热备。
19
+
20
+ > 本项目基于 [shaobeichen/dsh-pocket](https://github.com/shaobeichen/dsh-pocket) 二次开发(GPL-2.0):把公网传输从 cloudflared 隧道改为**自建 relay 中继**(Go 单二进制服务端 + Web 管理端),并增加了设备认证与多 PC 端管理。
21
+
22
+ ## 这是什么
23
+
24
+ **你不在电脑前,也想用电脑上的 DeepSeek Harness。**
25
+
26
+ - 下班路上,agent 在电脑上跑任务,你想掏出手机看看它干到哪了、结果如何
27
+ - 出门在外,突然想让电脑上的 agent 查点资料、写段代码,但没有远程桌面、没有 SSH
28
+ - 电脑在宿舍/办公室,你人在外面,想随时"操控你的 DeepSeek Harness"——发任务、看输出、点审批
29
+
30
+ DSH Pocket 就是干这个的:**装上它,手机扫个码,就能实时看到并操控电脑上的 DeepSeek Harness 界面**——人在外面也能用。
31
+
32
+ 实际效果——手机上的界面就是电脑上的界面,实时同步:
33
+
34
+ <p align="center">
35
+ <img src="docs/interface.jpg" alt="手机上的 DSH 界面" width="100%">
36
+ </p>
37
+
38
+ ## ✨ 特性
39
+
40
+ | 特性 | 说明 |
41
+ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
+ | 📶 局域网扫码 | 装好即用:设置 → 手机访问,打开就有局域网二维码,手机连同一 WiFi 扫码即开(自动识别本机局域网 IP,**WSL 环境自动取 Windows 物理网卡 IP**) |
43
+ | 🚪 局域网开关 | 设置页可**一键关闭/开启局域网访问**(切换时弹窗提醒):关闭后局域网二维码/链接立即失效,仅公网可用 |
44
+ | 🌐 公网扫码(人在外面) | 点「开启公网访问」→ cloudflared 隧道 → 出公网二维码,4G/任何网络都能访问 |
45
+ | 🏷️ 公网固定域名 | 可选「**命名隧道**」模式:填 Cloudflare Tunnel Token + 自己的域名,公网地址**固定不变**(重启不再变;见下方说明) |
46
+ | 🔐 访问密码 | 公网链接需输入 **8 位密码**(默认每次开启公网自动换新;**可自定义固定密码**——自定义后不再换新);局域网有独立 **8 位密码**(默认开启,设置页可**一键关闭**——关闭后局域网扫码直连) |
47
+ | 🔑 自定义密码 | 公网/局域网密码都可在设置页**设成自己固定的 8 位密码(英文字母大小写或数字)**(自定义后公网不再自动换新) |
48
+ | 🧘 会话保持 | 手机输一次密码后**长期免输**(登录状态绑定电脑上的 dsh web 进程:只要它不重启,手机不用再输;**dsh web 重启/更新后需重新输入一次**) |
49
+ | ⚡ 实时同步 | 流式输出走 WebSocket 全透传——**电脑上在输出,手机上同步在滚**,可双向操作;内置心跳保活(防路由器 NAT/省电机制静默断链,断线自动重连) |
50
+ | 📱 移动端适配 | 窄屏自动变抽屉布局(移植 dsh-web-mobile,MIT):侧栏抽屉、会话全宽、状态栏安全区、触控优化 |
51
+ | 📁 文件浏览 | 移动端「文件浏览」入口需要宿主提供 explorer 面板(dsh-web-ui 组件);官方 DSH 未内置时入口自动隐藏,不会出现"点了没反应" |
52
+ | 🗜️ 传输压缩 | 大 JSON 响应自动 gzip/brotli(长会话 17MB → ~1MB,brotli 质量 6:快且省流量),手机加载更快、更省流量 |
53
+ | 🔁 隧道自动恢复 | DSH 重启后自动重新拉起之前开着的公网隧道,无需手动重开 |
54
+ | 🛰 自建中继 | 用**自己的服务器**中转(PC 与手机都出站连接),不依赖 Cloudflare、地址固定、PC 无需任何入站端口;TLS 由 relay 自己终结,可跑在任意端口(不必占 443) |
55
+ | 📵 设备认证 | 中继通道用**设备认证**而非共享密码:每台手机一个独立密码,可单独撤销;凭据永不落盘(只存 SHA-256),登录需「凭据 + 密码」同时成立 |
56
+ | 🔛 常开(无人监听) | 中继配置好即常开:只要 dsh 在跑就在线,不用每次手动开;带急停「暂停远程」 |
57
+ | 🧩 零依赖安装 | 一个 npm 包、一个设置页,没有核心/适配器要分开装;无需账号、无需服务器 |
58
+
59
+ ## 🚀 怎么用
60
+
61
+ **入口在哪**:安装完成并重启 `dsh web` 后,打开 **设置**,左侧边栏就能看到 **「手机访问」** 入口(和「通用设置」「模型」同级):
62
+
63
+ <p align="center">
64
+ <img src="docs/entry.jpg" alt="手机访问入口" width="70%">
65
+ </p>
66
+
67
+ **前提**:电脑上已装好 [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)。如果终端提示 `dsh: command not found`(找不到 dsh 命令),先安装:
68
+
69
+ ```sh
70
+ npm install -g @deepseek-ai/dsh # 全局安装;验证:dsh --version
71
+ # 不想全局装?每次命令前加 npx:npx @deepseek-ai/dsh <命令>
72
+ ```
73
+
74
+ ```sh
75
+ # 1. 装插件(一个包全都有)
76
+ dsh plugin --profile web add dsh-pocket-relay -w
77
+
78
+ # 2. 重启 dsh web
79
+ npx @deepseek-ai/dsh web
80
+ ```
81
+
82
+ ### 局域网(同一 WiFi)
83
+
84
+ 设置 → **手机访问** → 手机扫「📶 局域网」二维码 → 打开链接**输入局域网密码**(显示在设置页局域网区块,点「刷新」可换新,或点「自定义」设成自己固定的 8 位密码——英文字母大小写或数字)→ 打开的就是电脑上的 DSH,实时同步。
85
+
86
+ > 「**局域网访问**」开关默认**开**:可一键**关闭/开启**(切换时弹窗提醒)——关闭后局域网二维码/链接立即失效(手机打不开),**公网不受影响**;想恢复时再点「开」即可。
87
+ >
88
+ > 局域网密码**默认开启**(安全优先)。如果只有自己用、嫌每次输密码麻烦,可在设置页局域网区块把「局域网访问密码」切到**关**——之后局域网扫码直连、无需密码(仅同一局域网设备可访问;**公网始终要密码**,不受影响)。
89
+ >
90
+ > 手机登录一次后**长期免输**:只要电脑上的 dsh web 不重启,再次打开手机不用再输入(**dsh web 重启/更新后需重新输入一次**)。
91
+ >
92
+ > 高级选项:自动识别在 Tailscale/VPN 等场景下可能选不到可达地址。可在「局域网地址」下拉框手动选择已检测到的 IP;一般不需要修改。
93
+
94
+ ### 公网(人在外面)
95
+
96
+ 同一页点「**开启公网访问**」→ **每次都会先弹出安全免责声明**,勾选「我已知情」后才能开启(公司/涉密网络请先确认合规)→ 等隧道建立(首次会下载 cloudflared,macOS/Linux 走清华镜像秒下)→ 手机扫「🌐 公网」二维码 → 打开链接**输入 8 位访问密码**(密码显示在设置页公网区块,默认**每次开启公网变新**,也可点「自定义」设成固定密码——英文字母大小写或数字,自定义后不再换新)→ 人在外面(4G/公司网)也能访问。
97
+
98
+ > 更新到新版本:`dsh plugin --profile web update dsh-pocket-relay --latest -w`(跨大版本时 `--latest` 是必须的,`^0.x` 范围不会自动升到 1.x)。
99
+
100
+ ### 公网固定域名(命名隧道,可选)
101
+
102
+ 默认「快速隧道」的公网地址每次重启都会变(前缀随机)。想要**固定公网地址**,可用 Cloudflare **命名隧道**(需要 Cloudflare 账号 + 自己的域名):
103
+
104
+ 1. 在 [Cloudflare Zero Trust](https://one.dash.cloudflare.com/) → **Networks → Tunnels** 创建一条 Tunnel,复制 **Tunnel Token**
105
+ 2. 在该 Tunnel 的 **Public Hostname** 里把你的域名(如 `pocket.example.com`)的 Service 指向 `http://127.0.0.1:3081`
106
+ 3. 回到设置页公网区块:模式切到「**命名隧道**」,粘贴 Tunnel Token、填写固定域名,保存
107
+ 4. 点「开启公网访问」→ 公网地址固定为你的域名,**重启不再变化**
108
+
109
+ 注意:命名隧道模式下公网密码**不自动轮换**(地址固定,重启后密码不变),建议配合「自定义密码」主动管理;Tunnel Token 只存本机(`$DSH_HOME/dsh-pocket/settings.json`,仅本机可读),设置页不回显。
110
+
111
+ ### 中继(自建服务器,长期使用推荐)
112
+
113
+ 如果你有一台带公网 IP 的服务器,用它当中继比 Cloudflare 隧道更稳、地址固定、也不依赖第三方:
114
+
115
+ ```
116
+ 手机 ──HTTPS──▶ 你的服务器(relay) ◀──出站长连接── 电脑上的 dsh web
117
+ ```
118
+
119
+ 1. 编译并部署 relay(**Go 写的单个静态二进制,零运行时依赖**):
120
+ ```sh
121
+ npm run build:relay # 产出 relay/dist/
122
+ scp relay/dist/dsh-pocket-relay-linux-amd64 <服务器>:/opt/dsh-pocket-relay/dsh-pocket-relay
123
+ # 服务器上:
124
+ ./dsh-pocket-relay gen-token # 生成 agent token
125
+ cp config.example.json config.json && vi config.json
126
+ ./dsh-pocket-relay check --config config.json # 只校验配置
127
+ nohup ./dsh-pocket-relay --config config.json > relay.log 2>&1 &
128
+ ```
129
+ 2. 设置页 → 手机访问 → **中继**:填服务器地址、agent 端口、对外访问地址、Token。
130
+ 3. 打开常开开关(首次会弹一次安全声明),之后 **dsh 一起来就在线**。
131
+ 4. 点「添加设备」→ 手机扫配对二维码 → 设置该设备的独立密码 → 回电脑点「批准」。
132
+
133
+ 要点:
134
+
135
+ - relay **自己终结 TLS**,可以跑在任意端口(不占 443);证书既可以沿用你已有的签发产物,
136
+ 也可以让程序**内置 ACME(DNS-01)自动申请与续期**——DNS-01 不需要开放 80/443。
137
+ - 自带 **Web 管理端**(状态 / 证书续期 / 访客白名单 / 日志)。
138
+ - 中继通道走**设备认证**:每台手机一个独立密码,可在设置页单独撤销;凭据永不落盘。
139
+ - 设备会话 10 分钟无操作失效(**只按真实操作续期**,心跳/后台请求不算)。
140
+ - 不想长期开着就点「暂停远程」;局域网与 Cloudflare 通道不受影响。
141
+
142
+ 完整部署(nohup 与 systemd、ACME 配置、管理端、排障、安全清单)见
143
+ **[relay/README.md](./relay/README.md)**。
144
+
145
+ ## ⚠️ 安全(必读)
146
+
147
+ - **DSH 能执行你电脑上的代码**。**局域网**二维码/URL 配上独立 **8 位密码**才是钥匙(密码**默认开启**,可关——关闭后局域网扫码直连,仅同一网络设备可访问),**请勿把局域网二维码、URL 或密码发给别人**
148
+ - **开启公网访问前必须阅读并勾选免责声明**(每次开启都会弹框;服务端强制校验,无法绕过):公网 = 把能执行代码的 DSH 暴露到互联网,请使用强密码、用完即关、涉密网络勿用
149
+ - **公网**有 **8 位密码**保护:链接随机分配、默认每次开启换新密码、旧链接立即作废——泄露了也进不来,改密码/重开即可作废;**自定义密码后不再自动换新**(你设的值即稳定密码,可为英文字母大小写或数字)
150
+ - 手机登录状态与电脑上的 dsh web 进程绑定:**电脑 dsh web 一直开着就不用重复输入;重启/更新后需重新输入一次**
151
+ - **登录限速**(防暴力破解):同一 IP 连续输错 **5 次**锁定 **60 秒**;全局失败超阈值时短暂全锁(防换 IP 分布式扫描);输对密码后计数清零
152
+ - 公网 URL 由 cloudflared 随机分配,**每次重启会变化**(旧链接自动失效,相当于天然轮换);**命名隧道固定域名**模式下地址不变、密码不自动轮换,请配合自定义密码管理
153
+ - **公网判定是 fail closed**(issue #66):除本机(loopback)和局域网私网地址外,**一切陌生域名(包括你自建隧道/反向代理指向本机端口的固定域名)一律按公网处理、强制公网密码**——不存在「换域名绕过密码」的口子
154
+ - 局域网模式不暴露公网,只有同一网络内的设备能访问
155
+ - 适合个人自用;公网密码存本机 `$DSH_HOME/dsh-pocket/token`(默认每次开启公网自动换新,**自定义后不换**),局域网密码存 `$DSH_HOME/dsh-pocket/token-lan`(设置页手动刷新),开关/自定义标记存 `$DSH_HOME/dsh-pocket/settings.json`
156
+ - **CLI 模式(命令行直跑 `dsh-pocket-relay`)也有密码**(issue #90 修复前这条路是无认证的):默认随机生成 8 位密码,打印在终端、并已内嵌进二维码(**扫码体验不变**),手动敲地址时需要填写,本机访问免密。`--pin <值>` 或 `DSH_POCKET_PIN=<值>` 自定义(至少 6 位);`--no-auth` 可关闭,**不推荐**——那等于把能执行代码的 DSH 裸暴露给任何能连上该端口的人
157
+
158
+ ## 💻 DSH Desktop(桌面版)
159
+
160
+ - 桌面版里 dsh-pocket-relay 的**扫码同屏**正常可用;**更新/重启由桌面版管理**(插件内这两项自动停用)
161
+ - ⚠️ 桌面端 **advanced 模式**暂不支持手机访问(该模式禁用网页布局、手机拿不到 layout 服务,会白屏)——请切回 **compatibility** 模式后重启;advanced 模式下手机打开会看到明确的提示层
162
+
163
+ ## 🩹 常见问题(别踩的坑)
164
+
165
+ | 现象 | 原因与解决 |
166
+ | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
167
+ | `dsh: command not found` / 提示 DSH 未定义 | dsh CLI 没装:`npm install -g @deepseek-ai/dsh`,或命令前加 `npx @deepseek-ai/dsh` |
168
+ | `ERR_PNPM_ADDING_TO_ROOT` | pnpm 9 对 workspace 根的限制:安装/更新命令**末尾加 `-w`**(`--workspace-root`) |
169
+ | 装完/更新了但界面没变化 | **必须重启 `dsh web`** 才生效;运行中的进程仍加载旧代码 |
170
+ | `listen EADDRINUSE ... :3081` | 旧 dsh-pocket-relay 进程还占着端口:macOS/Linux `lsof -ti :3081 \| xargs kill -9`;Windows `netstat -ano \| findstr :3081`(找 LISTENING 的 PID)→ `taskkill /PID <PID> /F`,后重试 |
171
+ | 想换端口(issue #70) | 插件模式:在 `$DSH_HOME/dsh-pocket/settings.json` 写 `"proxyPort": 3082` 后重启 `dsh web`。CLI 模式:`dsh-pocket-relay --port 3082`。端口被占会报 `EADDRINUSE`,杀掉旧进程或换一个端口 |
172
+ | 想给访客一个临时密码 | 暂不支持:issue #69 的「临时访问 PIN」功能已在 2.6.x 移除(撤销时会崩)。现在分享访问:把主密码或 `?token=<主密码>` 链接发给对方,用完在设置页点「刷新」换掉即可 |
173
+ | Linux 服务器装不上 cloudflared(issue #45) | 远程 Linux 国内/企业网下所有 CDN 源(GitHub/ghproxy/gh.ddlc/gh-proxy)都连不上时:在服务器上手动装 `cloudflared`(如 `apt install cloudflared`、`dnf install cloudflared`、或下载 tgz 解压到任意目录),然后在 `$DSH_HOME/dsh-pocket/settings.json` 加 `"cloudflaredPath": "/path/to/cloudflared"`,重启 `dsh web` 后插件直接调用它,**不再走自动下载** |
174
+ | 版本停在 0.x 升不上去 | `^0.x` 范围不允许升到 1.x:更新用 `--latest`(`dsh plugin --profile web update dsh-pocket-relay --latest -w`) |
175
+ | 公网 `error 1033` | 见下方「公网隧道常见问题」——多半是本机代理/VPN(Clash 等 TUN 模式)掐断了隧道 |
176
+ | 点「重启 dsh web」后页面提示进程在后台运行 | 自重启的新进程是 detached 后台进程(不挂终端),是页内更新的标准做法;停止它:macOS/Linux `lsof -ti :3080 \| xargs kill -9`;Windows `netstat -ano \| findstr :3080` → `taskkill /PID <PID> /F`(日志在 `$DSH_HOME` 下 `dsh-pocket-restart-*.log`) |
177
+
178
+ ## ⚠️ 公网隧道常见问题(必读)
179
+
180
+ **现象**:点「开启公网访问」后,手机上打开公网地址报 `error 1033`(Tunnel error)。
181
+
182
+ **最常见原因:本机开着代理/VPN(Clash、Surge、v2ray、sing-box 等,尤其 TUN 模式)**。
183
+ 这类工具会接管全部流量,并常常把 cloudflared 的隧道边缘连接
184
+ (`*.argotunnel.com`、Cloudflare 边缘 IP)掐断,导致隧道注册成功但数据面连不上。
185
+
186
+ **解决(从轻到重,按顺序试)**:
187
+
188
+ 1. 先**只关闭代理的 TUN 模式**,不用退出代理软件——多数情况这一步就够:
189
+ - Clash:设置里关掉「**TUN 模式**」开关(或右键菜单栏图标 → 取消勾选 TUN 模式)
190
+ - Surge:关「**增强模式**」;v2ray/sing-box:关「**虚拟网卡/路由接管**」
191
+ - 然后回设置页重新点「开启公网访问」
192
+ 2. 仍不行就**彻底退出代理软件**(不只是关界面:Clash 要右键菜单栏图标 → 退出;若装有
193
+ 后台服务还要在服务管理器里停掉,`ps aux | grep clash` 确认进程消失),再重试
194
+ 3. 给代理加**直连规则**,放行隧道域名与 Cloudflare 边缘(Clash 规则示例):
195
+ ```yaml
196
+ - DOMAIN-SUFFIX,argotunnel.com,DIRECT
197
+ - DOMAIN-SUFFIX,trycloudflare.com,DIRECT
198
+ - IP-CIDR,198.41.192.0/24,DIRECT,no-resolve
199
+ ```
200
+ 4. 网络实在不通时,改用**局域网模式**:手机开热点 → 电脑连手机热点 → 扫局域网码,
201
+ 效果完全一样(人在外面也能用)
202
+
203
+ **其他可能**:企业防火墙/校园网拦截出站;此时请让 IT 放行或改用热点。
204
+
205
+ **首次开启时「下载 cloudflared」失败/卡住**:
206
+
207
+ - **macOS/Linux**:优先走**清华镜像**(实测 ~3MB/s,几秒下完);失败自动回退官方 GitHub + 加速源。
208
+ - **Windows**:无清华镜像(Homebrew 不支持 Windows),走官方直连下载(约 50MB,**单线程会慢,属正常**,耐心等几分钟;也可挂代理加速)。
209
+ - 全部失败时设置页会给出提示。备选方案(任选其一):
210
+
211
+ 1. 手动装好命令行 cloudflared 后重试(装好后 dsh-pocket-relay 直接用 PATH 里的,不再下载):
212
+ - macOS:`brew install cloudflared`;Linux:`sudo apt install cloudflared` 或官网下载
213
+ - Windows:`winget install cloudflared` 或官网下载
214
+ - 任何平台:`npm i -g cloudflared`
215
+ 2. 挂代理(系统代理/Clash 等)后重新点「开启公网访问」
216
+ 3. 手动下载二进制放到 `$DSH_HOME/dsh-pocket/bin/` 目录(`$DSH_HOME` 一般是 `~/.dsh`,Windows 是 `%USERPROFILE%\.dsh`;文件名用 `cloudflared`(Windows 加 `.exe`)或发布资产名均可,插件都认)
217
+
218
+ ## 🗂 架构(单包)
219
+
220
+ | 文件 | 说明 |
221
+ | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
222
+ | `lib/index.js` | 插件入口:自动起代理 + 注册 RPC + 访问密码管理(公网 8 位每次开启变新;局域网独立 8 位可手动刷新/开关)+ 局域网访问总开关 + 桌面端环境适配 |
223
+ | `lib/settings.mjs` | 设置持久化:局域网访问总开关(默认开启)+ 局域网密码开关(默认开启)存 `$DSH_HOME/dsh-pocket/settings.json` |
224
+ | `lib/service.mjs` | 服务:代理生命周期(端口自适应)、公网隧道(自动恢复)、状态快照(含二维码) |
225
+ | `lib/proxy.mjs` | 改头反向代理:Host/Origin → loopback,HTTP + WebSocket 透传 + polyfill 注入 + gzip/brotli 压缩 + 按 Host 区分的访问令牌认证(公网必验;局域网按开关)+ 局域网关闭时拦截局域网 Host |
226
+ | `lib/tunnel.mjs` | cloudflared:多镜像源下载(清华优先)/自适应多线程/启动/解析公网 URL(HTTP/2) |
227
+ | `lib/web-rpc.js` | loopback RPC:`status` / `tunnel.start` / `tunnel.stop` / `lan.setEnabled` / `version` / `update` / `restart` |
228
+ | `client/` | 设置页「手机访问」+ 移动端适配(dsh-web-mobile 移植) |
229
+ | `bin/dsh-pocket-relay.mjs` | CLI:局域网/公网模式,打印 URL + 二维码 |
230
+ | `lib/relay.mjs` | 中继客户端(PC 侧):出站长连接自建 relay、心跳、指数退避重连、协议版本守卫 |
231
+ | `lib/device-auth.mjs` | 设备认证存储:一次性配对码(仅内存)、scrypt 设备密码、凭据只存 SHA-256、渐进锁定、短会话 |
232
+ | `lib/device-auth-http.mjs` | 设备认证 HTTP 层:登录页 / 配对页 / 活动续期,以及覆盖全通道(含 WebSocket)的认证闸门 |
233
+ | `relay/` | **自建中继服务端(Go,单个静态二进制)**:线协议、中继核心、TLS、内置 ACME(DNS-01)、Web 管理端;详见 [relay/README.md](./relay/README.md) |
234
+
235
+ ## 🛠 开发
236
+
237
+ ```sh
238
+ npm install
239
+ node client/build.mjs # 改 client/ 后重新打包
240
+ npm test # 代理 / 认证 / 压缩 / 隧道 / 服务 / RPC / 设置(109 测试)
241
+ ```
242
+
243
+ **改完想在本机先试?** 不用发版:把插件换成指向本地仓库的软链,重启 dsh web 就是本地代码。完整步骤(含怎么换回 npm 官方版本)见 [LOCAL-DEV.md](./LOCAL-DEV.md)。
244
+
245
+ ## 🤝 致谢
246
+
247
+ - 移动端适配移植自 [mexiaosqwq/dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile)(MIT)
248
+ - 公网隧道基于 [cloudflared](https://github.com/cloudflare/cloudflared)
249
+
250
+ ## 📄 License
251
+
252
+ [GPL-2.0](LICENSE) —— 自由软件许可:可自由使用、修改、分发,但**修改版必须同样以 GPL 开源**并保留版权声明;商用同样适用。
253
+
254
+ > 说明:移动端适配部分移植自 [dsh-web-mobile](https://github.com/mexiaosqwq/dsh-web-mobile)(MIT 许可,兼容 GPL),其版权声明保留在 `client/mobile/LICENSE.dsh-web-mobile`。
255
+
256
+ ---
257
+
258
+ **有问题?欢迎反馈**:遇到 Bug、有想法、想提需求,请到 [GitHub Issues](https://github.com/kinderao/dsh-pocket-relay/issues) 告诉我们 🙏
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env node
2
+ // dsh-pocket-relay — 把 DeepSeek Harness 装进你的口袋
3
+ //
4
+ // 用法:
5
+ // dsh-pocket-relay # 局域网模式:手机同一 WiFi 扫码访问
6
+ // dsh-pocket-relay --public # 公网模式:cloudflared 隧道,人在外面也能访问
7
+ // dsh-pocket-relay --port 3081 # 自定义代理端口(默认 3081;dsh web 保持 3080)
8
+ //
9
+ // 前提:dsh web 已在 127.0.0.1:3080 运行。
10
+ // 手机看到的界面 = 电脑上的界面,实时同步(WebSocket 流式透传)。
11
+
12
+ import { networkInterfaces } from 'node:os';
13
+ import { realpathSync } from 'node:fs';
14
+ import { pathToFileURL } from 'node:url';
15
+ import { createRequire } from 'node:module';
16
+ import { randomBytes, randomInt } from 'node:crypto';
17
+ import { createPocketProxy, classifyHost } from '../lib/proxy.mjs';
18
+ import { startQuickTunnel } from '../lib/tunnel.mjs';
19
+
20
+ const require = createRequire(import.meta.url);
21
+
22
+ /** 自定义密码的最短长度(issue #40:别让人设 `1234`)。 */
23
+ export const MIN_PIN_LENGTH = 6;
24
+
25
+ export function parseArgs(argv) {
26
+ const args = {
27
+ port: 3081,
28
+ host: '0.0.0.0',
29
+ public: false,
30
+ upstream: { host: '127.0.0.1', port: 3080 },
31
+ pin: null,
32
+ noAuth: false,
33
+ };
34
+ for (let i = 0; i < argv.length; i++) {
35
+ const a = argv[i];
36
+ if (a === '--public') args.public = true;
37
+ else if (a === '--port') args.port = Number(argv[++i]) || 3081;
38
+ else if (a === '--host') args.host = argv[++i] ?? '0.0.0.0';
39
+ else if (a === '--pin') args.pin = String(argv[++i] ?? '');
40
+ else if (a === '--no-auth') args.noAuth = true;
41
+ else if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
42
+ }
43
+ return args;
44
+ }
45
+
46
+ /**
47
+ * 决定本次运行用哪个访问密码(issue #90 第 8 条)。
48
+ *
49
+ * CLI 此前**完全不构造 auth**,且默认监听 `0.0.0.0`——任何能连到这个端口的人都能
50
+ * 直接操作 dsh web,而 dsh web 能在宿主机上执行任意代码。插件版一直有 PIN,
51
+ * 只有 CLI 这条路是裸的,属实是缺陷而非取舍。
52
+ *
53
+ * 修法上刻意**不改默认监听地址**:CLI 的主用法就是「手机连同一 WiFi 扫码」,
54
+ * 默认绑 loopback 等于把这个功能废掉。改成默认生成 PIN,并把它内嵌进二维码的
55
+ * `?token=` —— 扫码体验完全不变,手动敲地址的人才会看到登录页。
56
+ *
57
+ * 优先级:`--pin` > `DSH_POCKET_PIN` > CSPRNG 随机生成。
58
+ * @returns {{ pin: string|null, source: 'flag'|'env'|'generated'|'disabled', error?: string }}
59
+ */
60
+ export function resolvePin({ pin = null, noAuth = false } = {}, env = {}) {
61
+ if (noAuth) return { pin: null, source: 'disabled' };
62
+ const explicit = pin != null && pin !== '' ? { value: String(pin), source: 'flag' }
63
+ : (env.DSH_POCKET_PIN ? { value: String(env.DSH_POCKET_PIN), source: 'env' } : null);
64
+ if (explicit) {
65
+ if (explicit.value.length < MIN_PIN_LENGTH) {
66
+ return {
67
+ pin: null,
68
+ source: explicit.source,
69
+ error: `访问密码至少 ${MIN_PIN_LENGTH} 位(当前 ${explicit.value.length} 位)。`
70
+ + `弱口令在公网上撑不过几分钟 | Access password must be at least ${MIN_PIN_LENGTH} characters.`,
71
+ };
72
+ }
73
+ return { pin: explicit.value, source: explicit.source };
74
+ }
75
+ return { pin: String(randomInt(10_000_000, 100_000_000)), source: 'generated' };
76
+ }
77
+
78
+ /**
79
+ * 构造给 createPocketProxy 的 auth(issue #90 第 8 条)。
80
+ * `isProtected` 与插件版语义保持一致:本机免密(能在本机直连本来就说明已经上了机器),
81
+ * 局域网与公网一律要密码。返回 null 表示不启用认证(`--no-auth`)。
82
+ */
83
+ export function buildAuth(pin) {
84
+ if (!pin) return null;
85
+ return {
86
+ sessionKey: randomBytes(32).toString('hex'),
87
+ isProtected: (host) => classifyHost(host) !== 'loopback',
88
+ getToken: () => pin,
89
+ };
90
+ }
91
+
92
+ /** 把访问密码拼进入口 URL,让二维码扫了就能直达(与插件版一致)。 */
93
+ export function entryUrl(base, pin) {
94
+ if (!pin) return base;
95
+ const u = new URL(base);
96
+ u.searchParams.set('token', pin);
97
+ return u.toString();
98
+ }
99
+
100
+ function printHelp() {
101
+ console.log(`dsh-pocket-relay — 手机访问电脑上的 DeepSeek Harness
102
+
103
+ 用法:
104
+ dsh-pocket-relay 局域网模式(手机同一 WiFi)
105
+ dsh-pocket-relay --public 公网模式(cloudflared 隧道,人在外面)
106
+ dsh-pocket-relay --port 3081 自定义代理端口
107
+ dsh-pocket-relay --host 自定义监听地址(默认 0.0.0.0)
108
+ dsh-pocket-relay --pin <值> 自定义访问密码(至少 ${MIN_PIN_LENGTH} 位;也可用环境变量 DSH_POCKET_PIN)
109
+ dsh-pocket-relay --no-auth 关闭访问密码(不推荐,见下)
110
+ dsh-pocket-relay --help 帮助
111
+
112
+ 前提:dsh web 已在 127.0.0.1:3080 运行(npx @deepseek-ai/dsh web)。
113
+
114
+ 安全提醒:dsh web 能在这台机器上执行代码。默认会随机生成一个 8 位访问密码,
115
+ 二维码里已内嵌该密码(扫码直达),手动输入地址时需要填写。本机访问免密。
116
+ --no-auth 会让任何能连到监听端口的人直接控制 dsh web,仅在完全可信的网络里用。
117
+ `);
118
+ }
119
+
120
+ function lanIPv4() {
121
+ const addrs = [];
122
+ for (const ifaces of Object.values(networkInterfaces())) {
123
+ for (const i of ifaces ?? []) {
124
+ if (i.family === 'IPv4' && !i.internal) addrs.push(i.address);
125
+ }
126
+ }
127
+ return addrs[0] ?? null;
128
+ }
129
+
130
+ function printQr(url, label) {
131
+ const qrcodeTerminal = require('qrcode-terminal');
132
+ console.log(`\n${label}\n ${url}`);
133
+ qrcodeTerminal.generate(url, { small: true }, (qr) => console.log(qr));
134
+ console.log('');
135
+ }
136
+
137
+ async function main() {
138
+ const args = parseArgs(process.argv.slice(2));
139
+
140
+ const resolved = resolvePin(args, process.env);
141
+ if (resolved.error) {
142
+ console.error(`❌ ${resolved.error}`);
143
+ process.exit(1);
144
+ }
145
+ const pin = resolved.pin;
146
+ const auth = buildAuth(pin);
147
+
148
+ console.log('🚀 dsh-pocket-relay 启动中…');
149
+ const { port, close } = await createPocketProxy({ ...args, auth });
150
+
151
+ if (pin) {
152
+ console.log(`\n🔐 访问密码:${pin}${resolved.source === 'generated' ? '(本次随机生成)' : ''}`);
153
+ console.log(' 二维码已内嵌密码,扫码直达;手动输入地址时需要填写。本机访问免密。');
154
+ if (resolved.source === 'generated') {
155
+ console.log(` 固定密码:--pin <值> 或 DSH_POCKET_PIN=<值>`);
156
+ }
157
+ } else {
158
+ console.log('\n⚠️ 已用 --no-auth 关闭访问密码。');
159
+ console.log(' dsh web 能在这台机器上执行任意代码——现在任何能连到');
160
+ console.log(` ${args.host}:${args.port} 的人都可以直接控制它。请仅在完全可信的网络里这样用。`);
161
+ }
162
+
163
+ const lan = lanIPv4();
164
+ if (lan) {
165
+ printQr(entryUrl(`http://${lan}:${port}`, pin), '📶 局域网访问(手机连同一 WiFi):');
166
+ } else {
167
+ console.log('⚠️ 未检测到局域网 IP,跳过局域网二维码');
168
+ }
169
+
170
+ // Ctrl+C / kill:停隧道 → 关代理 → 真正退出(修复:之前只停隧道不退出进程)
171
+ const controller = new AbortController();
172
+ let tunnel = null;
173
+ const shutdown = async () => {
174
+ console.log('\n👋 dsh-pocket-relay 已退出 | bye');
175
+ controller.abort();
176
+ tunnel?.kill();
177
+ await close().catch(() => {});
178
+ process.exit(130);
179
+ };
180
+ process.on('SIGINT', () => void shutdown());
181
+ process.on('SIGTERM', () => void shutdown());
182
+
183
+ if (args.public) {
184
+ console.log('🌐 正在建立公网隧道(cloudflared)…');
185
+ try {
186
+ tunnel = await startQuickTunnel({ port, signal: controller.signal });
187
+ printQr(entryUrl(tunnel.url, pin), '🌐 公网访问(人在外面也能用):');
188
+ console.log(' 隧道会持续运行;Ctrl+C 退出(下次启动会换新 URL)');
189
+ } catch (err) {
190
+ console.error(`❌ 公网隧道失败:${err.message}(局域网二维码仍可用)`);
191
+ }
192
+ } else {
193
+ console.log(` (加 --public 开启公网隧道)`);
194
+ }
195
+
196
+ console.log(`✅ dsh-pocket-relay 已就绪:手机扫码上面的二维码,看到的界面与电脑完全一致、实时同步。\n 按 Ctrl+C 停止。`);
197
+ await new Promise(() => {});
198
+ }
199
+
200
+ // 只有被直接执行时才启动(测试里 import 本文件拿纯函数时不能把服务跑起来)。
201
+ // npm 会把 bin 装成 symlink,argv[1] 是 symlink 路径而 import.meta.url 是真实路径,
202
+ // 所以必须先 realpath 再比较,否则装完的 CLI 会变成什么都不做。
203
+ const isDirectRun = (() => {
204
+ try {
205
+ return import.meta.url === pathToFileURL(realpathSync(process.argv[1] ?? '')).href;
206
+ } catch {
207
+ return false;
208
+ }
209
+ })();
210
+
211
+ if (isDirectRun) {
212
+ main().catch((err) => {
213
+ console.error(`❌ dsh-pocket-relay: ${err?.message ?? err}`);
214
+ process.exit(1);
215
+ });
216
+ }
package/client/api.js ADDED
@@ -0,0 +1,88 @@
1
+ // dsh-pocket 设置页签 RPC 契约(client 与 host 共享)
2
+ export const POCKET_RPC_CHANNEL = '/dsh-pocket';
3
+
4
+ export const POCKET_ENDPOINTS = Object.freeze({
5
+ status: 'pocket.status',
6
+ tunnelStart: 'tunnel.start',
7
+ tunnelStop: 'tunnel.stop',
8
+ tunnelSetConfig: 'tunnel.setConfig',
9
+ version: 'pocket.version',
10
+ update: 'pocket.update',
11
+ restart: 'pocket.restart',
12
+ lanTokenRefresh: 'token.lanRefresh',
13
+ lanAuthSetEnabled: 'lanAuth.setEnabled',
14
+ lanSetOverride: 'lan.setOverride',
15
+ lanSetEnabled: 'lan.setEnabled',
16
+ pinSetCustom: 'pin.setCustom',
17
+ pocketReset: 'pocket.reset',
18
+ // 自建中继通道(替代 cloudflared 的传输层)。配置好即常开,无需手动开启。
19
+ relaySetConfig: 'relay.setConfig',
20
+ relaySetEnabled: 'relay.setEnabled',
21
+ // 设备管理(设备认证):生成配对码、批准/拒绝/撤销设备。
22
+ // 这些端点只对本机 loopback 开放——代理层会拒绝非本机来源调 /dsh-pocket(白名单除外),
23
+ // 所以「已通过设备认证的手机」也拿不到它们,见 lib/proxy.mjs。
24
+ deviceList: 'device.list',
25
+ devicePairingCreate: 'device.pairing.create',
26
+ devicePairingCancel: 'device.pairing.cancel',
27
+ deviceApprove: 'device.approve',
28
+ deviceReject: 'device.reject',
29
+ deviceRevoke: 'device.revoke',
30
+ // 移动端「复制文件内容」(issue #17):手机经此 RPC 让主机读取文件正文,
31
+ // 再写入剪贴板——因为手机无法直接打开电脑上的文件。
32
+ fileRead: 'pocket.fileRead',
33
+ });
34
+
35
+ /** 语义化版本比较:a > b 返回正数,相等 0,a < b 负数(数字段 + 预发布后缀)。 */
36
+ export function compareVersions(a, b) {
37
+ const pa = String(a).replace(/^[vV]/, '').split('.');
38
+ const pb = String(b).replace(/^[vV]/, '').split('.');
39
+ for (let i = 0; i < 3; i++) {
40
+ const x = parseInt(pa[i], 10) || 0;
41
+ const y = parseInt(pb[i], 10) || 0;
42
+ if (x !== y) return x - y;
43
+ }
44
+ // 数字段相等:无预发布后缀的更新;都有后缀时按段比较(alpha < beta < rc…,
45
+ // 数字段按数值:rc.9 < rc.10)
46
+ const aPre = String(a).replace(/^[vV]/, '').match(/-.*$/)?.[0] ?? '';
47
+ const bPre = String(b).replace(/^[vV]/, '').match(/-.*$/)?.[0] ?? '';
48
+ if (!aPre && !bPre) return 0;
49
+ if (!aPre) return 1;
50
+ if (!bPre) return -1;
51
+ // 逐段比较:数字段按数值、文本段按字典序
52
+ const aParts = aPre.slice(1).split('.');
53
+ const bParts = bPre.slice(1).split('.');
54
+ const len = Math.max(aParts.length, bParts.length);
55
+ for (let i = 0; i < len; i++) {
56
+ const ax = aParts[i] ?? '';
57
+ const bx = bParts[i] ?? '';
58
+ if (ax === bx) continue;
59
+ const aNum = /^\d+$/.test(ax);
60
+ const bNum = /^\d+$/.test(bx);
61
+ if (aNum && bNum) return Number(ax) - Number(bx); // 数值比较
62
+ if (aNum) return 1; // 数字段 > 文本段
63
+ if (bNum) return -1;
64
+ return ax < bx ? -1 : 1; // 字典序
65
+ }
66
+ return 0;
67
+ }
68
+
69
+ /** 浏览器可见的状态字段(无敏感信息;含二维码 data URL)。 */
70
+ export function redactStatus(s) {
71
+ return {
72
+ proxyRunning: s?.proxyRunning === true,
73
+ proxyPort: s?.proxyPort ?? null,
74
+ lanUrl: s?.lanUrl ?? null,
75
+ lanQr: s?.lanQr ?? null,
76
+ lanCandidates: Array.isArray(s?.lanCandidates) ? s.lanCandidates : [],
77
+ lanIpOverride: s?.lanIpOverride ?? '',
78
+ tunnelRunning: s?.tunnelRunning === true,
79
+ tunnelUrl: s?.tunnelUrl ?? null,
80
+ tunnelQr: s?.tunnelQr ?? null,
81
+ tunnelState: s?.tunnelState ?? { phase: 'idle' },
82
+ tunnelConfig: s?.tunnelConfig ?? { mode: 'quick', hostname: '', tokenSet: false },
83
+ dshPort: s?.dshPort ?? null,
84
+ relayRunning: s?.relayRunning === true,
85
+ relayState: s?.relayState ?? { phase: 'idle' },
86
+ relayQr: s?.relayQr ?? null,
87
+ };
88
+ }
@@ -0,0 +1,47 @@
1
+ // dsh-pocket 网页客户端打包:client/index.jsx → client/client.js
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { build } from 'esbuild';
6
+
7
+ const sourceDir = dirname(fileURLToPath(import.meta.url));
8
+ const packageRoot = resolve(sourceDir, '..');
9
+ const outputPath = resolve(packageRoot, 'client/client.js');
10
+ const loaderId = process.env.DSH_POCKET_CLIENT_ID ?? 'dsh-pocket';
11
+
12
+ const result = await build({
13
+ entryPoints: [resolve(sourceDir, 'index.jsx')],
14
+ bundle: true,
15
+ format: 'cjs',
16
+ platform: 'browser',
17
+ target: ['chrome100'],
18
+ external: ['react', 'react/jsx-runtime', '@deepseek-ai/dsh-client-ui-primitives'],
19
+ write: false,
20
+ minify: process.env.NODE_ENV === 'production',
21
+ legalComments: 'none',
22
+ });
23
+
24
+ const bundled = result.outputFiles?.[0]?.text;
25
+ if (!bundled) throw new Error('esbuild did not produce a client bundle');
26
+
27
+ const wrapped = `window.__ModuleLoader__.load({
28
+ id: ${JSON.stringify(loaderId)},
29
+ factory: (require) => {
30
+ var module = { exports: {} };
31
+ var exports = module.exports;
32
+ // The DSH client module system provides react as a module, never as a
33
+ // global. esbuild keeps react external (see the build config above) and
34
+ // its classic JSX transform emits bare React.createElement calls for the
35
+ // mobile components (which import only named hooks, not React itself), so
36
+ // the bundle must bind React itself - otherwise every mobile component
37
+ // crashes at render time with "ReferenceError: React is not defined".
38
+ var React = require("react");
39
+ ${bundled}
40
+ return module.exports;
41
+ }
42
+ });
43
+ `;
44
+
45
+ await mkdir(dirname(outputPath), { recursive: true });
46
+ await writeFile(outputPath, wrapped, 'utf8');
47
+ console.log(`Wrote ${outputPath}`);