Skip to content

Instantly share code, notes, and snippets.

@felix021
Last active August 23, 2026 05:41
Show Gist options
  • Select an option

  • Save felix021/c22b61d5e51bd36c7a6fc8ddb42b84d1 to your computer and use it in GitHub Desktop.

Select an option

Save felix021/c22b61d5e51bd36c7a6fc8ddb42b84d1 to your computer and use it in GitHub Desktop.
用 nginx 反代 dsh web:HTTPS + Basic Auth 部署笔记
# 用 nginx 反代 dsh web:HTTPS + Basic Auth 部署笔记
dsh(DeepSeek 的 agent harness)自带 web 界面,`dsh web` 默认只监听 `127.0.0.1:3080`。这篇记录怎么用 nginx 把它挂到 HTTPS 端口上,从浏览器安全访问,外加 systemd 常驻和静态资源缓存。2026-08 更新:dsh 0.1.1 起前端新增浏览器侧 loopback 判定,Host 改写不再够用,需要 sub_filter 补丁(见「三道安全栅栏」)。文中域名、用户名、密码均已替换为占位符,按自己环境改。
## 需求
- dsh 跑在内网机器上,只听 `127.0.0.1:3080`
- 想通过 `https://<your-domain>:3088` 访问,全程 TLS
- 设置页、凭据管理也要能用(这会影响方案,见下)
- 进程常驻:开机自启、崩了自动拉起
- 打开要快:不能每次都全量拉前端资源
## dsh 的三道安全栅栏
反代 dsh 最大的坑在它的信任检查(源码里叫 browser-trust fence),不了解会一直 403 或莫名自锁。规则:
1. **Host 栅栏**:所有 `/api` 请求的 Host 头必须是 loopback(`127.0.0.1` / `localhost`),或者启动时用 `--trusted-host` 声明过的域名,否则一律 403。
这防的是 DNS rebinding(攻击者把自己的域名解析到你的机器,浏览器带着他的 Host 头访问你本机服务)。`--trusted-host` 接受 `host` 或 `host:port`,可重复传。
2. **privileged 方法栅栏**:`settings.*`、`credentials.*`、`agentPreset.*`、`host.pickDirectory`、`host.openPath`、`llm.discoverModels` 这批方法**硬编码只认 loopback**,`--trusted-host` 对它们无效,Host 是外部域名就 403。
3. **前端栅栏(0.1.1 起)**:前端代码按**浏览器地址栏**的 hostname 自判是否 loopback(`dsh-client-connection` 包里的 `isLoopbackHostname(location.hostname)`,只认 `localhost` / `[::1]` / `127.x.x.x`)。判非 loopback 时,settings/credentials 相关的前端模块直接进 "memory" 模式——**RPC 根本不发**,设置页报「加载提供方目录失败: settings are unavailable in this browser」。这跟请求头无关,后端 Host 改写骗得过 1、2 两道,骗不过浏览器里的 JS。
这是有意设计:作者认为 `--trusted-host` 只是防 rebinding 的栅栏,不是认证层;配置平面(含 API key 读写)在出现真正的认证之前锁定本机。模型目录(`llm.providers` / `llm.models`)特意放开,外部域名可以拉到模型列表。
所以只配 `--trusted-host` 的话:聊天和选模型能用,一进设置页就报 `HTTP 403`。而 Host 改写方案在 0.1.1+ 会撞上第 3 道栅栏:后端放行、前端自锁,报的不是 403 而是 "settings are unavailable in this browser"。
## 方案
链路:浏览器 →(TLS + Basic Auth)→ nginx :3088 →(Host 改写为 loopback、清掉 Origin、前端 JS 补丁)→ dsh :3080
- **Basic Auth 承担认证**:这是整个方案的安全前提。改写 Host 等于把 privileged API 的门打开,门上得有锁。
- **Host 改写成 `127.0.0.1:3080`**:让 dsh 把反代请求当本机直连,前两道栅栏都过。
- **Origin 头清掉**:dsh 会校验 Origin 与 Host 同源,浏览器带来的 `Origin: https://<your-domain>:3088` 和改写后的 Host 对不上会 403。nginx 里把头设为空字符串即可(空值头不会转发给后端)。
- **前端栅栏用 sub_filter 补丁过(0.1.1+)**:dsh 前端不是单一 bundle,浏览器按需从 `/plugins/@deepseek-ai/<pkg>/client.js` 拉取**明文、带注释**的 JS。用 nginx 的 `sub_filter` 给 `isLoopbackHostname` 函数体注入一行:我们的域名也算 loopback。见下面的 `/plugins/` location。
### nginx 配置
http 块里加 WebSocket 透传用的 map 和 gzip(dsh 后端本身不压缩):
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
gzip on;
gzip_types text/css text/javascript application/javascript application/json image/svg+xml;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
```
server 块(证书路径按自己的改)。公共反代头提到 server 级,四个 location 各司其职:
```nginx
server {
listen 3088 ssl;
http2 on; # 多路复用,同域多请求不再排队
server_name <your-domain>;
ssl_certificate <path>/fullchain.pem;
ssl_certificate_key <path>/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 1d;
auth_basic "dsh";
auth_basic_user_file <nginx-prefix>/conf/dsh.htpasswd;
# 公共反代头,被各 location 继承(location 里一旦写任何
# proxy_set_header 就整组覆盖,所以放 server 级最省心)
proxy_set_header Host 127.0.0.1:3080;
proxy_set_header Origin ""; # 空值头不会转发,dsh 视为本机直连
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# 带内容 hash 的前端资源:长缓存 + immutable
# dsh 的 assets 文件名形如 index-C-1AiF3k.js,升级即变名,天然失效
location ^~ /assets/ {
proxy_pass http://127.0.0.1:3080;
expires 30d;
add_header Cache-Control "public, immutable";
}
# 入口 HTML 不缓存,保证发版后能拿到新 hash 的资源引用
location = / {
proxy_pass http://127.0.0.1:3080;
add_header Cache-Control "no-cache";
}
# 前端栅栏补丁(dsh ≥0.1.1):前端 JS 从这里按需加载(明文带注释),
# 给 isLoopbackHostname 注入本域名豁免,否则设置页报
# "settings are unavailable in this browser"
location ^~ /plugins/ {
proxy_pass http://127.0.0.1:3080;
sub_filter_types text/javascript application/javascript;
sub_filter_once off;
sub_filter 'if (hostname === "localhost" || hostname === "[::1]") return true;'
'if (hostname === "<your-domain>") return true; if (hostname === "localhost" || hostname === "[::1]") return true;';
}
# 其余:页面路由、/api(SSE 流式输出与 WebSocket)
location / {
proxy_pass http://127.0.0.1:3080;
proxy_buffering off; # 关缓冲,SSE 才能逐字出
proxy_read_timeout 3600s;
}
}
```
几个容易漏的点:
- `proxy_buffering off`:dsh 的流式回复走 SSE,开着缓冲会卡住不逐字出。
- `proxy_set_header Host` 直接写 `127.0.0.1:3080` 而非变量,别用 `$host`(不带端口)或 `$http_host`(保留了外部域名,过不了栅栏)。
- `sub_filter_types` 必须显式加 `text/javascript`:nginx 的 sub_filter 默认只处理 `text/html`,而 dsh 后端 serve `/plugins/` 的 JS 时发的就是这个 content-type。漏了的话补丁静默不生效,症状和没配一样。
- sub_filter 匹配的是上游源码字符串,**dsh 升级若改了 `isLoopbackHostname` 的实现,补丁会静默失效**(设置页又开始报 "settings are unavailable in this browser"),需要对照新版源码更新匹配串。所以升级后要专门验证这一条(见「验证」节末尾)。
### 为什么要专门做缓存和压缩
dsh 前端约 1.3MB(vendor + index 的 js/css),而后端不发任何缓存头(无 Cache-Control/ETag)、不做 gzip——不加这两层的话,浏览器每次打开页面都全量裸拉 1.3MB,跨内网/VPN 访问会明显卡。加上之后:gzip 把传输压到约 29%,`immutable` 让二次打开一个字节都不用传。实测收益:首次打开快 3 倍多,之后秒开。
### 生成密码文件
```bash
PASS=$(openssl rand -base64 15 | tr -d '/+=' | head -c 16)
printf '<user>:%s\n' "$(openssl passwd -apr1 "$PASS")" > <nginx-prefix>/conf/dsh.htpasswd
chmod 640 <nginx-prefix>/conf/dsh.htpasswd # 见下面的坑
echo "密码: $PASS" # 记下来
```
**权限坑**:nginx worker 通常不是 root(比如 docker 的 nginx:alpine,worker 是容器内 UID 101 的 nginx 用户)。如果 htpasswd 是 600 且属主不是 worker 能读的,会出现很迷惑的现象——**不带密码请求正常 401(回 challenge 不需要读文件),一带密码就 500**(worker 打开文件 Permission denied,看 error log 才知道)。确保 worker 可读:宿主文件 644,或者 chown 成容器内 worker 的 UID。
### 启动 dsh
用 systemd user service 跑,开机自启、挂了自动拉起。screen 只是"挂着":进程崩了、机器重启了都得手动重建,systemd 都替你做了。
```ini
# ~/.config/systemd/user/dsh.service
[Unit]
Description=dsh web
[Service]
WorkingDirectory=<workdir>
ExecStart=<node-path> <npm-global-root>/@deepseek-ai/dsh/lib/bin.js web --trusted-host <your-domain>:3088
Restart=always
RestartSec=3
StartLimitIntervalSec=300
StartLimitBurst=5
[Install]
WantedBy=default.target
```
```bash
systemctl --user daemon-reload
systemctl --user enable --now dsh.service
loginctl enable-linger <user> # 必做,见下
```
几个踩过的坑:
- **`Restart=always` 而不是 `on-failure`**:dsh 收到 SIGTERM 会优雅退出并返回 exit 0,`on-failure` 认为是"正常退出"就不拉起了。`systemctl --user stop` 主动停止不会触发重启,不受影响。
- **ExecStart 全部用绝对路径**:systemd 环境里没有 nvm/npm 的 PATH,裸 `node`、全局 `dsh`(shebang 是 `#!/usr/bin/env node`)都找不到——后者会让服务 127 循环重启,日志里一行 node 的输出都没有。先 `npm i -g @deepseek-ai/dsh`,再用 `which node` 和 `npm root -g` 拿到两个路径填进去。别直接用 npx 缓存(`~/.npm/_npx/...`)里的路径——清一次缓存服务就起不来了。
- **开 linger**:不开的话你一退出登录,所有 user service 就被杀了。`loginctl enable-linger <user>` 开了之后不登录也保持运行,机器重启后自动拉起。
- `StartLimitIntervalSec` + `StartLimitBurst`:给循环重启兜底。配置写错时 300 秒内最多拉起 5 次就放弃进 failed,不会无限刷日志。
`--trusted-host <your-domain>:3088` 在这个方案里已经不是必须的(Host 已被改写),但留着无害,算第二道冗余。
**升级 dsh**:绝对路径写法的好处是升级不用改 unit——npm 全局目录位置不变,直接:
```bash
npm i -g @deepseek-ai/dsh && systemctl --user restart dsh.service
```
注意 0.1.1 起升级后要重新验证 sub_filter 补丁仍匹配(dsh 可能改了 `isLoopbackHostname` 源码),见「验证」节。
### 如果 nginx 跑在 docker 里:单文件挂载的 inode 坑
配置文件如果是**单文件** bind mount(比如 `- ./nginx.conf:/etc/nginx/conf.d/default.conf`),编辑器保存通常是"写临时文件 + rename",**inode 变了,容器里挂的还是旧 inode**。症状:宿主机上看配置是新的,`nginx -s reload` 后行为却毫无变化,`nginx -t` 也不报错(测的还是旧文件)。修复:`docker restart <nginx>` 会重新解析宿主路径、拿到新 inode;`docker compose up -d --force-recreate` 也行。验证生效:对比宿主和容器内文件的 md5sum。目录挂载没有这个问题。
## 验证
```bash
# 无密码 → 应 401
curl -k https://127.0.0.1:3088/
# 带密码拉页面 → 应 200
curl -k -u <user>:<password> https://127.0.0.1:3088/
# privileged API(带 Origin 模拟浏览器)→ 应 200
curl -k -u <user>:<password> -X POST \
-H 'Content-Type: application/json' \
-H 'Origin: https://<your-domain>:3088' \
-d '{"type":"client-request","rpcId":"t1","method":"settings.describe","payload":{}}' \
https://127.0.0.1:3088/api/settings.describe
# 静态资源 → 应看到 cache-control: max-age=... + immutable 和 content-encoding: gzip
curl -k -u <user>:<password> -H 'Accept-Encoding: gzip' -D - -o /dev/null \
https://127.0.0.1:3088/assets/ | grep -iE 'cache-control|content-encoding'
# 崩溃恢复:杀掉主进程,3 秒后应自动拉起
systemctl --user kill -s SIGKILL dsh.service && sleep 6 && systemctl --user is-active dsh.service
# 前端栅栏补丁是否仍生效(0.1.1+;每次 dsh 升级后都要查)
# 应输出 1(经 nginx 的 JS 里注入了本域名的豁免)
curl -k -u <user>:<password> \
"https://127.0.0.1:3088/plugins/@deepseek-ai/dsh-client-connection/client.js" \
| grep -c '<your-domain>'
```
浏览器第一次访问会弹 Basic Auth 登录框,输一次后浏览器记住。
## 安全注意
- Basic Auth 是这套方案里唯一的锁,后面就是能写 API key 的接口。sub_filter 补丁把前端"仅 loopback"的自锁也打开了,安全性完全压在这把锁上——密码用随机生成的,别复用别外传。
- TLS 必须开着:Basic Auth 是 base64 明文,没有 HTTPS 等于裸奔。记得盯证书有效期(`openssl x509 -enddate -noout -in <cert>`),过期后浏览器会告警、API 客户端直接断——建议配 acme 之类自动续期或至少日历提醒。
- 如果只需要聊天和选模型、不动设置,更好的做法是尊重 dsh 的默认设计:`--trusted-host <your-domain>:3088` 就够,privileged 操作走 SSH 隧道(`ssh -L 3080:127.0.0.1:3080 <host>` 后访问 `http://127.0.0.1:3080`)。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment