Files
dsh-lan-access/README.md
T
sutong 42859309c3 fix: 复制按钮在局域网页面下静默失败
明文 HTTP 的局域网页面不是安全上下文,navigator.clipboard 不存在,
旧代码落到 else 分支直接报告成功却什么都没复制;而且 .then(done, done)
连 Promise 被拒(权限拒绝)也当成功——两种情况都会谎报「已复制」。

现在:
- 只在真的写入剪贴板后才报成功;clipboard API 被拒也继续回退
- 回退到 document.execCommand('copy')(明文 HTTP 下仍然可用)
- 两者都不可用时选中链接并提示手动 Ctrl/⌘+C,不再谎报
- URL 改为可选中的只读输入框(原先是没有省略号就无法看全的 div)

新增 7 项回归测试覆盖:clipboard 缺失时确实走 execCommand、可用时走
API 且不重复复制、被拒时回退、两者都无时不抛异常。旧代码在该测试下失败。
README 中英双语排障表补充该现象与 HTTPS 说明。
2026-09-28 23:17:12 +08:00

147 lines
9.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# @sutong/dsh-lan-access
局域网访问 dsh Web UI:一个**独立反向代理**,在 `0.0.0.0:<port>` 监听,把 HTTP 与 WebSocket 转发到回环 web 服务器,并**保留官方 launch token 登录**。
**简体中文** | [English](README.en.md)
## 为什么需要插件
`dsh web --host 0.0.0.0` 被官方 CLI 主动拒绝(`@deepseek-ai/dsh-web-app/lib/startup.js`:会把这台机器的远程代码执行能力暴露到网络)。所以官方 web 服务器始终只绑回环,局域网访问必须由插件在**另一个端口**上另开监听并转发。
## 与 dsh-lan-proxy 的区别
社区插件 `@wingsky-1/dsh-lan-proxy` 也做转发,但设计取向不同:
| | 本插件 | dsh-lan-proxy |
|---|---|---|
| 默认登录 | **必须持有 launch token**(横幅/二维码里的链接) | `injectToken: true`,局域网内任何设备免 token 直入 |
| 上游 `Host` | 重写为回环(官方围栏通过) | 重写为回环 |
| `Set-Cookie` | 不改动(浏览器按**请求 URL** 建立 host-only cookie,转发侧无需干预) | 需配合 token 注入与 401 重放 |
| WebSocket | 握手按字节转发,`permessage-deflate` 端到端协商,**代理不解压任何帧** | 压缩桥接(存在解压放大面) |
| 官方行覆盖 | **不覆盖任何 shipped row**,失败/禁用不会影响回环 UI | 插入自身行 + 兼容注入 |
> **端口冲突**:dsh-lan-proxy 默认占用 `3081`,因此本插件默认用 `3082`。两者可共存,但同时启用等于把 UI 暴露两次;建议只留一个。
## 安装
```sh
dsh plugin --profile web add /path/to/dsh-lan-access
```
安装后重启一次 `dsh web`:
```sh
# 重启后终端会打印带 token 的局域网链接
```
或在本仓库内用本地目录安装(Profile → Plugins → 本地安装)。
## 配置
配置写在插件行的 `config` 里(Host Profile → Plugins → 本插件 → Configure,或直接改 `cordis.patch.yml`)。
| 键 | 默认 | 说明 |
|---|---|---|
| `enabled` | `true` | 总开关;关闭后不再监听 |
| `host` | `0.0.0.0` | 监听地址 |
| `port` | `3082` | 监听端口(避开 dsh-lan-proxy 的 3081) |
| `targetHost` | `127.0.0.1` | 上游主机。**仅接受回环地址**,否则插件拒绝启动(防止变成开放转发/SSRF) |
| `targetPort` | `0` | 上游端口;`0` = 跟随 `webServer` 实际端口 |
| `printBanner` | `true` | 启动时打印局域网链接 |
## 使用
1. **终端横幅**(启动时):
```
dsh-lan-access: 0.0.0.0:3082 → http://127.0.0.1:3080 (LAN listener)
dsh-lan-access: http://192.168.1.5:3082/?token=XXXXXXXX
```
2. **Web UI 面板**:Settings → **局域网访问**(插件自带的设置页)。列出全部局域网地址、可复制、可直接打开,并为你选中的地址渲染**二维码**,手机扫一下即可进入。
链接里的 `token` 只在首次访问用到:服务器校验后会铸一个 30 天有效的 cookie 并 302 跳到干净的 `./`,**token 不会留在地址栏或历史记录里**。之后该设备靠 cookie 直接进入。
## 安全模型
- **认证不放松**:不注入 token、不绕过围栏。局域网设备必须拿到横幅或二维码里的链接。这比"信任整个局域网"更保守。
- **官方围栏照旧生效**:`/api` 只接受回环或已声明的 authority,且 `Origin` 必须与 `Host` 同源;本插件把上游 `Host`/`Origin` 重写为回环,因此这些检查全部通过而不是被绕过。
- **DNS 重绑定防护**:监听器只接受 `localhost`、回环地址、本插件公布的局域网 IP 字面量,或它自己的监听地址;域名一律断开。
- **上游锁定回环**:`targetHost` 非回环直接拒绝启动,避免开放代理。
- **无额外解压面**:中间不做响应/帧压缩,也不解压 WebSocket 帧。
- **面板数据路由**:`/api/dsh-lan-access/summary` 挂在官方 web 服务器上,并**复用官方 `connection.admit()`**(同一套围栏 + 浏览器认证)——只有已登录页面能读到 token。
- **⚠️ 风险面**:拿到该链接的设备拥有**完整 dsh 控制权,包括在宿主机执行命令**。请只在可信局域网启用;不使用时卸载或关闭插件。列表里的每一条链接都是一把钥匙。
## 局域网页面上的设置界面(ownsHostCompat)
DSH 官方把「持久化设置」限制在回环页面:客户端由 `location.hostname` 推导 `isLoopback`
(`dsh-client-connection/lib/client.js`),非回环页面得到 `persistence = "memory"`
(`dsh-client-ui-settings/lib/client.js`),于是设置镜像永远没有 `view`,模型/提供商页会报
**settings are unavailable in this browser**,General 页的「打开配置文件」也会消失。
**这与转发是否正确无关**:任何局域网暴露方式都会遇到,用 `127.0.0.1:3080` 打开则一切正常。
服务端也**没有**对应的回环门禁(`/api` 围栏 + token/cookie 才是授权面),所以本插件提供一个开关:
| 键 | 默认 | 说明 |
|---|---|---|
| `ownsHostCompat` | `false` | 为**非回环页面**声明 `ownsHost`,恢复官方设置界面(回环页面不受影响) |
本 profile 已设为 `true`。取舍:该页面因此可读写服务器设置(含凭据)。考虑到持有带 token
链接的设备本就拥有完整控制权(含在宿主机执行命令),这不算新增一类能力,但确实绕过了官方的
回环隔离 —— 设为 `false` 即回到官方姿态,设置请在本机通过 `127.0.0.1:3080`(或
`ssh -L 3080:127.0.0.1:3080 <主机>` 隧道)修改。
## 添加工作区的目录选择器
`directory-picker-auto` 在启动时按**服务器侧**事实选一个后端:`native` 要求「仅绑回环 + 非 SSH +
有可用显示器」,理由是「全网卡绑定意味着存在操作系统对话框够不到的远程浏览器」。本插件刻意让官方
服务器保持回环(LAN 由 3082 独立转发),于是局域网部署仍被判为 `native` —— 对话框会在**服务器
本机**弹出,远程操作者既看不到也点不到。
因此本插件的 patch 按官方文档的方式锁定 `browse` 这一对(`directory-picker-auto` 的文档写明
「pinning an interaction remains composing that pair directly instead of this row」):
- `@deepseek-ai/dsh-host-directory-picker-browse`(后端)
- `@deepseek-ai/dsh-client-ui-directory-picker-browse`(界面)
该后端在页面内列出服务器目录树,官方描述为「serves remote clients the dialog backend cannot」。
**取舍**:这个 seam 每个进程只能挂一个后端,所以**本地页面也会改用页面内浏览**,不再弹操作系统
对话框(也就是说"本地原生、远程网页"无法并存)。想恢复自适应选择,删掉 `cordis.patch.yml` 里
最后那三行即可(`- id: directory-picker / disabled: true` 与其后的 `insert` 块)。
## 排障
| 现象 | 原因与处理 |
|---|---|
| 终端出现 `LAN listener unavailable ... EADDRINUSE` | 端口被占用(常见:dsh-lan-proxy 占着 3081)。改 `port`,或停用另一个插件。面板会同时显示该错误,不会展示不可用的二维码。 |
| 局域网设备打开后 401 | 用的是不带 token 的裸地址,且该设备还没有 cookie。改用横幅/面板里带 `?token=` 的链接。 |
| 重启后旧书签失效 | launch token 每次启动都会变。已登录过的设备靠 cookie 仍然有效;否则重新用新链接进入。 |
| 面板显示"未找到非内部 IPv4 地址" | 这台机器当前没有可用的局域网 IPv4(只有回环)。 |
| 扫码无反应 | 部分机型对深色背景上的二维码识别差;面板里的二维码固定渲染在白底卡片上。适当调高屏幕亮度。 |
| 点「复制」没反应 / 剪贴板里没内容 | 明文 HTTP 的局域网页面**不是安全上下文**,`navigator.clipboard` 在该页面不存在。插件会退回 `document.execCommand('copy')`;若浏览器两条路都禁止,就**选中链接并提示手动 Ctrl/⌘+C**,不再谎报「已复制」。想用正规剪贴板 API 需 HTTPS。 |
## 开发
```sh
node scripts/build-client.mjs # 由 src/panel.js + src/qrcode.js 生成 client.js
node scripts/smoke.mjs # 宿主端转发契约(HTTP/WS/Host/Origin/cookie/绑定失败)
node scripts/test-qr.mjs # 与 npm qrcode 参考实现逐模块比对
# test-qr.mjs 需要一个“参考实现”作为对照(仅测试用,不是运行时依赖):
# mkdir -p /tmp/qr-oracle && cd /tmp/qr-oracle && npm init -y && npm i qrcode@1.5.4
# 放在别处就用 QR_ORACLE_DIR=<dir> node scripts/test-qr.mjs
node scripts/test-client.mjs # 浏览器端契约(槽位注册、渲染、路由一致)
```
`client.js` 是生成产物,请改 `src/` 后重新构建。
二维码编码器(`src/qrcode.js`)是零依赖自实现(byte 模式 / ECC M / version 1–10),与 npm `qrcode` 参考实现做了全版本 × 全掩码的逐模块比对(240/240 一致)。
## 回退
在 Plugins 页停用或卸载本插件即可;因为本插件**不覆盖任何官方行**,停用只会让局域网监听消失,回环 `dsh web` 不受影响。
## 许可
MIT