排查 WebSocket 断线：OPNsense 策略路由引发的非对称路径问题

2026-02-24

背景

我写了一个 JumpServer CLI 客户端 (jms-cli)，通过 WebSocket 连接 JumpServer KOKO 终端。连接在大约 15-20 秒后必定断开，困扰了我很长时间。

KOKO 每 5 秒发送 WebSocket ping，客户端必须及时回复 pong。通过 pcap 抓包分析，发现 pong 响应延迟达到 3-13 秒，服务端判定连接死亡后主动断开。

网络拓扑

家里的网络拓扑：

关键问题：非对称路由。去程经过 OPNsense pf 防火墙，回程直接走 L2 绕过了它。

排查过程

错误假设一：MTU/MSS

VPN 隧道 ppp0 的 MTU 只有 1354，自然怀疑是 MTU 问题导致大包被丢弃。

在 Go 客户端中实现了 TCP_MAXSEG socket option，通过 net.Dialer.Control 在 SYN 阶段就 clamp MSS：

func mssControl(mss int) func(string, string, syscall.RawConn) error {
return func(network, address string, c syscall.RawConn) error {
return c.Control(func(fd uintptr) {
syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP,
syscall.TCP_MAXSEG, mss)
})
}
}

即使将 MSS 压到 1000，依然断线。分析 pcap 才发现：所有数据包只有 30-76 字节，远低于任何 MTU 限制。WebSocket ping/pong 帧本身就很小，MSS 根本不是问题。

错误假设二：Gorilla WebSocket Ping/Pong 处理

怀疑是 gorilla/websocket 的 PingHandler 在 ReadMessage 内部回复 pong 时与其他写操作竞争锁。做了多轮优化：

Reader/Processor 拆分 — reader 只做 ReadMessage→channel

Channel 信号方案 — PingHandler 只发信号，独立 goroutine 串行化 WriteControl

这些优化让代码更健壮，但断线问题依旧。

SOCKS5 Workaround

配置 SOCKS5 代理后连接稳定了：

{
"proxy": "socks5://192.168.10.24:1080"
}

SOCKS5 改变了 TCP 路径：.52 → .24（本地网络）建立一个 TCP 连接，.24 → JumpServer 建立另一个。两段都是对称路由，没有问题。

这个线索说明问题在网络路径上，不在应用层。

找到根因：OPNsense pf 策略路由

SSH 到 OPNsense 检查 pf 规则：

scrub on igb3 all max-mss 1300 fragment reassemble

pass in quick on igb3 route-to (igb3 192.168.10.24) inet
from ! 192.168.10.24 to <us> flags S/SA keep state

<us> alias 包含 10.15.0.0/16, 10.21.0.0/16, 10.28.0.0/16。

这条规则造成了两个问题：

1. Hairpin 路由

数据包从 igb3 进入 OPNsense，经过 pf 处理后又从 igb3 发回给 .24。每个包都要经过防火墙的 scrub（fragment reassemble）和状态跟踪。

2. 非对称路由 + pf 状态跟踪冲突

去程：.52 → OPNsense(igb3) → pf scrub+state → hairpin igb3 → .24 → VPN → JumpServer
回程：JumpServer → VPN → .24 → 直接 L2 → .52（不经过 OPNsense）

pf 的 keep state 创建双向状态表，但只能看到去程的包。TCP 序列号校验在只看到单向流量时会出问题，导致间歇性丢包。一个 35 字节的 pong 回复被延迟 3-13 秒，完全是因为在 OPNsense 上被 pf 状态检查阻塞或丢弃后重传。

同时验证了 .24 本身的配置完全正确：

# .24 的 iptables — 没有问题
iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu # 双向
iptables -t nat -A POSTROUTING -o ppp0 -j MASQUERADE
sysctl net.ipv4.ip_forward = 1
# ppp0 接口：0 errors, 0 drops

修复

绕过 OPNsense，让客户端直接在 L2 层发给 .24（同一子网 192.168.10.0/24）：

Linux (.52)：

# 临时
sudo ip route add 10.21.0.0/16 via 192.168.10.24

# 固化（NetworkManager）
sudo nmcli connection modify "Wired connection 1" +ipv4.routes "10.21.0.0/16 192.168.10.24"

OPNsense DHCP 推送（推荐，覆盖所有 LAN 设备）：

单机固化（nmcli / LaunchDaemon）对固定的 LAN 设备有效，但对于通过 Tailscale 漫游的设备（如 Mac Mini），静态路由 via 192.168.10.24 在外网时无法直达 .24，会与 Tailscale 的 utun 路由冲突。

更好的方案是通过 OPNsense DHCP 推送 RFC 3442 Classless Static Routes (Option 121)，这样：

LAN 设备通过 DHCP 自动获取路由，在家时走 .24 直连

漫游设备在外网时，Tailscale 的路由优先级更高，走 Tailscale 自己的路径

不需要每台设备单独配置

OPNsense GUI 配置路径（Kea DHCP）：

Services > Kea DHCPv4 > [LAN]

找到 Classless static routes 字段，填入：

10.21.0.0/16 - 192.168.10.24

多条路由用逗号分隔：

10.15.0.0/16 - 192.168.10.24, 10.21.0.0/16 - 192.168.10.24, 10.28.0.0/16 - 192.168.10.24

注意：Option 121 只对通过 DHCP 获取 IP 的设备生效。静态 IP 的服务器不走 DHCP 流程，不会收到这条路由，需要在机器上单独配置。如果既要固定 IP 又要收 Option 121，可以用 DHCP Reservation（MAC 绑定 IP）替代静态配置。

Option 121 编码说明：

10 → /16 前缀长度
0a:15 → 10.21（网络地址，只需 ceil(16/8)=2 字节）
c0:a8:0a:18 → 192.168.10.24（网关）

如果需要推送多条路由（比如同时推 10.15.0.0/16 和 10.28.0.0/16），直接拼接：

10:0a:0f:c0:a8:0a:18:10:0a:15:c0:a8:0a:18:10:0a:1c:c0:a8:0a:18
│ 10.15/16 via .24 │ 10.21/16 via .24 │ 10.28/16 via .24 │

修复后的路径完全对称：

额外改进：自动重连

作为安全网，在 jms-cli 中实现了自动重连机制：

区分用户主动退出（~.、Ctrl+C×2、服务端 CLOSE）和网络断线

网络断线时自动重连，指数退避（2s, 4s, 8s, 10s），最多 5 次

每次重连重新申请 connection token

var errDisconnected = errors.New("disconnected")

// ConnectTerminal 中用 atomic 标记退出原因
var userExit atomic.Bool
var serverClose atomic.Bool

// 断线时返回 errDisconnected 触发重连
if !userExit.Load() && !serverClose.Load() {
return errDisconnected
}

后续：DHCP Option 121 与特殊设备的冲突

问题

DHCP Option 121 推送上线后，发现 .21（OpenVPN 网关 vpn）访问 公司内网设备超时。

排查发现 DHCP 推送了一条错误路由：

CLASSLESS_ROUTES=...10.128.0.0/16,192.168.10.21...

10.128.0.0/16 的下一跳指向了 .21 自己，形成环路。这个网段本应走 OpenVPN 隧道（tun0 → 172.16.81.1），但 DHCP 路由优先级更高，覆盖了 VPN 推送的路由。

根因

.21 和 .24 是网络基础设施节点（VPN 网关），它们不应该接收 DHCP 推送的策略路由：

设备

角色

问题

.21 (vpn)

OpenVPN 客户端，通过 tun0 连接中国内网

DHCP 路由覆盖了 VPN 路由，10.128.0.0/16 无法通过隧道到达

.24

FortiClient VPN，通过 ppp0 连接美国内网

它本身就是 Option 121 的网关目标，不应再收到这些路由

修复

将两台设备从 DHCP 改为静态 IP，彻底脱离 DHCP Option 121 的影响。

.21 (vpn)：

# /etc/netplan/50-cloud-init.yaml
network:
ethernets:
ens18:
addresses:
- 192.168.10.21/24
routes:
- to: default
via: 192.168.10.1
nameservers:
addresses:
- 192.168.10.1
dhcp4: false
version: 2

同时禁用 cloud-init 网络管理：

echo 'network: {config: disabled}' | sudo tee /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg
sudo netplan apply
sudo systemctl restart openvpn@vpn # netplan apply 会中断 tun0，需重启 VPN

.24 (FortiClient)：

# /etc/netplan/00-installer-config.yaml
network:
ethernets:
ens18:
addresses:
- 192.168.10.24/24
routes:
- to: default
via: 192.168.10.1
nameservers:
addresses:
- 192.168.10.1
dhcp4: false
version: 2

注意：netplan apply 会重置网络接口，导致 VPN 隧道断开。OpenVPN 需要手动重启；FortiClient 的 ppp0 由 FortiClient 守护进程管理，会自动恢复。

最终架构

教训

先看 pcap 再猜。如果一开始就分析包大小，可以直接排除 MTU/MSS 假设

SOCKS5 能修 ≠ MTU 问题。SOCKS5 改变的是 TCP 路径，不只是包大小

非对称路由是有状态防火墙的天敌。pf/iptables 的 conntrack 依赖看到双向流量

策略路由 + hairpin 是隐蔽的性能杀手。包从同一接口进出，每个包都要过防火墙状态机

同子网设备之间的流量不需要经过路由器。加一条静态路由就能完全绕过

网络基础设施节点应使用静态 IP。VPN 网关等特殊设备不应依赖 DHCP，避免被 Option 121 等策略路由干扰自身的隧道路由