不装插件,用三个原生参数把 DeepSeek Harness 反向代理到Nginx

先看一个真实的报错

把 DeepSeek Harness 放到公网,很多人的第一反应是 --host 0.0.0.0,然后被直接拒绝:

error: --host 0.0.0.0 is an unspecified (wildcard) address, which is not supported:
binding every interface would expose remote code execution to the network;
bind one concrete IPv4 or IPv6 address instead

这不是没做完,是故意的。DSH 的 Web 端默认只监听回环,且没有任何「按方法区分」的本机豁免——也就是说,它默认假设对面就是本机浏览器。想从外面访问,正确的做法不是加插件、也不是改代码,而是用 0.2.1 里已经有的三个原生参数,把它交给一个反向代理。

下面所有结论,我都用 0.2.1-rc.2 起了一个临时实例实测过,不是照抄文档。

三个参数,各管一件事

参数 它负责什么 它不负责什么
--host 监听器绑哪个地址。只接受本机某个具体 IPv4/IPv6,通配地址被拒 不决定谁能访问,也不改变信任名单
--trusted-host 往 Host/Origin 栅栏的信任名单里加 authority,可重复;host:port 精确匹配,不带端口则匹配任意端口 不做认证,不配置路由
--public-url 公告浏览器该用哪个 HTTP(S) 根:打印与打开的启动 URL、DSH_WEB_URL、agent 运行上下文里的 GUI 地址 不授予任何信任,不配置监听、路由或 cookie 作用域

还有一对可选参数:--tls-cert 与 --tls-key,让监听器自己提供 HTTPS。两者必须同时给,只给一个会报用法错误;DSH 不签发、不续期证书,也没有 ACME,换了证书要重启。

实测:401 和 403 的分界线

我用 --host 127.0.0.1 --port 3099 --public-url https://dsh.example.com/ --trusted-host dsh.example.com 起了实例,然后换不同的 Host 头请求 /api:

请求的 Host 结果 含义
dsh.example.com 401 通过了信任栅栏,差的是登录 cookie
127.0.0.1:3099 401 绑定 IP 本身天然被接受
evil.example.com 403 不在名单里,直接拒绝
dsh.example.com + 错误 Origin 403 Origin 必须与 Host 同源

还有一个很容易配错的点,我单独验证了:只写 --public-url、不写 --trusted-host,访问照样是 403。公告和信任是两件事——前者告诉浏览器「去哪里找我」,后者才决定「我认不认这个域名」。

为什么代理必须保留 Host

这是整套配置里最容易翻车的地方。DSH 的会话 cookie 是 host-only 的,签名 payload 里绑定了它看到的 authority;同时每个请求还要过 Origin 校验。也就是说:

  • 如果代理把 Host 改写成 127.0.0.1:3080,浏览器发来的 Origin: https://dsh.example.com 就对不上,直接 403(我实测过,确实如此);
  • 如果代理保留 Host 但没配 --trusted-host,同样 403;
  • 只有「保留 Host + trusted-host 写上公网域名」,401 才会出现——那是正常的「请先登录」。

换到 token 那一步,实测返回长这样(值已脱敏):

HTTP/1.1 303 See Other
location: ./
set-cookie: dsh-auth-xxxx=v1.eyJhdXRob3JpdHkiOiJkc2guZXhhbXBsZS5jb20i...;
            Max-Age=2592000; Path=/; HttpOnly; SameSite=Strict

注意三点:cookie 有效期 30 天;作用域是 host-only;没有 Secure 标记——因为签发它的监听器是明文 HTTP。这一点后面单独说。

方案一:Caddy,根域名(推荐)

DSH 只绑回环,Caddy 在宿主机上终止 TLS:

dsh --profile web --no-open \
  --host 127.0.0.1 --port 3080 \
  --public-url https://dsh.example.com/ \
  --trusted-host dsh.example.com

对应的 Caddyfile:

dsh.example.com {
    encode zstd gzip
    reverse_proxy 127.0.0.1:3080 {
        header_up Host {host}
    }
}

header_up Host {host} 是显式保留原始 Host。Caddy 默认通常也会透传,但写出来能避免以后换配置时被悄悄改掉。WebSocket 的 /api/remote.mux 由 Caddy 自动处理升级,不用额外配置。

方案二:Nginx

Nginx 的默认行为恰好相反——不写 proxy_set_header Host,上游收到的是 $proxy_host,于是必然 403:

map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

server {
    listen 443 ssl http2;
    server_name dsh.example.com;

    location / {
        proxy_pass http://127.0.0.1:3080;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection $connection_upgrade;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 3600s;
        proxy_buffering off;
    }
}

顺带说一句:DSH 判断 Host 时不采信 X-Forwarded-*,所以你写不写转发头都不影响鉴权;写上的价值是日志和以后接别的服务时保持一致。

子路径部署:public-url 真正发光的地方

如果不想为它单独开一个域名,可以挂在子路径上。--public-url 要写成带前缀的根,代理负责把前缀剥掉:

dsh --profile web --no-open \
  --host 127.0.0.1 --port 3080 \
  --public-url https://example.com/dsh/ \
  --trusted-host example.com
example.com {
    handle_path /dsh/* {
        reverse_proxy 127.0.0.1:3080 {
            header_up Host {host}
        }
    }
}

这条路能走通,是因为前端的资源引用是相对路径(实测首页里的 ./assets/...、plugins/...),配上前缀剥离后浏览器解析出来的地址仍然会经过代理。不过根域名还是更省心:少一层前缀匹配,也少一类「某个绝对路径没被代理到」的疑难杂症。

容器里:别绑 0.0.0.0

容器里最常见的错误是「为了让宿主机访问到,绑 0.0.0.0」。DSH 不允许,官方给的做法是绑容器自己的地址:

services:
  dsh:
    image: your-registry/dsh:0.2.1-rc.2
    restart: unless-stopped
    command:
      - sh
      - -c
      - >-
        exec dsh --profile web --no-open
        --host "$$(hostname -i | awk '{print $$1}')"
        --port 3080
        --public-url https://dsh.example.com/
        --trusted-host dsh.example.com

compose 里 $$ 是转义后的 $,别写成单个。Caddy 放在同一个 compose 网络里,反代 dsh:3080 即可。如果嫌动态取地址麻烦,Linux 上也可以让 DSH 用 network_mode: host 绑 127.0.0.1,代价是失去端口隔离。

TLS 与 cookie 的那点遗憾

代理终止 TLS、上游走 HTTP,是标准做法,但它带来一个副作用:DSH 签发的 cookie 不带 Secure。原因是这个标记只由「接收请求的监听器是否提供 TLS」决定,public-url 和转发头都不参与判断。

后果不算致命——cookie 仍然是 HttpOnly + SameSite=Strict,且只能由代理转发进来;但如果你要求它必须带 Secure,只有一条路:让 DSH 自己开 TLS(--tls-cert 和 --tls-key),代理再反代到 https://127.0.0.1:3080。多一层证书管理,换一个标记,值不值得看你的合规要求。

上线前的检查清单

  • 端口只对代理开放。127.0.0.1:3080 或 compose 内网,别让 3080 直接暴露在公网。
  • trusted-host 写域名,不写 IP。写 IP 等于把 Host 校验让给了任何能用该 IP 访问的人。
  • public-url 用 https。它在 agent 上下文里会被当作 GUI 地址使用,写 http 会误导后续操作。
  • 启动 URL 当密码对待。那一行里带进程级 token,别贴进群里、别进工单。不想让它进日志,可以把配置里的 printUrl 关掉,但那样你就得从别处拿登录入口。
  • 别缓存 /api。反向代理对 API 和 WebSocket 一律关闭缓冲与缓存。
  • 它是单用户模型。cookie 对应的就是 owner,多个人共用一个入口等于共用同一个 agent 和同一份文件权限。

我的判断

这三个参数的分工其实很清晰:监听、信任、公告。绝大多数配置错误,都是把其中两件事混成一件——以为 public-url 配了就能访问,或者以为 --host 换了就放行了。

DSH 在这件事上的取舍我挺认同:默认只信本机,往外走必须显式点名,token 换 cookie 而不是长期 bearer,cookie 绑 authority。它没有替你解决「怎么安全地暴露到公网」,但把每一层该由谁负责划得很清楚——剩下的,是反向代理和你的防火墙该干的活。

延伸阅读

© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 抢沙发
头像/SUENCA
欢迎您留下宝贵的见解!
提交
头像/SUENCA

昵称

取消
昵称表情代码图片快捷回复

    暂无评论内容