@jaxzhou/dsh-proxy-client
dsh-proxy 的 DSH 客户端插件包(npm 包名 @jaxzhou/dsh-proxy-client,本地目录 packages/dsh-proxy-client):把本机终端里的 dsh web 通过 dsh-proxy-server 反向暴露给同账号的其它端,并提供 GUI 配置页面。
- 宿主半(
lib/index.js,cordis 插件):本地配置/令牌存储、出站 WebSocket 持久连接与自动重连、把服务器开出的虚拟流桥接到 ctx.webServer.port 上的本机 dsh web、注册面板 API。零运行时依赖(用 Node 内置 WebSocket)。
- 浏览器半(
lib/client.js,window.__ModuleLoader__ bundle):侧边栏底部入口 + 键控 main 面板,提供服务器地址、与 textwork.cn 相同的账号登录(手机号+短信验证码 / 账号密码)、连接开关、设备列表与「切换到此端」。
账号与 textwork.cn 一致:手机号 + 短信验证码(含京东云人机验证),或账号密码。登录由代理服务器
内部转调 textwork.cn 后端完成;本机只保存服务器签发的长效 token(默认 30 天),密码不落盘。
安装
# 在跑 dsh web 的机器上(包内声明了 dsh.bundle,安装后自动加入 profile bundles 并激活)
dsh plugin --profile web add @jaxzhou/dsh-proxy-client # 从 npm
dsh plugin --profile web add <仓库路径>/packages/dsh-proxy-client # 或从本地目录(开发时)
dsh --profile web # 重启后生效
启动日志出现下面两行即装载成功:
[dsh-proxy-client] panel API ready; local dsh web on 127.0.0.1:3080
[dsh-proxy-client] not connected yet - configure the server address and log in from the DSH sidebar panel
若把包作为普通依赖安装(不带 bundle 声明),需在 profile 的 cordis.patch.yml 手工加入:
- insert:
- id: jaxzhou-proxy-client
name: '@jaxzhou/dsh-proxy-client'
⚠️ 不要同时使用两种方式:本包自带的 bundle 层会插入同一行;重复的 loader entry id 会让 dsh web
启动失败(duplicate loader entry id: jaxzhou-proxy-client)。如果你以前手工加过这一行,升级/安装本包后
请把手工行删掉(profile 的 cordis.patch.yml 恢复为 [])再重启。
故障隔离(远端不可用时不影响 DSH)
本插件是可选增强:代理服务器宕机、网络不可达、服务器地址写错、甚至本地配置文件不可写,
都不应影响 DSH 本体和其它插件。为此:
| 风险 | 处理 |
|---|
| 启动时阻塞 | apply() 不做任何网络 I/O;首次连接用 setTimeout(0) 推迟到启动路径之外,DSH 启动不等待本插件 |
| 连接挂死 | 连接阶段有 20s 上限(connectTimeoutMs,含 TCP/TLS/WS 升级);握手另有 10s 上限;超时即放弃并按指数退避重试(1s → 30s) |
| 关闭卡住 | stop() 最多等 3s(stopTimeoutMs),并用 generation 计数让被放弃的旧循环彻底失效,不会出现双循环 |
| 未处理 rejection 杀掉进程 | 连接循环、重连、事件回调、节流定时器全部自带捕获;后台任务绝不以 rejection 收尾(unhandled rejection 在现代 Node 上是致命错误,会连带杀掉 DSH) |
| 同步抛错破坏插件树 | apply() 整体不抛:面板路由注册失败只损失面板、隧道照常;配置存储不可用则整个插件安静退出 |
| 配置文件不可写 | 读/写/解析失败一律降级为内存配置(store.lastError 记录原因,日志提示一次),插件继续可用 |
| GUI 渲染异常 | 插件注入的两个 slot 都包在错误边界里:本插件面板崩溃只会显示一块「已隔离」小卡片,不会白屏 DSH 或影响其它插件 slot |
| 面板请求挂住 | 浏览器半的每次调用有 30s 上限;宿主半的面板 API 对上游请求有 20s 上限,服务器不可达时快速返回类型化错误(含上游状态码) |
| 日志刷屏 | 连接失败只在状态变化时记录,退避到 30s 后每分钟最多两条 |
| 进程无法退出 | 插件创建的所有定时器都 unref(),不会阻止 DSH 正常关闭 |
对应的自动化断言见 npm run e2e 的「resilience: remote server unavailable」一节(93 项断言内)。
配置页面
入口在 侧边栏底部、设置(Settings)行正上方:一个「远程访问」行(折叠成窄栏时是圆形图标),
行尾带一个连接状态小圆点(绿=已连接 / 黄=连接中 / 红=认证失败 / 灰=未连接)。点击即可打开配置面板:
| 区块 | 内容 |
|---|
| 状态 | 已连接 / 连接中 / 认证失败 / 未配置,以及当前连接的细节 |
| 本机 DSH Web | 代理将要暴露的本机地址(自动取自 DSH 实际监听端口)与设备名/ID |
| 服务器 | 服务器地址、设备名、自动连接、反向访问(是否共享本机 DSH 启动令牌)、连接 / 断开、保存并应用;外加一行只读的「登录账号」状态(已登录显示显示名 + 切换账号 / 退出登录) |
| 同账号在线端 | 设备名、ID、连接时间、「本机」标记与「在服务器中打开 →」(新标签页打开服务器同源的设备视图 https://<server>/d/<设备ID>/,由服务器经本插件的隧道代理到对方本机的 dsh web) |
生产服务器地址:https://dsh.textwork.cn。
连接与登录流程
面板本身只有「地址 + 连接」,凭据只在弹窗里输入:
填服务器地址 ──► 点「连接」
│
├─ 本机已有可用 token ──► 直接连接(不打扰)
│
└─ 没有 token / token 已失效 ──► 弹出账号输入框
├─ 手机号验证码登录(可注册)
└─ 账号密码登录
↓
提交给代理服务器换取登录令牌(密码不落盘)
↓
自动连接并上线
要点:
- 客户端不保存密码:弹窗里的凭据只提交给代理服务器,服务器校验后返回一个长效 token,
本机只保存这个 token(
$DSH_HOME/dsh-proxy-client.json,0600)。
- 弹窗内容随服务器能力变化:
/api/auth/config 报告该服务器是否支持短信登录 / 账号密码 / 注册
(textwork.cn 三种都支持;--auth local 的独立部署只显示账号密码)。
- 切换服务器会失效旧 token:
/config 发现地址变化时会清掉上一个服务器签发的 token,
下次「连接」重新弹窗——避免拿 A 服务器的 token 去连 B 服务器。
- 登录态失效(服务器重启、token 过期、被吊销)时面板显示「认证失败」,点「连接」会重新弹窗。
- 已登录时也可以点「切换账号」主动换一个账号,或点「退出登录」清除本机 token。
本地配置
$DSH_HOME/dsh-proxy-client.json(默认 ~/.dsh/dsh-proxy-client.json,权限 0600):
{
"serverUrl": "https://dsh.textwork.cn",
"account": "13810385787",
"displayName": "138****5787",
"token": "<代理服务器下发的 bearer token>",
"deviceId": "<本机稳定设备 ID,首次自动生成>",
"deviceName": "desk-pc",
"autoConnect": true,
"shareLoginToken": true
}
密码与短信验证码从不写入该文件。
面板 API(DSH 同源,供面板使用)
| 方法与路径 | 说明 |
|---|
GET /dsh-proxy-client/api/status | 连接状态、脱敏配置、本机 dsh web 地址、同账号设备(带 self 标记) |
GET /dsh-proxy-client/api/auth-config?serverUrl= | 目标服务器支持的登录方式(authMode/phoneLogin/passwordLogin/register) |
GET /dsh-proxy-client/api/captcha-session?serverUrl= | 人机验证风险预判(转发给代理服务器 → textwork 上游) |
POST /dsh-proxy-client/api/send-code | {serverUrl?, phone, captchaSid, captchaToken} → 发送短信验证码 |
POST /dsh-proxy-client/api/login | /register | {serverUrl?, phone, smsCode, displayName?} 或 {serverUrl?, username, password} → 登录并保存 token、连接 |
POST /dsh-proxy-client/api/config | {serverUrl?, deviceName?, autoConnect?, shareLoginToken?} → 保存并重连;地址变化时清除上一个服务器签发的 token,返回体里的 config.hasToken 决定面板是直接连接还是弹窗 |
POST /dsh-proxy-client/api/logout | 注销并断开 |
POST /dsh-proxy-client/api/connect | /disconnect | 手动连接控制 |
POST /dsh-proxy-client/api/attach | {deviceId} → 服务器返回同源代理地址(/d/<设备ID>/?ticket&token),浏览器无需能访问目标机器 |
该路由与 DSH GUI 同源、默认只在回环可达;令牌始终留在宿主半,浏览器半拿不到。
上游状态码会一并回传({ok:false,error,status}),因此验证码服务不可用时前端能与服务器一样「放行」。
反向访问的登录链路
目标端 DSH 的首页需要它自己的进程启动令牌。宿主半通过 DSH Connection 服务的 ctx.connection.authenticatedUrl() 读到该令牌(受「反向访问」开关控制),随 hello 上报给服务器;服务器在 attach URL 上拼接:
https://dsh.textwork.cn/d/<deviceId>/?__dshproxy_ticket=<ticket>&token=<目标端启动令牌>
浏览器停在代理服务器的源上,服务器经隧道转发;目标 DSH 完成 token→cookie 交换,标签页里获得完整可用的 GUI。
开发与测试
# 无需 DSH 的宿主模拟器(跑本包真实的连接管理器)
node scripts/fake-client.ts --server http://127.0.0.1:4000 --username demo --password demo1234 \
--name desk-pc --target 127.0.0.1:3080 --state .tmp/client-desk.json
npm run smoke:client # 用 react-dom/server 真渲染浏览器半(28 项:面板无内联凭据表单、弹窗两种登录方式、错误边界)
npm run e2e # 进程内 97 项断言(含 textwork 认证中转、面板 API 契约与故障隔离)
npm run probe:deployed # 对已部署服务器做真实 TLS 端到端探测(两阶段:服务端隧道 + 面板 API 登录流程)
npm run check # typecheck + smoke:client + e2e
npm run demo # 五进程本地演示
代码地图
| 文件 | 作用 |
|---|
lib/index.js | cordis 插件:注册面板路由、读取 DSH 启动令牌、启动/停止连接;apply() 不抛错、不做内联网络 I/O |
lib/connection.js | WebSocket 会话循环、重连退避、流桥接、背压;连接/握手/停止均有超时上界,回调全部隔离 |
lib/settings.js | 配置存储(原子写、0600、deviceId 生成);I/O 失败降级为内存配置并记录 lastError |
lib/api.js | 面板 API、textwork 登录链路中转、服务器 REST 调用(20s 上游超时,响应写入自带保护;切换服务器时清 token) |
lib/protocol.js | 线协议帧(与 proxy-server 的 protocol.ts 逐字节一致,e2e 校验) |
lib/client.js | 浏览器半 bundle:侧边栏图标 + 「地址 + 连接」主面板 + 账号弹窗(两种登录方式)+ 人机验证浮层;两个 slot 均包错误边界,调用有 30s 上限 |
cordis.patch.yml | bundle 层:插入插件行(id: jaxzhou-proxy-client) |