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)
start说端口被占用 → 确实有 ssh 在监听 3080stop说 unit 根本没在运行 → systemd 层面查无此 unit- 但那个 ssh 进程确实活着,且监听端口就是它
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 清理路径 |