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

三个关键决定(都是实测后得出的):

  1. 必须用子域名,不能用子路径
  2. dsh 的实时通道是 WebSocket,不是 SSE,Nginx 要配 Upgrade
  3. 远程访问必须显式 --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.pickDirectoryhost.openPathsettings.*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 的初始根是 homedirC:\Users\<你>),手机上要访问 D:\ 得手动输入路径,不够顺手。在 dsh-host-directory-picker-browselist() 里注入一小段:每次列目录时探测 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/letsencryptssl_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 '{}'
#    → forbidden

11. 安全模型总结

整套系统是三层防护,缺一不可:

第一层   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——远程场景下,配置、凭据、系统对话框这些高风险的交互被默认拒绝;
  • 网关之外,/apiContent-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**>)均已打码,部署时替换为实际值。

本文由 fmujie 创作,采用 知识共享署名 3.0,可自由转载、引用,但需署名作者且注明文章出处。

还不快抢沙发

添加新评论

召唤看板娘