DeepSeek Harness(dsh)是 DeepSeek 开源的 Agent 框架("一切皆插件")。它的 Web UI 默认只监听本机127.0.0.1:<30**>,而且没有任何内置鉴权——远程访问必须自己搭网关。
这篇文章记录我把dsh通过 FRP 内网穿透 + Laravel 管理员网关 + Nginx 反向代理,做成"手机浏览器随时访问家里电脑上的 Agent"的完整过程,包括踩过的坑和几个绕不开的源码级修改。
1. 背景与目标
DeepSeek Harness v0.1(npx @deepseek-ai/dsh web)启动后是一个纯本地 Web 应用:页面、后端、实时通道全在 127.0.0.1:<30**> 一个端口上。它没有账号体系,也没有 TLS,官方明确说"非 loopback 绑定(--host 0.0.0.0)目前故意不支持"——因为这会直接把远程代码执行能力暴露到网络上。
目标很直接:手机浏览器打开 https://dsh.f*****.cn,登录后就能操作家里电脑上的 Harness,非局域网可用。安全要求同样明确:未经登录的管理员,一个字节都拿不到。
整体方案复用了我已有的基础设施:一台京东云服务器(Nginx + PHP-FPM 跑 Laravel,FRP 服务端 frps 以 Docker host 网络运行)、本机 Windows(frpc 客户端 + dsh)。
2. 总体架构
手机浏览器
│ HTTPS / WSS
▼
https://dsh.f*****.cn(云服务器)
│ nginx server 块(子域名)
│ ├─ /login 等 Laravel 路径 → 直接交 PHP-FPM(子域单独登录)
│ └─ 其余全部 → auth_request /remote-auth(Laravel 管理员判定)
│ → proxy_pass http://host.docker.internal:<84**>/
▼
frps(云服务器,Docker host 网络,bind_port=<74**>)
│ FRP 隧道(remote_port=<84**>)
▼
frpc(本机 Windows)
│
▼
dsh web(本机 127.0.0.1:<30**>)
├─ /api/* JSON-RPC(HTTP POST + WebSocket downlink)
├─ /assets/ 前端静态资源
└─ /plugins/* 插件运行时 bundle三个关键决定(都是实测后得出的):
- 必须用子域名,不能用子路径;
- dsh 的实时通道是 WebSocket,不是 SSE,Nginx 要配
Upgrade; - 远程访问必须显式
--trusted-host,否则/api一律 403。
3. 为什么要子域名:dsh 前端不支持子路径
dsh 是 Vite 构建的 SPA,所有资源都是绝对路径:/assets/*、/plugins/*、/manifest.webmanifest,API 也是。翻它的源码确认了根本原因:
/** Browser = same-origin ... */
resolveBase() {
const loc = globalThis.location;
return loc?.origin !== void 0 && loc.origin !== "null" ? loc.origin : INTERNAL_BASE;
}/api 请求的 base 是 location.origin(域名根)。也就是说,任何请求最终都会落在域名根路径下,nginx 无论怎么配子路径前缀都会漏掉一部分(我最先试 /dsh/ 子路径,结果插件 bundle /plugins/... 全部 404,/api 还会和 Laravel 自己的 /api 路由冲突)。
结论:给 dsh 一个独立子域名(DNS 加 A 记录 + Let's Encrypt 证书),所有路径天然正确,与主站零冲突。
4. 通信协议:HTTP POST + WebSocket downlink
dsh 的 /api 不是传统 REST,而是 JSON-RPC 风格:
上行:
POST /api/<method>,body 是统一信封,Content-Type必须是application/json(否则 415):{ "type": "client-request", "rpcId": "t1", "method": "host.describe", "payload": {} }响应同样带
type: "server-response"和相同的rpcId。- 下行(实时事件):两个 WebSocket 通道——
/api/events.mux和/api/events.host,服务端只往下发ServerRequest帧;非 WebSocket 的普通 GET 返回426 upgrade required。
对 Nginx 意味着:必须配 WebSocket 升级头 + 长超时 + 关闭缓冲(终端/对话是流式的):
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
proxy_buffering off;5. 第一道闸:Laravel auth_request 管理员网关
dsh 自身没有认证层,所以让 Laravel 当大门(服务器上已有现成的管理员会话体系)。子域名 server 块里复用同一套判定:
location = /remote-auth {
internal;
include fastcgi_params;
fastcgi_pass php:<90**>;
fastcgi_pass_request_body off;
fastcgi_param REQUEST_METHOD GET;
fastcgi_param CONTENT_LENGTH "";
fastcgi_param CONTENT_TYPE "";
fastcgi_param SCRIPT_FILENAME /var/www/front/public/index.php;
fastcgi_param SCRIPT_NAME /index.php;
fastcgi_param DOCUMENT_URI /index.php;
fastcgi_param REQUEST_URI /remote-auth;
fastcgi_param HTTP_COOKIE $http_cookie;
fastcgi_param HTTP_HOST $http_host;
fastcgi_param HTTPS on;
fastcgi_param SERVER_PORT 443;
}
location / {
auth_request /remote-auth;
proxy_pass http://host.docker.internal:<84**>;
# ... WebSocket 头、超时、关缓冲
}踩坑:子域登录死锁。 Laravel 的 session cookie 不跨域——用户在 self.f*****.cn 登录过,访问 dsh.f*****.cn 时浏览器不会带 cookie,auth_request 永远 401。而 dsh.f*****.cn 的 / 又被代理给了 Harness,用户连登录页都进不去。
解决:把 Laravel 登录相关路径从代理中排除,直接交 PHP-FPM:
location ~ ^/(login|logout|register|password)(/|$) {
include fastcgi_params;
fastcgi_pass php:<90**>;
fastcgi_param SCRIPT_FILENAME /var/www/front/public/index.php;
fastcgi_param DOCUMENT_ROOT /var/www/front/public;
fastcgi_param HTTP_HOST $http_host;
fastcgi_param HTTPS on;
fastcgi_param SERVER_PORT 443;
}登录页的静态资源(/css、/js 等)也要单独配 root /var/www/front/public 直接服务(和 Harness 的 /assets/ 不冲突)——否则登录页裸奔无样式。
6. 第二道闸:--trusted-host 与 browser-trust fence
dsh 对 /api 有一个 browser-trust fence(防 DNS rebinding / 跨站请求):
- 请求的
Host必须是 loopback 或--trusted-host声明的 authority; - 启动时加
--trusted-host dsh.f*****.cn; - 实测:带正确域名的
/api放行,Host: evil.example.com直接forbidden(HTTP 403)。
dsh web --trusted-host dsh.f*****.cn特别注意:特权方法只认 loopback。 源码里有一张特权方法表(PRIVILEGED_METHODS),包括 host.pickDirectory、host.openPath、settings.*、credentials.*、agentPreset.*、llm.discoverModels——这些方法即使配了 --trusted-host 也强制 loopback-only(HTTP 403),官方解释是"在真正的认证层出现之前,远程不放行这些"。
这直接导致下一个问题:手机上没法选文件夹。
7. 目录选择器:native vs browse,以及两处源码级修改
dsh 的目录选择(选工作区文件夹)有两种后端:
| 能力 | 行为 | 远程可用性 |
|---|---|---|
native | 弹本机操作系统的文件对话框(host.pickDirectory) | ❌ 远程被 fence 403,且对话框弹在电脑上,人不在电脑前看不到 |
browse | 网页内目录树(host.listDirectory,列目录) | ✅ 非特权方法,--trusted-host 放行 |
而"自动选择器"(directory-picker-auto)的判定只看宿主环境,不看浏览器在哪:
if (facts.bindHost !== "127.0.0.1") return "browse";
if (present(facts.env.SSH_CONNECTION) || present(facts.env.SSH_TTY)) return "browse";
if (facts.platform === "darwin" || facts.platform === "win32") return "native"; // ← Windows 直接 native
if (facts.platform !== "linux" || !facts.linuxChooser) return "browse";
return present(facts.env.DISPLAY) || present(facts.env.WAYLAND_DISPLAY) ? "native" : "browse";本机是 Windows → 无条件 native → 手机远程调 host.pickDirectory → HTTP 403,且弹出的窗口在电脑上。
补丁 1:Windows 改走 browse
把上面第 3 行改为:
if (facts.platform === "darwin") return "native";Windows 不再强制 native,落到 browse。代价:本机访问时目录选择也从"系统对话框"变成网页目录树(功能等价)。改的是 dsh-host-directory-picker-auto/lib/index.js 一行,重启生效。
补丁 2:目录列表常驻盘符
browse 的初始根是 homedir(C:\Users\<你>),手机上要访问 D:\ 得手动输入路径,不够顺手。在 dsh-host-directory-picker-browse 的 list() 里注入一小段:每次列目录时探测 A:\~Z:\,把存在的盘符放到 entries 最前面:
if (process.platform === "win32") {
const probes = Array.from({ length: 26 }, (_, i) => String.fromCharCode(65 + i) + ":\\");
const available = await Promise.all(probes.map((d) => stat(d).then(() => d, () => null)));
const driveRows = [];
for (const drive of available) if (drive !== null) driveRows.push({ name: drive, path: drive, hidden: false });
entries.unshift(...driveRows);
}实测:手机端目录选择器顶部常驻 C:\、D:\、E:\…,点击即进入对应盘。
两个补丁都在 node_modules 里(各约几行),dsh 升级后需要重打——预览版本就迭代快,升级时把这两个补丁和新版 diff 一下即可。
8. FRP 隧道:为什么不用开安全组
隧道沿用现有的 frps(云服务器,Docker host 网络)。本机 frpc.ini 加一条代理:
[dsh]
type = tcp
local_ip = 127.0.0.1
local_port = <30**> # 本机 dsh web
remote_port = <84**> # 公网侧端口
use_encryption = true
use_compression = true一个容易误解的点:京东云安全组不用动。安全组只管"互联网 → 服务器"的入站流量,而我们的链路里 <84**> 只在服务器内网流转(nginx 容器 → 宿主机的 frps),手机只碰 443(早已放行)。用 ss -tlnp 确认 frps 监听 <84**> 即可(frpc 在线时才会监听该端口,frpc 一断,curl http://127.0.0.1:<84**>/ 就连不上——排查"公网 401 变 500"时先查这一环)。
9. 证书:certbot webroot 签发子域证书
服务器已有 80 端口默认 server 的 /.well-known/acme-challenge/ location(webroot 指向 certbot 目录),直接复用:
certbot certonly --webroot -w /opt/f***-lnmp/certbot/www -d dsh.f*****.cn \
--agree-tos --no-eff-email --non-interactive签发后证书在 /etc/letsencrypt/live/dsh.f*****.cn/,nginx 容器挂载了 /etc/letsencrypt,ssl_certificate 直接引用即可,自动续期由 certbot 的后台任务接管。
10. 链路验证命令
# 1) frp 隧道直达本机 dsh(frpc 在线时)
curl http://127.0.0.1:<84**>/ | grep -o '<title>[^<]*</title>'
# → DeepSeek Harness
# 2) 公网未登录访问:网关必须拦截(401),绝不能 200/500
curl -s -o /dev/null -w '%{http_code}\n' https://dsh.f*****.cn/
# → 401
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://dsh.f*****.cn/api/host.describe \
-H 'Content-Type: application/json' -d '{}'
# → 401
# 3) 登录页可达(子域单独登录)
curl -s -o /dev/null -w '%{http_code}\n' https://dsh.f*****.cn/login
# → 200
# 4) 本地 fence 行为(绕过网关,直接打本机 <30**>,带 Host 头模拟)
curl -s -X POST http://127.0.0.1:<30**>/api/host.describe \
-H 'Content-Type: application/json' -H 'Host: dsh.f*****.cn' \
-d '{"type":"client-request","rpcId":"t1","method":"host.describe","payload":{}}'
# → ok:true(版本、cwd、默认模型等)
curl -s -X POST http://127.0.0.1:<30**>/api/host.describe \
-H 'Content-Type: application/json' -H 'Host: evil.example.com' -d '{}'
# → forbidden11. 安全模型总结
整套系统是三层防护,缺一不可:
第一层 Nginx auth_request → Laravel 管理员登录/角色 ← 谁进得来
第二层 dsh browser-trust fence(--trusted-host) ← Host 头防伪造/防 rebinding
第三层 特权方法 loopback-only(pickDirectory/settings/凭据)← 远程默认降权- dsh 绑定保持
127.0.0.1(官方拒绝0.0.0.0是有道理的); --trusted-host只放行"普通方法",特权方法仍锁定 loopback——远程场景下,配置、凭据、系统对话框这些高风险的交互被默认拒绝;- 网关之外,
/api的Content-Type: application/json强制校验(415)也挡住了跨站"简单请求"盲发副作用调用。
12. 已知限制与后续
- 预览版:dsh 0.1.0-rc.6 迭代很快,补丁(Windows browse + 盘符)需随版本重打;
- 移动端适配:Web UI 是桌面布局,手机能用但体验一般;官方没有移动端,社区有"远程访问"插件方向(反向隧道 +
remote-auth配对),但预览阶段集成成本高; - 单用户:Laravel 网关是单管理员判定,无多租户隔离;
- API key:建议在服务端用环境变量/配置文件预置,避免在公网页面手输。
附:关键文件位置
| 位置 | 内容 |
|---|---|
服务器 /etc/nginx/conf.d/dsh.conf | 子域 server 块(auth_request + WS 代理 + login 排除 + 静态资源) |
服务器 /etc/letsencrypt/live/dsh.f*****.cn/ | 子域证书 |
本机 frpc.ini(追加 [dsh] 段) | remote_port <84**> → local <30**> |
dsh-host-directory-picker-auto/lib/index.js | 补丁 1:Windows 走 browse |
dsh-host-directory-picker-browse/lib/index.js | 补丁 2:目录列表常驻盘符 |
安全说明:文中域名(dsh.f*****.cn)、服务器 IP、端口(<84**>/<74**>/<30**>/<90**>)均已打码,部署时替换为实际值。

还不快抢沙发