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

Kiyor
2026年02月24日 09:35
Show TOC

背景

我写了一个 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 时与其他写操作竞争锁。做了多轮优化:

  1. Reader/Processor 拆分 — reader 只做 ReadMessage→channel
  2. 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):

  1. Services > Kea DHCPv4 > [LAN]
  2. 找到 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/1610.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

教训

  1. 先看 pcap 再猜。如果一开始就分析包大小,可以直接排除 MTU/MSS 假设
  2. SOCKS5 能修 ≠ MTU 问题。SOCKS5 改变的是 TCP 路径,不只是包大小
  3. 非对称路由是有状态防火墙的天敌。pf/iptables 的 conntrack 依赖看到双向流量
  4. 策略路由 + hairpin 是隐蔽的性能杀手。包从同一接口进出,每个包都要过防火墙状态机
  5. 同子网设备之间的流量不需要经过路由器。加一条静态路由就能完全绕过
  6. 网络基础设施节点应使用静态 IP。VPN 网关等特殊设备不应依赖 DHCP,避免被 Option 121 等策略路由干扰自身的隧道路由
AI Smart Recommendations
Based on Semantic Similarity

AI is analyzing article content to find similar articles...

More Articles

View more exciting content

About Blog

Tech sharing and life insights