升级一次就丢一次的两行补丁:dsh 远程设置放行补丁的工具化与开源全记录

时间:2026-08-14 ~ 08-15
背景:dsh 0.1.0-rc.6 把”配置平面”限制在本机 loopback,远程连工作区都设置不了(聊天直接不可用),设置/插件配置要么 403、要么卡片空白
根因:后端 privileged 方法被空信任列表拒绝 + 前端 isLoopback 决定持久化 scope,两层栅栏
方案:两处一行放行补丁 → 固化为幂等脚本 dsh-remote-access-patchstatus/apply/rollback + 夹具测试 + GitHub Actions CI)
仓库:https://github.com/HomoLand/dsh-remote-access-patch


一、背景:把 dsh 变成”对外可用”的服务

事情的起点,是我把家里的 dsh(DeepSeek Harness,一个跑在 Node 上的 AI 代理开发环境)暴露到了公网。

架构上它藏得很深——四层安全叠起来才敢放出去

用户浏览器 ──HTTPS──► Traefik(nodePort 30443)──► Authelia forwardAuth(two_factor/TOTP)
                                                      │ 通过
                                                      ▼
                                        dsh Service ──► Endpoints 10.200.0.1:3080
                                                      │ (socat 收口,仅集群 Pod 可达)
                                                      ▼
                                        dsh web 只绑 127.0.0.1:3080(DSH_HOME=/opt/dsh)
  • 入口dsh.maoyulong.club(EdgeOne CDN 加速,回源家宽 IPv6)和 dsh-v6.maoyulong.club:30443(IPv6 直连逃生通道)两个域名;
  • 认证:Authelia two_factor(TOTP)通过 Traefik forwardAuth 挡住所有远程请求;
  • 收口:dsh 进程永远只绑 127.0.0.1,集群侧经 socat 转发 10.200.0.1:3080 访问;
  • Host 栅栏:dsh 启动参数 --trusted-host dsh.maoyulong.club --trusted-host dsh-v6.maoyulong.club

按这套部署好之后,登录、TOTP 都正常。但远程根本没法用:工作区设置不了,聊天自然起不来;设置页也全是报错。

二、问题现象:远程”基本不可用”

从远程浏览器登录进去,最要命的症状是:

  1. 工作区设置不了 → 聊天不可用host.pickDirectory(选择工作区目录)属于特权方法,远程调用直接 403,工作区根本选不了,聊天会话自然无法开始——这已经不是”设置页不好看”的问题,是核心功能不可用
  2. 常规/模型设置:接口报错,控制台里是
    transport failure for /api/settings.describe: HTTP 403
    
  3. 插件页面:插件列表在,但每个插件的配置卡片根本不渲染,状态显示 status="unavailable"

而同一台机器上走 127.0.0.1 本机访问,一切都好好的。配置平面认”本机”,不认”域名”。

三、挖根因:两层 loopback 栅栏

顺着 dsh 源码翻,发现设计者在这里做了两层防护:

3.1 后端:15 个”特权方法”被空信任列表钉死

dsh-client-connection 里有一批 privileged 方法(settings.describe/update/mutateagentPreset.read/copy/removecredentials.*host.pickDirectoryllm.discoverModels 等,共 15 个),闸门长这样:

if (method !== void 0 && PRIVILEGED_METHODS.has(method)
    && !isTrustedApiRequest(request, []))        // ← 空信任列表
  return new Response("forbidden", { status: 403 });

注意最后那个参数:[]。空数组意味着无论 --trusted-host 配了什么域名,这个闸门永远拒绝——远程访问特权方法必然 403。

3.2 前端:isLoopback 决定持久化到哪

dsh-client-ui-settings 里,settings scope 的持久化后端由 connection.isLoopback 决定:

const controller = new SettingsScopeController(connection.api, spec,
  connection.isLoopback ? "host" : "memory");

浏览器端的 isLoopback 是看 location.hostname 的——远程访问时是域名,不是回环地址,于是落到 "memory"“memory” scope 的状态是 unavailable,配置卡片干脆不渲染。

3.3 为什么这么设计

dsh 源码注释说得明明白白:

trustedHosts 是 DNS-rebinding 栅栏,不是认证层;配置平面在”真正的认证层”出现之前,保持 loopback-only。

换句话说,官方宁可让远程设置不可用,也不愿意在没有认证的前提下开放配置平面——这本身是合理的安全设计。但我的部署里,dsh 前面已经有一层真正的认证(Authelia two_factor)了,所以这个 loopback-only 的限制可以安全地放行。

四、手工补丁:两处一行改动

安全前提成立,那就动手。两处改动各一行:

补丁 1(后端)dsh-client-connection/lib/index.js

- if (method !== void 0 && PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, [])) return new Response("forbidden", { status: 403 });
+ if (method !== void 0 && PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, trustedHosts)) return new Response("forbidden", { status: 403 });

补丁 2(前端)dsh-client-ui-settings/lib/client.js

- const controller = new SettingsScopeController(connection.api, spec, connection.isLoopback ? "host" : "memory");
+ const controller = new SettingsScopeController(connection.api, spec, "host");

配套动作:两份原文件先 cp -a 备份成 .orig-bak → 改完 systemctl restart dsh.service → 浏览器 Ctrl+Shift+R 强刷加载新 bundle。然后远程就全通了:工作区能选了、聊天恢复正常,常规/模型/插件配置全部可读可写。

到这里其实已经能用了。但真正的坑在后面。

五、痛点:升级一次,补丁丢一次

dsh 是 npm 全局包。维护文档里我给自己留了一条醒目的备注:

⚠️ npm install -g @deepseek-ai/dsh 重装会覆盖这两个文件,补丁失效需重打。

这是我最不喜欢的运维动作类型:手工改第三方包源码 + 每次升级都要重来。而且重打不是无脑重复——行号会漂移、升级可能改逻辑、忘备份就完蛋。两行 diff 看着简单,实际执行中每一步都可能出错:

  • 忘了先备份就动手;
  • 改错行(同文件里还有一处 isTrustedApiRequest(request, []),在 Typert interceptor 里,不能动);
  • 改完忘了重启,或者没强刷浏览器,以为没生效;
  • 最要命的:没有任何校验,改没改对全凭肉眼。

于是 8 月 14 号补丁生效当晚我就决定:把它做成脚本,一次写好,永久复用。

六、工具化:把”改文件”变成”跑脚本”

dsh-remote-access-patch 就这么诞生了。设计上我只定了四条铁律:

6.1 幂等,重复跑不坏事

apply 之前先检查目标状态,已经打过就输出 [SKIP] 直接跳过,绝不二次修改。脚本可以放心反复执行——这对”升级后重打”场景是刚需。

6.2 按锚串定位,不依赖行号

两处替换都用唯一锚串(而不是行号),升级后行号必然漂移,锚串不会:

补丁1锚串:PRIVILEGED_METHODS.has(method) && !isTrustedApiRequest(request, [])
补丁2锚串:new SettingsScopeController(connection.api, spec, connection.isLoopback ? "host" : "memory")

锚串 1 特意带上了 PRIVILEGED_METHODS.has(method) 前缀——同文件第 237 行还有一处裸的 isTrustedApiRequest(request, [])(Typert interceptor),不加前缀就会误伤。锚串匹配到多次时脚本直接拒绝执行,宁可报错也不瞎改。

6.3 先备份,可回滚

apply 前自动 cp -a.orig-bakrollback 一键恢复。升级后重打时 .orig-bak 会被刷新成当前版本的原始文件,保证回滚永远回到”这个版本没打补丁”的状态,而不是一个过期版本。

6.4 可验证:夹具测试 + CI

这是我最看重的部分。仓库里带了一份最小夹具test/fixture/),专门构造了”第 237 行 lookalike + 第 538 行目标行”的场景,test/run.sh 跑 7 项断言:

PASS: 初始:后端含旧锚串
PASS: apply:后端目标行已替换
PASS: apply:lookalike 行未被误改   ← 专门验证不误伤
PASS: apply:前端已替换
PASS: 幂等:重复 apply 输出 SKIP
PASS: rollback:后端恢复一致
PASS: rollback:前端恢复一致

配套 Makefilemake check / test / lint)和 GitHub Actions CI——每次 push 自动跑 bash -n + shellcheck + 功能自测。“改对了没”从此不用靠肉眼,跑一遍测试就知道。

 


本文基于 homelab 实际部署记录整理。部分内容由ai总结。

 

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇