把 dsh 放到反向代理后面(含本插件的两处适配)
August 19, 2026 · View on GitHub
这份文档记录一条实际跑通的链路:公网 → Nginx Proxy Manager(443, TLS + 密码) → socat 转发(网桥网关) → dsh(回环)。
它不属于本插件的功能,但和本插件强相关:dsh 一旦通过域名访问,插件的 RPC 通道和通知里的跳转链接都需要相应适配,否则面板会整体 403、通知链接点开是死链。下面每一条约束都是实测得出的,不是照搬通用教程。
文中
dsh.example.com是占位符,换成你自己的域名。别把真实域名写进公开仓库——dsh 没有自己的认证,把入口地址公开等于给扫描器发请帖(实测中该域名上线数小时内就出现了 LeakIX 的扫描记录)。
先说清楚风险
dsh 没有任何登录认证。 谁能访问到它的端口,谁就能在这台机器上以 root 执行命令、读写文件。本插件的定时任务还会用免审批的权限预设自动跑工具。
所以整套设计只有一个目标:让"能访问到 dsh 端口"这件事尽可能难。具体是两条:
- dsh 的端口从不出现在公网网卡上
- 唯一入口(443)前面有密码
架构
公网
│
▼
┌────────────────────────────┐
│ Nginx Proxy Manager :443 │ TLS(Let's Encrypt)
│ (docker 容器) │ + Basic Auth ← 唯一的门
└────────────┬───────────────┘
│ http://10.0.2.1:3080
▼
┌────────────────────────────┐
│ socat 10.0.2.1:3080 │ 仅监听 docker 网桥网关
│ (dsh-relay.service) │ 公网路由不到这个地址
└────────────┬───────────────┘
│ TCP 127.0.0.1:3080
▼
┌────────────────────────────┐
│ dsh web 127.0.0.1:3080 │ 仅回环
│ (dsh.service) │ --trusted-host dsh.example.com
└────────────────────────────┘
3080 在公网上不存在(curl http://<公网IP>:3080 超时)。
每一跳为什么存在
为什么不直接 dsh web --host 0.0.0.0
那是最省事的做法,代价是 http://<公网IP>:3080 变成一个明文、无密码的 root 控制台,而且可以绕过反代——前面配的证书和密码全部失效,因为攻击者根本不用走域名。
为什么中间需要 socat
因为两边都动不了:
- dsh 侧:webserver 的配置 schema 只接受
"127.0.0.1"或"0.0.0.0"两个字面量。填网桥地址会被直接拒:invalid config: $.host expected "127.0.0.1" | "0.0.0.0" but got "10.0.2.1" - NPM 侧:它跑在容器里,容器内的
127.0.0.1是容器自己,碰不到宿主机的回环。
于是缝隙在中间:dsh 想留在回环上,NPM 需要一个容器能连到的地址。socat(socket cat)就做一件事——在网桥网关上开一个监听,把字节原样转给回环:
socat TCP-LISTEN:3080,bind=10.0.2.1,fork,reuseaddr TCP:127.0.0.1:3080
fork 让它能并发处理多个连接,reuseaddr 让重启时不被 TIME_WAIT 卡住。它不解析内容,所以 WebSocket 升级和 SSE 流都能透明穿过。
systemd-socket-proxyd 是 systemd 自带的同类工具,可以替换 socat,但收益不大。
网桥地址填哪个
看 NPM 容器所在网络的网关:
docker inspect npm --format '{{range $k,$v := .NetworkSettings.Networks}}net={{$k}} gateway={{$v.Gateway}}{{println}}{{end}}'
本例是 nginx_proxy_manager_default / 10.0.2.1。不要在 NPM 里把转发目标填成公网 IP——从容器出去再绕回来是不通的(实测 upstream timeout),而且如果为了让它通而把 dsh 绑到 0.0.0.0,就回到上面那个把控制台裸露的问题了。
配置步骤
1. dsh:留在回环,信任域名
--trusted-host 不能省。dsh 有一道浏览器信任围栏(防 DNS rebinding):Host 头既不是回环、又不在可信列表里时,/api 请求全部 403 —— 表现是页面能打开但什么都加载不出来。
/etc/systemd/system/dsh.service 关键行:
[Service]
ExecStart=/root/.nvm/versions/node/vXX/bin/dsh web --trusted-host dsh.example.com
# nvm 的 node 不在系统 PATH 上,必须钉死;DSH_HOME 依赖 $HOME
Environment=PATH=/root/.nvm/versions/node/vXX/bin:/usr/local/bin:/usr/bin:/bin
Environment=HOME=/root
# 没有显式 cwd 的定时任务会继承这个目录,别留给启动者
WorkingDirectory=/root
Restart=always
StartLimitIntervalSec=0
用 systemd 而不是 pm2:要守护的两个进程里 socat 不是 Node,pm2 的 cluster/指标全用不上;pm2 startup 本身就是生成一个 systemd unit,等于多叠一层;nvm 的 PATH 问题正是 pm2 常见的"手动能跑、重启就挂"根源;而 relay 依赖 dsh 的顺序关系只有 systemd 能表达。
2. relay:systemd 单元
见仓库同目录的 dsh-relay.service 示例。要点:
After=docker.service dsh.service
Requires=dsh.service
ExecStart=/usr/bin/socat TCP-LISTEN:3080,bind=10.0.2.1,fork,reuseaddr TCP:127.0.0.1:3080
Restart=always
StartLimitIntervalSec=0
After=docker.service 是必须的:网桥地址在 docker 建好网络之前不存在,socat 会绑定失败。
3. ufw:一条容易漏的规则
这是最容易卡住的一步。 如果机器开着 ufw 且 INPUT 默认策略是 DROP:
Default: deny (incoming)
-P INPUT DROP
那么容器访问网关地址的包走的是 INPUT 链(不是 FORWARD),会被默认拒绝。症状很有迷惑性:宿主机自己 curl http://10.0.2.1:3080 返回 200(走回环,不过 INPUT),但容器连不上,NPM 报 upstream timeout。
只放行网桥子网这一跳,不动任何公网规则:
ufw allow from 10.0.2.0/24 to 10.0.2.1 port 3080 proto tcp comment 'NPM -> dsh relay'
顺带一提:ufw 默认拦住 3080 正是这套方案安全性成立的原因之一,别为了图省事去 ufw allow 3080。
4. NPM:三个设置,两个坑
代理主机(Proxy Host):
| 字段 | 值 |
|---|---|
| 域名 | dsh.example.com |
| 协议 / 转发主机 / 端口 | http / 10.0.2.1 / 3080 |
| Websockets 支持 | 必须开 |
| SSL | Let's Encrypt + Force SSL |
| 通信规则(Access List) | 见下 |
坑一:Websockets 默认是关的。 关着的时候 nginx 会丢掉 Upgrade 头,dsh 对 /api/events.mux 返回 426 Upgrade Required,前端无限重试,表现是页面卡在 "Loading plugins..." 或渲染进程被拖死。开启后 vhost 里会出现:
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;
握手成功的标志是访问日志里 /api/events.mux 从 426 变成 101。
坑二:Access List 只填密码不够。 建访问列表时:
- 「满足任意条件」保持关闭(=
satisfy all) - 「授权列表」里加用户名 + 密码
- 「规则」里必须加一条
allow0.0.0.0/0
漏掉最后一条的话,NPM 生成的配置是:
auth_basic "Authorization required";
# Access Rules: 0 total
deny all;
satisfy all;
satisfy all 要求 IP 规则和密码都通过,而零规则会生成 deny all —— 结果是连密码正确都返回 403,且没有 WWW-Authenticate 头。加上 allow 0.0.0.0/0 后 IP 这关放行,密码成为唯一的门。
也可以改用「满足任意条件」(
satisfy any),但语义变成"IP 通过或密码通过任一即可",以后误加一条 allow 规则就等于绕过密码。allow 0.0.0.0/0+satisfy all更稳。IP 白名单在这里不建议加:手机走移动网络 IP 会变,加了会把自己锁在外面,也和"点通知跳转"冲突。
本插件的两处适配
RPC 通道必须是 trusted-host
插件的 RPC 通道原本声明 authority: 'loopback'。dsh 对 loopback 通道的可信主机列表是硬编码空数组:
const trustedHosts = options.authority === "loopback" ? [] : this.trustedHosts;
所以 loopback 通道只认回环的 Host 头,走域名时会被无条件 403 —— dsh 本体能打开,但定时任务面板全挂。改成 authority: 'trusted-host' 后才会去查 --trusted-host 声明的域名。
这一条已经修在代码里(lib/index.js),走隧道时行为不变(回环主机名无条件通过)。
通知跳转要填公网地址
「通知设置」→「Web 界面地址」填 https://dsh.example.com。推送里的链接是 <base>/?dsst-session=<id>,手机必须能访问这个地址——填 127.0.0.1 时手机上点开指向的是手机自己。
验证清单
# 1) 未授权必须被挡(401 + 挑战头,不是 403)
curl -sS -o /dev/null -D - https://dsh.example.com/ | grep -iE '^HTTP/|www-authenticate'
# 2) 错误密码也是 401
curl -sS -o /dev/null -w '%{http_code}\n' -u wrong:wrong https://dsh.example.com/
# 3) 3080 在公网上必须不可达(超时或拒绝)
curl -sS -m 8 -o /dev/null -w '%{http_code}\n' http://<公网IP>:3080/
# 4) WebSocket 握手成功:应看到 101,而不是 426
docker exec npm sh -c 'tail -50 /data/logs/proxy-host-3_access.log' | grep events.mux
# 5) 插件 RPC 经域名可用:应为 200
docker exec npm sh -c 'tail -50 /data/logs/proxy-host-3_access.log' | grep rpc-scheduled-tasks
# 6) 两个单元都 enabled,崩溃能自愈
systemctl is-enabled dsh dsh-relay
systemctl kill dsh-relay && sleep 5 && systemctl is-active dsh-relay
已知限制
/api/settings.describe和/api/credentials.describe经域名返回 403,而其余/api/*全部 200。这个"只有这两个被拒"的模式排除了反代配置问题,推测是 dsh 有意把设置与凭据这类敏感端点限制为仅回环 —— 未在源码中定位到该判断,属推断。实际影响:走域名时「设置」页的模型/凭据部分不可用,要改这些配置请走 SSH 隧道访问127.0.0.1:3080。- Basic Auth 是唯一的门,密码强度就是这台机器的安全底线。dsh 没有用户体系,拿到密码等于拿到 root。
- 反代只解决"谁能连进来"。连进来之后权限不受限 —— 定时任务用什么权限预设跑,见 README 的「无人值守的权限」一节。
- 想彻底不暴露公网,Tailscale / ZeroTier 这类组网是更小的攻击面,代价是每个客户端都要装。