从"能跑"到"敢一直开着":心跳、重连与握手自检
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 转非零退出 |
六、小结
一句话概括 v4:从"能跑"到"敢一直开着",差的就是四件事——死了知道,死了会换,换不动会喊人,生出来先自检。
做的时候最舒服的一点是:PING/PONG 帧类型 v2 就定义好了,Session 的对称性 v2 就做全了——提前把结构做对称,后面加东西是一行一行地补,不是一次一次地重构。
下一步是 v5:反向转发与控制面。v2 埋的对称性会在那里兑现——服务端 Open、客户端 Accept,隧道从"单向往外捅"变成"双向互通"。
最后更新:2026-11-02