Ckarefulon/dsh-tailscale-remote ↗★ 1

dsh-tailscale-remote

通过Tailscale将Web界面发布至专属虚拟网 适合需要从手机等远程设备安全连接并控制DSH Web界面的用户。

包名
dsh-tailscale-remote
兼容性
待验证
版本
0.5.3
许可证
MIT
最近更新
2026年10月20日

安装

$npx -p @deepseek-ai/dsh dsh plugin --profile web add github:Ckarefulon/dsh-tailscale-remote

配置

字段默认说明
enabledtrue关掉则插件完全不动作。
httpsPort7259tailnet 上的 HTTPS 端口。
connectOnStarttruetailscale 没在跑时是否把它拉起来。
serveOnStarttrue是否建立 Serve 路由。
teardownOnExittrue退出时是否回滚。
trustedHoststrue是否把该 authority 加进 /api 信任列表。
loginRoutetrue是否注册 /remote/ 这个稳定登录入口(设置页那一行的「更换密钥」也依赖它)。
revokeSessionsOnRotatetrue换密钥时是否同时吊销所有已登录的会话。默认开:密钥泄露时对方可能已用旧密钥换到一张 10 年 cookie,只换登录密钥拦不住他。关掉则只换链接、所有设备继续登录。
cookieMaxAgeDays3650登录 cookie 有效期(天)。0 = 不插手 DSH 的 30 天默认值。
binaryPath自动探测tailscale CLI 可执行文件路径。
guiPath自动探测Tailscale 桌面客户端路径(Windows 默认 tailscale-ipn.exe)。
readyTimeoutMs8000等 tailscale up 生效的预算。
guiReadyTimeoutMs25000启动桌面客户端后等 backend 就绪的预算。
stateFile$DSH_HOME/tailscale-remote.json状态文件位置。
litetrue精简模式默认值:手机那个页面只带基础插件(首屏插件从约 5.3 MB gzip 降到约 1.1 MB)。桌面端不受影响。侧边栏底部的图标点一下可以随时改,改了就以那个持久选择为准。
liteSeeds内置白名单精简模式保留哪些插件(闭包种子)。只在 DSH 升级改包名、你想自己补几个包时才需要。
liteHide内置列表精简页面上要藏掉的界面锚点(CSS 选择器)。默认藏掉右侧栏与轨迹页签。设置入口永远不藏(手机上进设置页只有那一个地方)。
websocketCompressiontrue给 DSH 那条 WebSocket(/api/remote.mux,会话历史走它)开 permessage-deflate。惰性挂钩:启动期零开销,第一次握手时才 patch。

精简模式(手机流量省 79%)

DSH 的 Web 首屏会把插件图里每一个客户端包 preload 下来,本机实测约 5.3 MB(gzip)。 其中大半手机根本用不到:设置里的账号插画单个包就 2.4 MB,还有插件管理器、模型设置、 各种第三方面板。手机走 Tailscale 时带宽窄,这些字节是纯粹的负担。

插件的做法是不动 DSH 一行代码,只在渲染出来的 index.html 上做手术:

  1. 用一份白名单在活的插件图上求闭包,得到精简图(实测 30/86 个包);
  2. 把 globalThis["__DSH_BOOT__"] 换成精简图 —— 客户端于是只认这些包;
  3. 把整图那些首屏 preload 换成精简版,由插件自己的路由服务,并用 brotli 压 (比 webserver 默认的 gzip level 1 再省约 32%);
  4. 在 head 里补一段 ``,把右侧栏、轨迹页签藏掉,顺手把 「工作区」标题行里搜索按钮前面那段硬编码空白归零。

桌面端一个字节都不会变。 Electron 不走这条路:它直接读磁盘上的 index.html, 再经 IPC 把注入表交给客户端;而 tapIndex 只参与 webServer.renderIndex(), 也就是 HTTP 那条回退路径。所以「不动桌面 DSH」是结构保证,不是小心翼翼。 另外客户端半边在桌面窗口里一个插槽都不注册(判据是 preload 注入的 dshDesktopBoot,只有桌面窗口才有),所以桌面上的 DSH 与没装本插件时完全一致。

实测(真浏览器 + 真宿主):精简页面的插件请求只有两个 —— 一个 bootstrap 小包加一个 合并请求,首屏不再有那条约 5 MB 的 /plugins/??...;页面照常启动、连接正常、 工作区与会话列表、输入框全部可用,控制台零报错。

怎么切换:精简页面上,侧边栏底部「设置」图标旁边多了一个图标按钮 (16×16 的双向箭头,与 DSH 自己的图标同规格)。点一下在「精简 / 完整」之间切换:

  • 关掉后刷新手机上的页面,就会发完整版(所有插件)。
  • 站点本身立刻记录这个选择,但页面是在下一次渲染 index 时变的 —— 所以按钮的 title / aria-label 会写清「刷新后生效」,否则看起来像按钮坏了。
  • 完整模式页面里不显示这个图标(那时完整模式页面跟手机上没区别,用不着它)。
  • 桌面窗口里根本没有这个图标(连插槽都不注册)。
  • 设置图标本身始终保留:sidebar.settings 是 single 插槽、由 ui-settings 注册在 侧边栏底部,是手机上进设置页的唯一入口,藏了就没法改模型/账号/插件了。

为什么不能简单地「把不要的包从图里删掉就算完」:exports.inject 是服务级 门禁。像 ui-sidebar-right 提供的 sidebarRight 被 ui-chat 硬依赖,删了它 ui-chat 的 fiber 会停在 pending,而 shell 的 boot 审计把 pending 算失败, 整页白屏。所以这类包留着、只藏界面;真正能删的是那些没人依赖其服务的。

安全阀:如果白名单在活图上失配(DSH 升级拆包/改名),插件原样发完整页面并记一条日志 —— 宁可多发几 MB,也绝不发一个进不去设置页的残缺页面给手机(那里用户没有任何自救手段)。

手机中途连接为什么快了很多

「刷新页面」只是第一段。手机真正难熬的是中途操作:载入历史会话、发命令、收流式回复。 这两件事走的是完全不同的两条路,插件各修了一处:

① 拉会话历史 / 收事件 = 一条 WebSocket,而 DSH 默认完全不压它。 会话历史不走 HTTP,走 /api/remote.mux;DSH 用 new WebSocketServer({ noServer: true }) 建它,而 ws 的 perMessageDeflate 默认是 false。实测握手响应里没有 sec-websocket-extensions —— 那些高度重复的 JSON 事件是明文过的(一个界面 12 KB 的会话, 背后收进了 22.5 MB 下行文本)。

插件在 ws.WebSocketServer.prototype.handleUpgrade 上补一层:把 this.options.perMessageDeflate 打开。位置很关键 —— ws 在 handleUpgrade 内部同步用 this.options 构造 socket, 所以在这里改一定生效,没有竞态。打的又是 DSH 自己那一个 ws 模块实例(从 process.resourcesPath 下的 app.asar 解析),所以对已经建好的 mux 也生效,不用重启、不碰 DSH 一行代码。

② 启动开销为零。 require('ws') 要从 asar 里读并编译,实测 159 ms —— 花在启动路径上 就是「插件拖慢了启动」。所以这个 patch 是惰性的:启动期只挂一个一次性钩子 (server.prependListener('upgrade'),只观察不消费事件),等第一次 WS 握手路过时再 patch。 那 159 ms 于是落在手机那条连接上,而不是你的启动路径上(实测装配耗时 0.04 ms)。

③ 首屏合并请求自己压。 webserver 的 gzip 是 level 1(约 1.30 MB),同一份字节用 brotli q5 约 0.89 MB、gzip level 6 约 1.06 MB。插件自己压并缓存结果(只算一次), 按 Accept-Encoding 协商 br → gzip → 原文,并带 Vary: Accept-Encoding。

关于「永久不过期」

DSH 把 cookie 有效期上限写死在 connection 的 cookieMaxAgeDays 里,并且校验时要求 expiresAt - issuedAt <= maxAgeMilliseconds —— 所以自己造一个超长 cookie 是无效的, 必须改这个上限。本插件启动时会直接改 connection.browserAuth.maxAgeMilliseconds, 默认给 10 年(3650 天),够用且不会溢出 JS 安全整数范围。

想更长就把它调大(比如 36500 = 100 年);0 表示完全不动 DSH 的默认 30 天。