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 能跨重启继续用。
两条结论直接决定反代怎么写:
- Host 必须原样转发。写成
$host:443会当场 403 —— 浏览器发来的 Origin 是https://域名,归一化后不带端口,和host:443对不上。 - 必须传
--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 能执行任意命令,这台机器上还跑着网关和博客,它的进程不该读到后者的凭据。除 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 进。
小结
- dsh 默认只回环,反代要原样转发 Host 并传
--trusted-host,否则/api被信任栅栏拒掉 - 上游 401 不触发
error_page,proxy_intercept_errors on是自动换 cookie 的前提 - token 每进程轮换、cookie 签名密钥持久化;用
ExecStartPost=+脚本把 token 同步给 nginx,重启后进入无感 - 按 RCE 服务对待:独立用户、只读根、只写自己 home、清空 capability,再用 ACL 挡住同机其他家目录
- 非回环页面的设置页是禁用的,模型与凭据统一走
settings.yaml和环境变量