Last active
August 23, 2026 05:41
-
-
Save felix021/c22b61d5e51bd36c7a6fc8ddb42b84d1 to your computer and use it in GitHub Desktop.
用 nginx 反代 dsh web:HTTPS + Basic Auth 部署笔记
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| # 用 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