SSH ControlMaster 复用引发的「幽灵监听」:-L 端口转发生命周期详解

八月 24, 2026 [Linux 与系统] #SSH #systemd #调试 #port-forwarding

现象:一个"杀不掉"的端口监听

我有个 dsh-proxy.sh,职责很单纯:用 systemd-run --user 起一条 ssh 隧道,把远端机器的 3080 端口暴露到本机 127.0.0.1:3080(远端 caddy + dsh web 的 basicauth HTTPS 入口,浏览器 origin 必须落在 loopback 才能操作配置平面)。

某天它开始闹鬼,三个症状互相矛盾:

$ dsh-proxy.sh start
本地端口 3080 已被占用(可能本机有 dsh 在跑?),中止启动

$ dsh-proxy.sh stop
unit 未在运行

$ lsof -i:3080 -nP | grep LISTEN
ssh      206121 peter  16u  IPv4 ... TCP 127.0.0.1:3080 (LISTEN)

stop 停不掉、start 起不来、端口还被一个"不属于任何 unit"的进程占着——这到底是谁?

排查:证据链

1. unit 确实不存在了

$ systemctl --user status dsh-proxy --no-pager
Unit dsh-proxy.service could not be found.

unit 是 systemd-run --collect 起的临时 unit,进程退出后会被立即回收。所以 unit 存在过、但它的进程已经死了——这是 --collect 的预期行为。

2. 监听进程不是隧道的 ssh,而是 mux master

$ ps -fp 206121
UID   PID  PPID  ...  CMD
peter 206121 3559 ...  ssh: /home/peter/.ssh/socket/user@192.168.1.10-22 [mux]

命令行的 [mux] 是关键词:这是一个 ControlMaster 复用 master 进程。它 13:21 就存在了——比隧道 19:44 的启动早得多。查 ~/.ssh/config:

$ grep -nE "ControlMaster|ControlPath|ControlPersist" ~/.ssh/config
52:  ControlMaster auto
53:  ControlPath ~/.ssh/socket/%r@%h-%p
54:  ControlPersist 1h

全局开着 ControlMaster,任何到同一主机的 ssh 都会走复用。

3. master 上还挂着别的活

$ ss -tnp | grep 206121
ESTAB 127.0.0.1:13081 <- 127.0.0.1:47262  ssh(206121)   # -D 动态转发(SOCKS)
ESTAB 192.168.1.20:60688 -> 192.168.1.10:22 ssh(206121) # master 到远端的真实连接

这个 master 是 VS Code Remote-SSH 建的(-D SOCKS + 会话),正被实时使用着。→ 不能杀 master 释放端口,一杀 VS Code 的远程会话就断。

4. journal 里只有一条 Started

$ journalctl --user -u dsh-proxy --no-pager
Started dsh-proxy.service - /usr/bin/ssh -N -L 3080:127.0.0.1:3080 ... -- user@192.168.1.10.

隧道确实启动过,之后 unit 干净消失(无 Stopped、无失败重启日志,--collect 的典型表现)。

证据链闭环:unit 的 ssh 进程死了,但 3080 的监听还活在一个别人家的 mux master 进程里。

根因:mux 复用下,-L 转发的生命周期不在 client 手里

OpenSSH Connection Multiplexing 机制

当 ControlMaster auto + 已有同主机 master 时,新的 ssh -N -L 3080:... 不会自己建 TCP 连接,而是变成 master 的 mux client:通过本地 unix socket(~/.ssh/socket/%r@%h-%p)把请求发给 master,包括「帮我监听 3080」:

              unix socket                      TCP:22
systemd-run ssh ──────────► mux master 206121 ────────► remote
  (client, unit进程)         (VS Code 建的 master)      (真实连接)
                              │
                              └─► bind()/listen() 127.0.0.1:3080  ← 监听在 master 上!

于是 真正 listen() 3080 的是 master 进程——这就是为什么 lsof 的 owner 是 master 而不是 unit 的进程。

关键语义:client 退出 ≠ 转发撤销

OpenSSH 的 mux 转发由 master 统一持有:client 断开后,master 不会自动撤销它注册的转发(master 无法判断还有没有别的 client 在依赖它)。加上 ControlPersist 1h(且 VS Code 一直占用 master),master 会一直活着:

19:44:34  unit 启动,ssh 加入 master,master listen 3080
19:44:3x  unit 进程退出(任何原因:stop、断链、干净退出)
         --collect 回收 unit,systemctl 查无此 unit
         master 继续活着,3080 监听继续挂着 ← 幽灵监听诞生
之后      stop 报"未在运行",start 报"端口被占用"

端口要等 master 进程死亡才释放——而 VS Code 不退出,master 永不退出。死锁。

这个 bug 的普适性值得记住:凡是"进程 A 替进程 B 干了一件事(监听端口、持有资源),但资源生命周期的开关却在 A 手里"的场景,都会出现这种幽灵资源。mux 复用的性能收益(免握手、共享连接)换来的就是这个副作用。

手术刀:ssh -O cancel 只摘转发,不动 master

杀 master 是钝刀子(VS Code 会话会断)。正确工具是 control command:

$ ssh -O cancel -L 3080:127.0.0.1:3080 user@192.168.1.10

-O 命令走的是本地 unix socket(ControlPath),不发网络包、不触发认证,只是请求 master「撤销 3080 这条转发」。master 关掉那个监听 socket,但 TCP 连接、-D SOCKS、其它 client 一概不受影响:

$ lsof -i:3080 -nP | grep LISTEN   # 没了
$ ssh -O check user@192.168.1.10
Master running (pid=206121)          # master 还活着,VS Code 毫发无损

实测瞬时生效。这相当于给共享 master 上的转发做了一次外科手术。

脚本修复:双保险

只手动 cancel 一次治标不治本,修脚本:

1. 根治:隧道 ssh 独占连接

SSH_OPTS=(
    -N
    -L "3080:127.0.0.1:3080"
    -o ControlMaster=no        # 不建 master
    -o ControlPath=none        # 不连任何 master
    -o ExitOnForwardFailure=yes
    ...
)

ControlMaster=no + ControlPath=none 让隧道的 ssh 永远是独立连接:转发由自己进程持有,systemctl stop 杀进程即释放端口,unit 生命周期 = 端口生命周期,不再有第三方。

2. 兜底:start / stop 里的 cancel 自愈

历史遗留(或别的脚本复用 mux 挂了转发)时,start 遇到 ssh 进程占着端口就尝试 -O cancel 摘掉再继续,摘不掉才报错;stop 停完 unit 后再检查一次端口,发现 ssh 残留就顺手 cancel:

port_owner() {
    ss -ltnp 2>/dev/null | awk -v p=":3080" '$4 ~ p"$"' |
        sed -nE 's/.*users:\(\("([^"]+)".*/\1/p' | head -1
}

# stop 流程中:
case "$(port_owner)" in
    ssh) ssh -O cancel -L "3080:127.0.0.1:3080" "$REMOTE" ;;
    "")  ;;   # 本来就干净
    *)   echo "端口被非 ssh 进程占用,未处理" ;;
esac

验证过完整链路:start → 3080 归 unit 自有 ssh 进程(curl 过隧道得到 401 = basicauth 质询,转发功能正常)→ stop → 端口完全释放、master 全程未动。

总结

层教训
ssh共享 mux master 是性能利器,但 -L 转发的生命周期挂在 master 上,client 退出不等于转发撤销;ssh -O cancel 是唯一不伤 master 的摘除手段
systemd别让托管进程把关键资源(端口、socket)注册到不受 systemd 管辖的进程上,否则 stop/start 语义全部失真
排查端口被"幽灵进程"占着时先看进程命令行:[mux] 标记 = 复用 master,立刻去查 ~/.ssh/socket/ 下对应 ControlPath 的归属
预防需要 systemd 严格管理生命周期的隧道,一律 -o ControlMaster=no -o ControlPath=none 独占连接;万不得已共享时,预留 -O cancel 清理路径