把 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 端口"这件事尽可能难。具体是两条:

  1. dsh 的端口从不出现在公网网卡上
  2. 唯一入口(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 支持必须开
SSLLet'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
  • 「授权列表」里加用户名 + 密码
  • 「规则」里必须加一条 allow 0.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 这类组网是更小的攻击面,代价是每个客户端都要装。