把 dsh 挂上公网:两道闸与自动登录

DeepSeek Harness 部署到服务器并暴露公网:随机子域名 + Basic Auth + 自动换 cookie,附 systemd 加固与踩坑

dsh(DeepSeek Harness)是 DeepSeek 开源的 agent harness,有 CLI 也有 Web UI,默认只监听 127.0.0.1:3080,CLI 明确拒绝 --host 0.0.0.0。它自带启动 token 与签名 cookie 两道鉴权,但没有 TLS、没有账号体系,官方也把「网络部署」列为不支持。想在服务器上用,就得自己在前面架一层。下面是完整过程,机器是 Azure 上那台 892MB 的小 VPS。

整体链路
整体链路

一、为什么不能直接开出去

dsh 的 Web 宿主在 /api 前缀上有一道「浏览器信任栅栏」,代码注释写得很直白:它挡的是 DNS rebinding 和跨站请求,不是身份认证。

检查 规则
Host 必须是回环地址,或与 --trusted-host 声明的 authority 匹配
Origin(带了才查) 必须与 Host 完全一致
sec-fetch-site cross-site 一律拒
POST 媒体类型 必须是 application/json,否则 415

认证是另一层:每个进程随机生成一个启动 token,只有 GET /?token=... 能把它换成签名 cookie(HttpOnly、SameSite=Strict、绑定 host:port、默认 30 天)。token 不落盘、每次启动都变;签名密钥存在 $DSH_HOME/.credentials.yaml,所以 cookie 能跨重启继续用。

两条结论直接决定反代怎么写:

  1. Host 必须原样转发。写成 $host:443 会当场 403 —— 浏览器发来的 Origin 是 https://域名,归一化后不带端口,和 host:443 对不上。
  2. 必须传 --trusted-host <域名>,否则 /api 全部被 Host 栅栏拒掉。

二、安装与首启

sudo useradd -m -s /bin/bash dsh          # 独立账号,不进 sudo 组
sudo npm i -g @deepseek-ai/dsh            # 本次装到 0.1.5-rc.1

首次启动会从内置模板生成 profile($DSH_HOME/profiles,要装一批插件依赖),所以第一遍慢一些:

sudo -u dsh env HOME=/home/dsh DSH_HOME=/home/dsh/.dsh \
  dsh web --port 3080 --no-open --trusted-host dsh-xxxx.你的域名

# dsh web: http://127.0.0.1:3080/?token=...

--no-open 不弹浏览器,--trusted-host 只扩信任栅栏,不给身份。

三、systemd 托管

机器只有 892MB 内存,必须给上限;再按 RCE 服务的标准一并做隔离:

# /etc/systemd/system/dsh-web.service
[Unit]
Description=DeepSeek Harness (dsh) Web UI
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=dsh
Group=dsh
WorkingDirectory=/home/dsh/workspace        # 工作区就是启动目录
Environment=HOME=/home/dsh
Environment=DSH_HOME=/home/dsh/.dsh
EnvironmentFile=/etc/dsh/dsh.env            # DEEPSEEK_API_KEY,600 root
ExecStart=/usr/bin/dsh web --port 3080 --no-open --trusted-host dsh-xxxx.你的域名
Restart=always
RestartSec=5

MemoryMax=800M
TasksMax=512

NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/home/dsh
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
UMask=0077

[Install]
WantedBy=multi-user.target

ProtectSystem=strict 把整个根文件系统挂成只读,唯一能写的是 ReadWritePaths 指定的 /home/dsh。systemd-analyze security dsh-web 实测 4.1 OK。

四、Key 走环境变量

内置的 llm-deepseek 适配器默认凭据引用就是 DEEPSEEK_API_KEY,用 systemd EnvironmentFile 注入:

# /etc/dsh/dsh.env(600 root:root)
DEEPSEEK_API_KEY=sk-xxxxxxxx

由环境变量供值的引用在 Web 设置里是只读的(writable: false),key 不进应用可写状态,轮换只要改这个文件加一次 restart。

五、公网入口

为什么用子域名

dsh 前端是标准 SPA,静态资源与 /api 都走绝对路径,用路径前缀反代要动一堆地方,cookie 的 authority 绑定也更绕。子域名最省事;没有域名就用 sslip.io:dsh-xxxx.<IP>.sslip.io 会解析到那个 IP,Let’s Encrypt 照签。

随机子域名本身当第一层"隐藏":串不可猜。证书用 webroot 签,80 端口的 server 块只放行 ACME 校验:

location ^~ /.well-known/acme-challenge/ { root /var/www/html; auth_basic off; }
sudo certbot certonly --webroot -w /var/www/html -d dsh-xxxx.你的域名

# Basic Auth 账号,密码用 openssl 生成 apr1 哈希(服务器上没装 htpasswd 也能用)
printf 'dsh:%s\n' "$(openssl passwd -apr1 "$PASS")" \
  | sudo install -m 640 -o root -g www-data /dev/stdin /etc/nginx/.htpasswd-dsh

反代与限速

server {
    listen 443 ssl;
    server_name dsh-xxxx.你的域名;       # 只有这个名字能进

    auth_basic "DeepSeek Harness";       # 第一道闸
    auth_basic_user_file /etc/nginx/.htpasswd-dsh;
    limit_req zone=dsh_zone burst=120 nodelay;
    client_max_body_size 100m;

    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_buffering off;             # SSE / 流式必需
        proxy_read_timeout 3600s;        # 长任务别被 60s 掐断
        proxy_request_buffering off;
    }
}

六、第二道闸的坑

只过 Basic Auth 会被 dsh 自己挡住:

dsh web authentication required; reopen the URL printed by dsh web.

这是启动 token 那道闸。手动过的话,从服务日志里捞当前进程的 URL:

journalctl -u dsh-web -o cat | grep -o 'token=[A-Za-z0-9_-]*' | tail -1

两个容易踩的点:

  • 上游返回的 401 不会触发 error_page:nginx 默认 proxy_intercept_errors off,只拦自己产生的错误码,要在这个 location 里显式打开
  • map $http_upgrade $connection_upgrade 得定义在 http 块;漏了 WebSocket 升级会失败,页面上表现为一直转圈

七、让闸门无感

token 每进程都在 journal 里,可以让 nginx 自己拿它换 cookie:首页拿到 401 时内部重试一次带 token 的请求,把 303 + Set-Cookie 原样透给浏览器。浏览器地址栏全程不带 token,比手动粘贴 URL 更干净。

location = / {
    error_page 401 = @dsh_login;
    proxy_intercept_errors on;          # 关键:否则不触发 error_page
    proxy_pass http://127.0.0.1:3080;
}

location @dsh_login {
    proxy_pass http://127.0.0.1:3080/?token=$dsh_launch_token;
}

$dsh_launch_token 由一个小脚本同步:从 journal 取当前进程 token,写进 /etc/nginx/conf.d/dsh-token.conf 里的 map,再 reload nginx。挂到服务启动钩子上,重启后 token 自动跟上:

# /etc/systemd/system/dsh-web.service.d/token-sync.conf
[Service]
ExecStartPost=+/usr/local/bin/dsh-token-sync     # + 前缀 = 以 root 跑,才能读 journal、reload nginx

两道闸的时序
两道闸的时序

代价要说清楚:这样一来对外实际只剩 Basic Auth 一道闸,加上它前面的随机子域名。想恢复两道闸,屏蔽掉内部重试即可:

sudo touch /etc/dsh/auto-login.off && sudo dsh-token-sync --now   # 关
sudo rm /etc/dsh/auto-login.off && sudo dsh-token-sync --now      # 开

八、验证

鉴权矩阵,全部从公网入口测:

请求 结果
无 Basic Auth 401
密码错误 401
Basic Auth、无 cookie 303 + Set-Cookie(自动登录)
Basic Auth、带 cookie 200
直连 127.0.0.1:3080 并伪造 Host: localhost 401(信任栅栏与 cookie 双查)

最后用无头浏览器跑一轮真实任务,确认 /api、工具调用、模型都通:

dsh Web UI 里跑一轮任务 打开图片预览
dsh Web UI 里跑一轮任务

九、隔离与限制

同机隔离:dsh 能执行任意命令,这台机器上还跑着网关和博客,它的进程不该读到后者的凭据。除 ProtectHome=read-only 外再补一条 ACL,把整个家目录对它关掉:

sudo setfacl -m u:dsh:--- /home/你的用户     # 撤销:sudo setfacl -x u:dsh /home/你的用户

同机其他服务的家目录里若有 world-readable 的凭据文件(~/.cli-proxy-api/config.yaml、~/.pi、~/.codex),一并收成 700。

设置页在非回环访问下不可用:客户端会判断页面自身是不是 loopback,不是就把设置持久化降级成内存(源码里是 isLoopback ? 'host' : 'memory'),于是「设置 → 模型」直接报 settings are unavailable in this browser。模型与凭据都改服务端文件:

# /home/dsh/.dsh/settings.yaml,改完下一次请求生效,不用重启
llm-deepseek:
  reasoningEffort: high

公网暴露 RCE 的现实:过了 Basic Auth 就是一个能跑命令的 agent。密码用 24 位随机串、限定单域名、开限速,能做的都做了;要更稳就关掉自动登录,只用一次性 token URL 进。

小结

  1. dsh 默认只回环,反代要原样转发 Host 并传 --trusted-host,否则 /api 被信任栅栏拒掉
  2. 上游 401 不触发 error_page,proxy_intercept_errors on 是自动换 cookie 的前提
  3. token 每进程轮换、cookie 签名密钥持久化;用 ExecStartPost=+脚本 把 token 同步给 nginx,重启后进入无感
  4. 按 RCE 服务对待:独立用户、只读根、只写自己 home、清空 capability,再用 ACL 挡住同机其他家目录
  5. 非回环页面的设置页是禁用的,模型与凭据统一走 settings.yaml 和环境变量

版权说明

本文由 BING 撰写,采用 CC BY-NC-SA 4.0 许可协议。转载请注明作者与原文链接。

https://xbzhang.xyz/dev/posts/2026-09-17-dsh-public-web/

搜索文章

输入关键词查找,使用 ↑ ↓ 选择、Enter 打开

从标题、摘要、分类和标签中查找。

BINGBLOG · AI

AI 助读

回答由模型生成,请对重要信息自行核实。

你好,我可以根据本站文章帮助查找信息,也可以回答关于本站技术实践的问题。

OWNER ACCESS

博主验证

输入认证器中的 6 位动态码,以解除公共限速并启用模型选择。