# dsh-proxy DSH 插件:把 DeepSeek Harness Web 界面(默认 `127.0.0.1:3080`)转发到**另一个带认证的端口**,让同一局域网内的其他设备也能安全访问。 - **HTTP + WebSocket 全协议转发**:任务状态、日志等实时推送不失效 - **原生 Basic Auth(默认关闭)**:与独立版 dsh-proxy 一致,使用**浏览器自带的认证弹窗**,没有自绘登录页、没有会话 Cookie——默认用户名与密码均为空,局域网**开放访问**;在设置页**同时设置**用户名和密码后启用,外部访问时浏览器弹出原生 Basic Auth 登录框,成功后浏览器缓存凭据自动登录(含 WebSocket 握手) - **设置页面**:DSH 设置 → 「局域网代理」,显示监听端口/默认服务端口/**密码登录是否启用**/**免 token 登录是否启用**,可修改代理监听端口、用户名、密码、免 token 登录开关,可**启动/停止**代理,保存后自动重启转发服务(持久化到 `$DSH_HOME/dsh-proxy.json`) - **可选的免 token 首次访问(默认关闭)**:DSH 0.2.x 用每进程随机的启动 token 把整个 Web 界面锁起来,而局域网设备根本看不到这个 token,第一次打开只会得到 `401 dsh web authentication required`。打开该开关后,由代理代替浏览器完成 token→cookie 兑换,之后每次访问都不再需要 token。默认关闭,因为它会让代理端口成为唯一闸门(见「免 token 首次访问」)。 - **开箱即通的兼容修复**:`Host`/`Origin` 改写(通过 DSH `/api` 同源信任篱笆,LAN 访问不 403)、`crypto.randomUUID` polyfill 注入(LAN 非安全上下文下前端 RPC 可用) - **随 `dsh web` 启停**:无需单独进程,配置改在 profile 的 `cordis.patch.yml` > 为什么需要它:DSH Web 服务端**故意拒绝** `--host 0.0.0.0`(避免把 RCE 直接暴露到网络),本插件是官方认可的"出网"方式——独立监听端口 + 认证 + 反代回 loopback。 > > 独立版(Go/Node 单文件,不依赖 DSH)见 ;本插件是它的集成版,认证方式与独立版一致(原生 Basic Auth)。 ## 安装 **在线安装(推荐)**——直接从 GitHub 安装(无需下载仓库): ```bash dsh plugin --profile web add github:smanx/dsh-proxy#master ``` **本地安装**(仓库已克隆/下载到本机): ```bash dsh plugin --profile web add file:C:/mydata/codes/dsh-proxy ``` 安装后包名为 **`@smanx/dsh-proxy`**(scoped,带用户名前缀),`dsh plugin ls` 中显示为 `@smanx/dsh-proxy@0.1.1`。 安装后**重启 `dsh web`**(Ctrl+C 后重新运行)即生效。若 3081 被占用(例如独立版 dsh-proxy 还在运行),先停掉它,或在配置里换一个端口。 ## 配置 所有配置项都有 schema 默认值;在 **profile 的 `cordis.patch.yml`** 里按行 id 覆盖(patch 会整体替换该行 config,因此要写全你要改的键): ```yaml # C:\Users\cc\.dsh\profiles\web\cordis.patch.yml - id: dsh-proxy config: listenHost: '0.0.0.0' # 监听网卡;0.0.0.0 = 局域网可访问 listenPort: 3081 # 代理对外端口(默认 3081) upstreamHost: '127.0.0.1' # 上游 DSH 地址 upstreamPort: 0 # 0 = 跟随 web app 实际绑定的端口(默认) username: '' # 登录用户名(默认空 = 密码登录关闭) password: '' # 登录密码(默认空 = 密码登录关闭) skipLaunchToken: false # 免 token 首次访问(默认关闭,见下文) ``` - 默认 `username` 与 `password` 均为空 = **密码登录关闭**(局域网开放访问,谨慎)。**只有同时设置两者**才会启用密码登录(只设置其一仍保持关闭,设置页会提示)。 - `skipLaunchToken`(默认 **false**):详见下文「免 token 首次访问」。 ## 设置页面(改端口 / 账号密码 / 启停) 重启 `dsh web` 后,打开 DSH 设置(左下角齿轮)→ 「局域网代理」: - **运行状态**:两个红绿灯指示 —— **代理服务端口**(监听地址:端口,绿灯=代理实际在监听,红灯=端口被占用等绑定失败)与**默认服务端口**(DSH 服务端口,绿灯=可探测到默认服务,红灯=不可达);另有当前用户名、**密码登录是否启用**、**免 token 登录是否启用**(「未启用」的说明是「首次访问仍需手动带 token 访问一次」);右上角有 **启动 / 停止** 按钮(运行中时「启动」置灰,未运行时「停止」置灰)。 - **修改设置**:可改 **代理服务端口(监听端口)**、**用户名**、**密码**(留空 = 设为空)、以及**免 token 登录**复选框(其提示写明了安全代价)。点「应用」后,配置写入 `$DSH_HOME/dsh-proxy.json` 并**立即重启转发服务**;改监听端口后局域网访问地址随之变化。 设置页的修改会**持久化**并优先于 profile 的 `cordis.patch.yml`;`listenHost` / `listenPort` 等仍只能通过 `cordis.patch.yml` 修改。插件通过宿主 RPC 通道 `/dsh-proxy`(`status` / `update` / `start` / `stop`)提供该能力,通道仅限 loopback 权威(经代理改写后 LAN 端同样可访问)。 ## 使用 1. 重启 `dsh web`,启动日志会打印访问地址: ``` dsh-proxy: listening on 0.0.0.0:3081 -> http://127.0.0.1:3080 dsh-proxy: 局域网访问 http://192.168.1.100:3081 ``` 2. 浏览器访问 `http://<本机局域网IP>:3081`。 - **启用了密码登录** → **浏览器弹出原生 Basic Auth 登录框** → 输入用户名密码 → 进入 DSH 界面。浏览器会记住凭据,下次直接进入。 - **开启了免 token 登录** → 无需粘贴 token:首次访问由代理自动完成兑换,浏览器拿到签名 cookie 后直接进入 DSH 界面;之后每次访问都不再需要 token。 3. 局域网内其他设备用同一地址访问;登录成功后浏览器会记住凭据,下次直接进入。 > Windows 防火墙:若局域网设备连不上,为本机放行该端口(管理员 PowerShell): > `netsh advfirewall firewall add rule name="dsh-proxy" dir=in action=allow protocol=TCP localport=3081` ## 开发 ```bash pnpm install pnpm run check # typecheck + test + build pnpm run smoke # 对正在运行的 DSH(127.0.0.1:3080)做全流程冒烟测试 ``` - `src/` — TypeScript 源码;`src/proxy.ts` 是纯 node 代理核心(无 cordis 依赖,可独立测试),`src/controller.ts` 是代理控制器(启动/停止/重启/状态/更新,负责配置持久化),`src/index.ts` 是 cordis 插件入口(含 `/dsh-proxy` RPC 通道),`src/client/` 是浏览器设置页。 - `lib/` — 提交构建产物(`lib/index.cjs` 为完全自包含单文件:schemastery、http-proxy、dsh-home-paths 均已内联;`lib/client.js` 为浏览器端单文件,仅 external react)。 - `tests/` — vitest:认证原语、polyfill 纯函数、代理集成测试(HTTP + WebSocket)、控制器(真实上游 + 临时配置文件)、设置页 jsdom 渲染。 - 宿主编排测试(跑在真实 DSH 进程里的插件生命周期)见 `scripts/smoke.mjs` 思路 + 手动重启验证。 ## 认证细节 - **启用条件**:`username` 与 `password` **同时非空**才启用密码登录(默认均为空 = 开放访问);只设置其一仍保持关闭。 - **认证方式**:HTTP Basic Auth,浏览器原生弹窗——未认证的**任何**请求(页面、`/api/*`、脚本)→ **401 + `WWW-Authenticate: Basic realm="dsh-proxy"`**;WebSocket 拒绝同样带挑战头。登录成功后浏览器按站点缓存凭据,后续请求(含 WS 握手)自动携带。**没有自绘登录页、没有会话 Cookie。** - **公开静态文件**:`/manifest.webmanifest` 与 `/favicon.svg` 免认证——浏览器会在不带凭据的上下文(PWA 清单、图标)拉取它们,强制认证会 401。 - 凭据比较用常量时间(`timingSafeEqual`)。 ## 免 token 首次访问(`skipLaunchToken`) **解决什么问题。** DSH 0.2.x 用**每进程随机**的启动 token 把整个 Web 界面锁起来——`dsh web` 启动时会打印一个形如 `http://172.28.216.108:3080/?token=HO3B2CzY...` 的地址,不带 token 的访问只会得到: ``` 401 dsh web authentication required; reopen the URL printed by dsh web. ``` 只有 `GET /?token=...` 能把这个 token 兑换成**签名后的 30 天 cookie**(`dsh-auth-`)。局域网设备看不到这个 token,所以每台新设备第一次打开都卡在那个 401 上,必须有人到服务器启动日志里把 token 抄出来贴进地址栏——每台设备一次,`dsh web` 每次重启后重来。 开启 `skipLaunchToken: true` 后,由代理**代替浏览器完成它本来要手动做的那一次兑换**,且发生在客户端请求被转发之前: 1. 代理用客户端自己的路径与请求头向上游**探测一次**(只有 `GET /` 会走到这一步); 2. 若上游返回 **401**——说明这个客户端当前没有 DSH 认账的会话——就给客户端的 URL 追加 `?token=...`;URL 里已经带 `token` 参数的请求原样转发(DSH 会拒绝出现两次的 token); 3. DSH 随即签发 cookie 并 **303 重定向到 `./`**——浏览器落在应用界面上,并为之后的每个请求保留这个 cookie; 4. 其他任何状态码都说明请求本身没问题,按原样转发。 **是否升级只由上游的应答决定,绝不由请求头猜。** DSH 的会话 cookie 名是 `dsh-auth-`——换一个上游端口就换一个名字——而它的值会在 `cookieMaxAgeDays`(默认 30 天)后过期,或在签名记录变化后不再通过校验。曾有一版实现把「带了任何 `dsh-auth-*` cookie」当成「已登录」而跳过探测,于是浏览器只要留着这么一个陈旧或异名的 cookie,就永远被判为已认证、永远不再兑换,每次刷新都吃一次 401——开关看着是开的,设备却死活进不去。**现在索引请求总会先探测,401 就补 token,DSH 用新 cookie 覆盖旧的那个,设备自行恢复。** **只在 `GET /` 上动作。** DSH 只在这条路径接受 token(其 `authorizeIndex` 要求 `pathname === "/"` 且 token 恰好出现一次);换到别的路径,token 既换不到 cookie,也修不好任何东西,只会把启动 token 泄漏进 URL、日志和上游访问记录。所以 `/api/*`、静态资源、非 GET 请求一律原样转发。开启后每个索引请求多出的开销,就是一次无 body 的上游 GET。 **关闭时零开销。** 开关关闭(默认)时,热路径上每个请求只多一次布尔属性读取——探测完全不跑,请求与改动前逐字节一致。 **token 从不落盘。** 它从宿主的 Connection 服务(`ctx.connection.authenticatedUrl()`)读取,也就是 `dsh web` 启动时打印的那个值,读取时机在**转发服务启动时**,而非插件加载时(`BrowserAuth` 要等 harness 开始服务后才存在)。持久化的只有**开关标志**(`$DSH_HOME/dsh-proxy.json`),token 每次启动重读,因此能自动跟随重启。若宿主给不出 token,功能保持静默、记一条警告日志,代理行为与之前完全一致。 **怎么开启。** 在 `cordis.patch.yml` 里改(见上),或在设置页勾选**免 token 登录**复选框。设置页会写入开关标志、重启转发服务,**无需重启 `dsh web`** 即可生效。关闭开关会**立即清除**已缓存的 token。 **覆盖不到的地方。** 该能力只作用于普通 HTTP 请求路径,**不会处理 WebSocket 升级**:`GET /?token=...` 是浏览器导航,原始 WS 握手需要的 version 2 API cookie 只能由 `/connect?token=...` 签发。开启后页面能打开、浏览器也持有 cookie,但一个**完全不带 cookie** 的 WS 客户端仍然会拿到 401——与这个功能之前的行为一致。 **安全代价。** 开启后,**能连到代理端口的设备都不再需要启动 token 即可被 DSH 放行**。请把它视作「该端口没有 DSH 侧认证」,只剩下 Basic Auth 一道闸门。这正是它默认**关闭**的原因,也是为什么要搭配 `username` + `password` 使用。设置页在复选框旁写明了这一点。 ## 安全提示 - 默认**开放访问**(用户名密码为空)——启用密码登录前,局域网内任何人都可访问。请尽快在设置页**同时设置用户名和密码**。 - 该代理通过后,DSH 的 `/api` 信任篱笆看到的是改写后的 loopback `Host`,因此**设置/凭据等特权 RPC 也会对局域网开放**——密码登录(Basic Auth)是唯一闸门。 - **`skipLaunchToken` 会移除 DSH 自身的 token 校验**:能连到代理端口的设备无需启动 token 即可被放行。请把它视作「该端口没有 DSH 侧认证」,并务必同时配置 Basic Auth 凭据。 - 浏览器缓存的 Basic 凭据在**修改密码后不会自动失效**:旧凭据会 401 并重新弹窗,输入新密码即可;如需彻底清除,可在浏览器"已保存的密码"里删除该站点条目。