跳转至

从"能跑"到"敢一直开着":心跳、重连与握手自检

v3 收官时 minibridge 会加密、会认证、还管住了 SSRF。但它是温室里的花:连接一空闲几分钟就悄悄失效,服务端进程一重启客户端就装死到天荒地老。上篇结束时说"客户端断线不重连、没有心跳——留给 v5",真做的时候发现这两件事压不到 v5:一个不敢一直开着的隧道,等于没做。

一、引子:两条"死了都不知道"的连接

两个现象,都来自真实跑挂:

现象一:静置约 10 分钟,连接悄悄死掉。 客户端没有任何报错,直到下一次 Open 才发现会话早就没了。中间设备(家用路由的 NAT、云厂商安全组)会回收长时间没流量的连接表项——注意是"回收它的记账",不是"掐断你的连接"。两端的 TCP 栈毫发无损,谁也收不到任何信号。

现象二:kill 服务端,客户端永远不自愈。 会话死了客户端不知道,本地端口还在 accept,每条新连接都在日志里刷 open stream failed ... session closed。恢复办法只有一个:手工重启客户端。

于是 v4 的范围定了,四件事,全是止血:

# 事项 治什么
1 应用层心跳 PING/PONG NAT 回收空闲连接
2 死连接检测 + 自动重连 会话死了不自愈
3 连续失败退出进程 重连失败时静默装死
4 make verify 真握手自检 SAN 与实际地址不匹配

验收标准提前写死,做完能自己验:静置 30 分钟仍能 Open;kill 服务端后客户端自动恢复;重连 8 次失败 → 非零退出码;make verify 用生成的证书真握一次手。

二、心跳:TCP 自己的保活救不了你

第一个要澄清的误区是"TCP 不是有 keepalive 吗"。有,但指望不上:

  • NAT/安全组回收的是 它自己的转发表项,两端的 TCP 栈毫发无损——没有 RST,没有 FIN,谁都不报错
  • 路径真断掉时,写侧往往还能成功写进内核缓冲区,读写双双"看起来正常"
  • TCP keepalive 默认 2 小时才起探,改 sysctl 是全局动作,不该为一个应用去动

所以唯一可靠的办法是应用层 发了要等回:定时发一帧 PING,等对端的 PONG,等不到就数拍子。判死不靠读写出错,靠 往返超时。

// 30s 一拍;单拍 10s 内没等到 PONG 算丢一拍;连丢 3 拍判死(约 60~90s)
keepAliveInterval = 30 * time.Second
keepAliveTimeout  = 10 * time.Second
keepAliveMaxMiss  = 3

30s 这个数是冲着 NAT 的回收周期去的(常见 5~10 分钟),一帧 9 字节,代价可以忽略。核心循环:

func (s *Session) KeepAlive(interval, timeout time.Duration) {
    // 丢掉可能残留的旧 PONG,否则会拿上一次的结果冒充这一次
    select {
    case <-s.hbCh:
    default:
    }

    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    miss := 0
    for {
        select {
        case <-s.doneCh:
            return
        case <-ticker.C:
            // 写失败说明连接已经在报错,不必等满三拍
            if err := s.writeFrame(frame{typ: framePING}); err != nil {
                _ = s.Close(err)
                return
            }
            select {
            case <-s.hbCh:
                miss = 0
            case <-s.doneCh:
                return
            case <-time.After(timeout):
                miss++
                if miss >= keepAliveMaxMiss {
                    _ = s.Close(ErrKeepAliveTimeout)
                    return
                }
            }
        }
    }
}

坑:心跳和 Handshake 会互相偷帧

v3 的 Handshake 已经在用 PING/PONG——mTLS 下服务端对客户端证书的裁决发生在握手之后,要靠一颗 PING 把它逼出来。两件事都等 PONG,如果共用一个通知通道,心跳会拿 Handshake 那颗旧 PONG 冒充本次探测,连接死了要多花一整拍才发现。

解法是两个动作:

  • 通道分开:pongCh 归 Handshake,hbCh 归心跳;收到 PONG 时两边各投一份(容量 1、非阻塞、没人等就丢)
  • 顺序约束:心跳排在 Handshake 之后启动,且启动时先清一次 hbCh 残留

另外两端都要发。会话死了不能等客户端来告诉你——服务端挂着一堆死会话同样是泄漏。好在 v2 就把 Session 做成了对称的,两边各加一行 go sess.KeepAlive(...) 就完事。

三、重连:Session 不会复活

判死只解决一半。另一半是:一条 TLS 连接断了就是断了,Session 不会复活——唯一的恢复方式是重拨、重跑握手、重建会话。

结构上引入一个很薄的 tunnel:持"当前会话",转发方永远从 current() 拿最新:

func (t *tunnel) reconnectLoop(dial func() (*Session, error), initial time.Duration) error {
    for {
        sess := t.current()
        <-sess.Done() // 等当前会话死:断开、判死、主动 Close 都会响
        cause := sess.Close(nil) // Close 幂等且保留首次原因,取死因记日志
        log.Printf("tunnel session dead, reconnecting, cause=%v", cause)

        backoff := initial
        for attempt := 1; ; attempt++ {
            sess, err := dial()
            if err == nil {
                t.set(sess)
                log.Printf("tunnel reconnected, attempt=%d", attempt)
                break
            }
            if attempt >= maxReconnectAttempts {
                return fmt.Errorf("reconnect failed after %d attempts: %w", attempt, err)
            }
            time.Sleep(backoff)
            backoff = min(backoff*2, maxBackoff) // 1s 起步,封顶 30s
        }
    }
}

Done() 是整个重连体系唯一的接入口——P0-1 的心跳判死、读循环出错、主动 Close,全都汇到这个 channel 上,重连循环只需要守着它。三个设计决定值得展开:

立刻试第一把,失败再退避。 kill 服务端再拉起这种短故障,第一把重试通常就命中;退避是给长故障准备的,1s 起步指数翻倍封顶 30s——恢复要快,但服务端长宕机时也别把对端打爆。

重连期间新到的本地连接直接拒绝,不排队。 换会话的瞬间可能拿到已死的旧会话,Open 会以 ErrSessionClosed 失败——拒绝这一条,下一条自然走新会话。排队意味着等待队列和超时管理,复杂度配不上收益:用户侧重试是现成的。

连丢 8 次就退出进程,交给 systemd。 与其在进程里静默装死,不如非零退出让 supervisor 拉起——重启会重读配置、重跑首连,fd 和 goroutine 全部归零。注意重连循环只是 返回错误,main 里才转 log.Fatalf:退出是进程边界的事,留在可测试的函数外面。

两个配套细节:

  • dial 用 tls.DialWithDialer 挂 5s 超时。用裸 tls.Dial 的话,遇到被防火墙黑洞的地址,重连尝试会无限挂着,退避形同虚设
  • 首连失败仍然 Fatal。启动期的错误(证书错、地址错)重试无意义,要立刻暴露;重连只管"用着用着死了"

四、make verify:链校验证明不了 SAN

最后一条治的是个反复踩的坑:证书的 SAN 和实际拨号地址不匹配。症状是客户端握手报 x509: certificate is valid for ...,排查半天才发现生成证书时 SRV_SAN 没写对。

原来的 make verify 是纯静态检查:openssl verify 只能证明"链是对的",而 SAN 覆盖不覆盖实际地址是另一个维度。于是给 verify 加了真握手——起一个临时服务端,openssl s_client 拿全套证书真握一次 mTLS,两个方向都断言:

  • 客户端方向:输出里有 Verify return code: 0 (ok)——链、SAN(-verify_ip)、serverAuth 用途全过
  • 服务端方向:日志里出现 session closed。这行日志是 应用层真的见到这条连接 的证据——客户端证书若被拒,crypto/tls 在 Accept 之前就把连接丢了,应用层一行日志都不会有

负向实验证明它真能抓坑:

$ make certs SRV_SAN='DNS:wrong.example' && make verify
Verify return code: 64 (IP address mismatch)
握手失败:客户端没验过服务端证书(先查 SRV_SAN 是否覆盖 VERIFY_IP)
make: *** [Makefile:105: verify] Error 1

部署到别的地址时 make verify VERIFY_IP=10.0.0.5 就能模拟远端视角的校验,不用真把服务端搬过去。

五、实测

kill 服务端再重启,客户端日志完整还原了恢复路径:

14:35:58 crypto tunnel ready, server=127.0.0.1:9100, auth=mtls
14:35:59 forward finished, id=1, ..., err=<nil>          ← curl 200
14:35:59 tunnel session dead, reconnecting, cause=<nil>   ← kill 服务端
14:35:59 tunnel reconnect failed, attempt=1, backoff=1s   ← 第一把太快,服务端还没回来
14:36:00 tunnel reconnect failed, attempt=2, backoff=2s
14:36:02 tunnel reconnected, attempt=3                    ← 自动恢复
14:36:03 forward finished, id=1, ..., err=<nil>          ← curl 200,全程 3 秒

心跳的"不误杀"另有一验:闲置 32 秒(跨过一个心跳拍)后再 curl 仍然 200——PING 发出去、PONG 回来了,会话判活。

单测把两个关键路径钉死,毫秒级节奏跑完,不用等真实世界的 30 秒一拍:

用例 守住什么
对端装死不回 PONG 3 拍内 Done() 关闭,死因是 ErrKeepAliveTimeout
对端自动回 PONG 永不误杀(含 Handshake 残留 PONG 被清掉的路径)
kill 对端会话 自动换上新会话,无需干预
拨号永远失败 恰好 8 次后放弃返回错误,由 main 转非零退出
cd example/2026/10/27 && go test -count=1 -race ./...   # 四阶段全绿

六、小结

一句话概括 v4:从"能跑"到"敢一直开着",差的就是四件事——死了知道,死了会换,换不动会喊人,生出来先自检。

做的时候最舒服的一点是:PING/PONG 帧类型 v2 就定义好了,Session 的对称性 v2 就做全了——提前把结构做对称,后面加东西是一行一行地补,不是一次一次地重构。

下一步是 v5:反向转发与控制面。v2 埋的对称性会在那里兑现——服务端 Open、客户端 Accept,隧道从"单向往外捅"变成"双向互通"。


最后更新:2026-11-02

评论