时间:2026-08-14 ~ 08-15
背景:dsh0.1.0-rc.6把”配置平面”限制在本机 loopback,远程连工作区都设置不了(聊天直接不可用),设置/插件配置要么 403、要么卡片空白
根因:后端 privileged 方法被空信任列表拒绝 + 前端isLoopback决定持久化 scope,两层栅栏
方案:两处一行放行补丁 → 固化为幂等脚本 dsh-remote-access-patch(status/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 都正常。但远程根本没法用:工作区设置不了,聊天自然起不来;设置页也全是报错。
二、问题现象:远程”基本不可用”
从远程浏览器登录进去,最要命的症状是:
- 工作区设置不了 → 聊天不可用:
host.pickDirectory(选择工作区目录)属于特权方法,远程调用直接 403,工作区根本选不了,聊天会话自然无法开始——这已经不是”设置页不好看”的问题,是核心功能不可用; - 常规/模型设置:接口报错,控制台里是
transport failure for /api/settings.describe: HTTP 403 - 插件页面:插件列表在,但每个插件的配置卡片根本不渲染,状态显示
status="unavailable"。
而同一台机器上走 127.0.0.1 本机访问,一切都好好的。配置平面认”本机”,不认”域名”。
三、挖根因:两层 loopback 栅栏
顺着 dsh 源码翻,发现设计者在这里做了两层防护:
3.1 后端:15 个”特权方法”被空信任列表钉死
dsh-client-connection 里有一批 privileged 方法(settings.describe/update/mutate、agentPreset.read/copy/remove、credentials.*、host.pickDirectory、llm.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-bak,rollback 一键恢复。升级后重打时 .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:前端恢复一致
配套 Makefile(make check / test / lint)和 GitHub Actions CI——每次 push 自动跑 bash -n + shellcheck + 功能自测。“改对了没”从此不用靠肉眼,跑一遍测试就知道。
本文基于 homelab 实际部署记录整理。部分内容由ai总结。
