背景
我写了一个 JumpServer CLI 客户端 (jms-cli),通过 WebSocket 连接 JumpServer KOKO 终端。连接在大约 15-20 秒后必定断开,困扰了我很长时间。
KOKO 每 5 秒发送 WebSocket ping,客户端必须及时回复 pong。通过 pcap 抓包分析,发现 pong 响应延迟达到 3-13 秒,服务端判定连接死亡后主动断开。
网络拓扑
家里的网络拓扑:
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
node "开发机 .52\n(Linux)" as dev
node "Mac Mini .26" as mac
node "OPNsense .1\n(路由器/防火墙)" as opn
node "VPN 节点 .24\n(ppp0 MTU 1354)" as vpn
cloud "JumpServer\n10.21.250.233" as jms
dev --> opn : default route
mac --> opn : Tailscale\n回家后走 OPNsense
opn --> vpn : pf route-to\nhairpin igb3
vpn --> jms : ppp0 VPN
vpn --> dev : 返回直接 L2\n(不经过 OPNsense!)
@enduml
关键问题:非对称路由。去程经过 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.2410.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 │
修复后的路径完全对称:
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
node "开发机 .52" as dev
node "VPN 节点 .24" as vpn
cloud "JumpServer" as jms
dev -> vpn : 直接 L2\n(同子网)
vpn -> jms : ppp0 VPN
jms -> vpn : ppp0 VPN
vpn -> dev : 直接 L2\n(同子网)
@enduml
额外改进:自动重连
作为安全网,在 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 守护进程管理,会自动恢复。
最终架构
@startuml
!theme plain
skinparam backgroundColor #FEFEFE
node "LAN 设备\n(DHCP)" as lan
node "OPNsense .1\n(DHCP Server)" as opn
node ".21 vpn\n(静态 IP)" as vpn21
node ".24 FortiClient\n(静态 IP)" as vpn24
cloud "中国内网\n10.128.x.x" as cn
cloud "美国内网\n10.15/10.21/10.28" as us
opn --> lan : DHCP Option 121\n10.15/16,10.21/16,10.28/16 → .24
lan --> vpn24 : 直接 L2
vpn21 -[hidden]-> opn
opn -[hidden]-> vpn24
vpn21 --> cn : tun0 OpenVPN
vpn24 --> us : ppp0 FortiVPN
note bottom of vpn21 : 静态 IP\n不受 DHCP 路由影响
note bottom of vpn24 : 静态 IP\n不受 DHCP 路由影响
@enduml
教训
- 先看 pcap 再猜。如果一开始就分析包大小,可以直接排除 MTU/MSS 假设
- SOCKS5 能修 ≠ MTU 问题。SOCKS5 改变的是 TCP 路径,不只是包大小
- 非对称路由是有状态防火墙的天敌。pf/iptables 的 conntrack 依赖看到双向流量
- 策略路由 + hairpin 是隐蔽的性能杀手。包从同一接口进出,每个包都要过防火墙状态机
- 同子网设备之间的流量不需要经过路由器。加一条静态路由就能完全绕过
- 网络基础设施节点应使用静态 IP。VPN 网关等特殊设备不应依赖 DHCP,避免被 Option 121 等策略路由干扰自身的隧道路由