隧道掉头:反向转发、DoS 封顶与单张证书吊销
上篇结尾预告了 v5:服务端 Open、客户端 Accept,隧道从"单向往外捅"变成"双向互通"。这篇兑现它,顺带补上生产化最后一块砖:单张证书吊销——mTLS 体系里一直缺位的那一环。
一、引子:终于轮到"往回捅"
前四篇的隧道都是单向的:客户端在内网,主动连到公网服务端,把内网服务"捅"出去。但真实需求经常反过来:内网机器不能被公网访问,却要能对外提供服务。
SSH 的答案是 -R。自研隧道的答案在 v5 之前一直欠着——不是做不了,是做之前先想清楚一件事:
反向转发不是新协议,是同一条数据面换了个方向用。 v2 设计 Session 时把它做成了完全对称的:谁 Open、谁 Accept,角色不写死。这个"提前埋的对称性"在 v5 兑现。
二、反向转发:-bind 的一表两读,数据面零新增
接口设计浓缩成 -bind 的一表两读,正反向共用同一个参数,-reverse 只是方向开关:
// 正向:-bind 90:127.0.0.1:80 本端监听 :90,服务端拨 127.0.0.1:80
// 反向:-reverse -bind 90:127.0.0.1:80 服务端监听 :90,本端拨 127.0.0.1:80
//
// 数据面两边走的是同一段代码——Session 本来就是对称的(v2 埋的),
// 区别只在谁 Open、谁 Accept。
数据面的全部新代码,就是服务端上这一段 bridge——收一条用户连接,Open 开回客户端,客户端 Accept 后按 target 拨号再桥接:
// bridge 把一条用户连接经反向流接到客户端身边的目标。
//
// 与正向唯一的区别是方向:这里由服务端 Open、客户端 Accept——
// 帧和语义与 v2 完全相同,对称性在这里兑现。
func (rm *reverseManager) bridge(bind string, conn net.Conn, target string) {
st, err := rm.sess.Open(target)
if err != nil {
log.Printf("reverse open failed, bind=%s, target=%s, err=%v", bind, target, err)
_ = conn.Close()
return
}
log.Printf("reverse accepted, bind=%s, remote=%s, id=%d", bind, conn.RemoteAddr(), st.ID())
in, out, berr := bridge(conn, st)
log.Printf("reverse finished, bind=%s, target=%s, user_to_target=%d, target_to_user=%d, err=%v",
bind, target, in, out, berr)
}
rm.sess.Open(target)——服务端主动开流,这是整段里唯一"新"的调用
高亮那行之外,bridge(conn, st) 就是 v1 写的双向拷贝。v5 对数据面的净贡献是 Open 的一次调用加两行日志。
对称性的红利还不止省代码:正反向走同一段转发逻辑,意味着 v4 的心跳判死、会话热切换对反向流同样生效,一行不用改。
三、控制面:会话资产与声明式重放
数据面便宜,控制面才是 v5 的真正工作量的所在。两个设计决定撑起它。
决定一:服务端的监听是"会话资产"。 一个 bind 只属于一个会话,控制状态是 per-session 的——会话一死,服务端把它名下的监听全部关掉,否则端口泄漏,而且谁也说不清那个端口还通向哪:
// run 是反向监听的控制循环:收一条、办一条、回一条。
// 一个 bind 只属于一个会话,后来者会拿到 address already in use。
func (rm *reverseManager) run() {
go func() {
<-rm.sess.Done()
rm.closeAll() // 会话死亡 → 名下监听全部清零
}()
for {
select {
case msg := <-rm.sess.Ctl():
var err error
if msg.unlisten {
err = rm.unlisten(msg.bind)
} else {
err = rm.listen(msg.bind, msg.target)
}
rm.sess.ReplyCtl(msg, err)
case <-rm.sess.Done():
return
}
}
}
决定二:客户端是唯一的事实源。 注册表放在客户端,静态 -reverse 给初值,运行时增删改的是这张表;每次(重)连成功后整体重放。漂移了怎么办?漂移了就再重放一遍——不需要任何持久化协议:
// bootSession 把一条裸连接组装成完整的客户端会话(重连时也走这里):
// 逼出裁决 → 心跳 → 反向数据面 → 反向注册重放。
func bootSession(conn net.Conn, reg *reverseRegistry, allow *AllowList) (*Session, error) {
sess := newSession(conn, true)
if err := sess.Handshake(handshakeTimeout); err != nil {
_ = conn.Close()
return nil, err
}
go sess.KeepAlive(keepAliveInterval, keepAliveTimeout)
go serveSession(sess, allow)
// 反向注册:控制面状态是 per-session 的,每次(重)连成功后整体重放。
// 失败补试一把(多半是撞上旧会话的清理),再失败只记日志不拖垮正向。
for _, m := range reg.snapshot() {
err := sess.ListenRemote(m.bind, m.target, ctlTimeout)
if err != nil {
time.Sleep(reverseRetryDelay)
err = sess.ListenRemote(m.bind, m.target, ctlTimeout)
}
...
}
return sess, nil
}
运行时增删走本地 admin 面的行协议,一次一行:
register 的语义有一处讲究:会话掉线时先记账,回应 OK registered (session down, applied on next connect);立刻失败(如端口被占)则回滚,不留期望与现实不符的条目。错误分类不是装饰——"先记账"和"回滚"是两种未来。
四、生产化:三道闸
方向通了之后,剩下的是让它敢放在公网上跑。
1. -max-conns:在握手前拒绝。 名额用 channel 当计数器,占满后 Accept 到的连接 直接 Close,不做握手。TLS 握手是 CPU 操作,攻击者伪造 SYN 的成本远低于服务端做一次握手;把裁决挪到握手之前,DoS 的资源放大系数就锁死了:
func serveCrypto(ln net.Listener, allow *AllowList, maxConns int) {
slots := make(chan struct{}, maxConns)
for {
conn, err := ln.Accept()
if err != nil {
log.Printf("accept failed, err=%v", err)
return
}
select {
case slots <- struct{}{}:
default:
log.Printf("tunnel conn refused, remote=%s, conns=%d (limit)", conn.RemoteAddr(), maxConns)
_ = conn.Close() // 连握手的 CPU 都不给
continue
}
go func(conn net.Conn) {
defer func() { <-slots }()
if err := verifyClientSourceIP(conn); err != nil {
_ = conn.Close()
return
}
sess := newSession(conn, false)
go sess.KeepAlive(keepAliveInterval, keepAliveTimeout)
go serveSession(sess, allow)
go newReverseManager(sess).run()
}(conn)
}
}
测试钉死的行为:第一条占住唯一名额(maxConns=1)正常握手;第二条 Read 立刻得到 EOF——被服务端主动关掉,而不是挂到超时。
2. 客户端侧 -allow:SSRF 的对称治理。 v3 给服务端加了拨号白名单;反向模式下 服务端能驱动内网客户端拨号,等于把 SSRF 面搬到了客户端一侧。解法对称:客户端也挂白名单,默认 127.0.0.1:*——服务端只能驱动它拨本机回环,捅不进内网别处。规则只收 IP/CIDR 不收域名,校验通过后按 IP 直接拨,不给 DNS rebinding 留二次解析的缝。
3. admin 面强制回环。 能下 LISTEN 命令就等于拿到了本机的拨号权——启动时校验 admin 监听地址必须绑 loopback,公网上能碰到的 admin 面,迟早会有人替你碰。
五、单张吊销:mTLS 体系里缺的那一环
生产化补完,还剩一个不对劲的地方:Go 标准库的 mTLS 只验证"链是否通到 CA"。签出去的叶子证书在到期前永远有效——一张客户端证书泄漏,唯一办法是换 CA、重签所有证书、两端重启。泄一张赔全套,代价不成比例。
补法是把"吊销"从 CA 层降到文件层:-revoked 指向一个 serial 清单,握手时对端叶子证书命中即拒,链有效也挡下:
func verifyPeer(rl *RevocationList) func([][]byte, [][]*x509.Certificate) error {
return func(rawCerts [][]byte, _ [][]*x509.Certificate) error {
leaf, err := x509.ParseCertificate(rawCerts[0]) // (1)!
if err != nil {
return nil // 链校验已在标准流程报错,这里不抢它的错误
}
if rl.IsRevoked(leaf.SerialNumber) { // (2)!
return fmt.Errorf("peer certificate revoked: serial=%s", leaf.SerialNumber.Text(16))
}
return nil
}
}
rawCerts[0]恒为对端叶子证书,这一步在标准链校验之后追加- 只管"单张吊销"这一层裁决,不改标准校验流程
清单格式的解析讲究一个"手填什么都能认"——证书工具的打印风格本来就乱:
base := 10
if strings.HasPrefix(s, "0x") || strings.HasPrefix(s, "0X") {
s = s[2:]
base = 16
} else if strings.ContainsAny(s, "abcdefABCDEF") {
base = 16 // openssl 风格 6F:04:A1(冒号已剥掉)
}
两个安全取向的决定值得记下:
- 格式错误行启动即 Fatal。吊销清单静默丢一行等于该证书复活——安全收紧面宁可吵死不可漏挡
- 误吊销可逆。清空文件即恢复放行,当场纠错不用等下一版
六、两个真实的坑
坑一:容器文件系统的 mtime 是秒级粒度
热加载最初用 mtime 判断文件变化,单测稳定复现失败:写入新 serial 后 IsRevoked 仍返回 false,日志里第二次加载压根没发生——10ms 内两次写入,mtime 完全相同,变更检测看不见变更。
教训是别把 mtime 当变更信号用。修复改为每次握手读文件、比对内容。清单就几行,读一次的成本可以忽略;换来的行为在任何文件系统上一致。
坑二:裁决时序不对称
写测试时发现同一个吊销动作,两端的报错时机完全不同:
| 谁吊销谁 | 裁决发生在哪 | 表现 |
|---|---|---|
| 服务端吊销客户端 | 服务端握手内,客户端要等到写数据 | tls.Dial 成功,靠 Handshake 逼出错误 |
| 客户端吊销服务端 | 客户端握手内同步执行 | tls.Dial 直接报错 |
第一版测试按 v3 的老经验写"靠 Handshake 逼",在第二个方向上直接撞墙。修完的断言两头兼容:Dial 报错算证据,没报就再靠 Handshake 补一刀。
Warning
TLS 1.3 下 tls.Dial 返回 nil 不代表对端接受了你——客户端证书的裁决发生在服务端握手之后,多数错误要等第一次读写才浮出来。断言握手结果,别断言 Dial 结果。
测试清单最后过一遍,四个方向全钉死:
| 用例 | 守住什么 |
|---|---|
| 吊销的客户端证书(hex/十进制/openssl 冒号三种写法) | 链有效也拒 |
| 吊销的服务端证书(对称方向) | 客户端侧同样能拉黑 |
| 文件改写后不重启 | 下一次握手即生效,清空即恢复 |
| 名额占满后的新连接 | 立即 EOF,不给握手 |
七、小结
一句话概括 v5 收官:数据面零新增,控制面一场重放,安全面三道闸,证书生命周期从 CA 层降到文件层。
回头看最有价值的决定还是 v2 把 Session 做成对称的:v5 的反向转发没动一行数据面,v3/v4 的能力全部自动继承。吊销那一块则是把"信任"拆开了——链证明你是谁,serial 清单决定你还有没有资格。 两件事分开,泄漏的代价从"换 CA 重签全链"降到"加一行文本"。
隧道系列到此收官:v1 桥接、v2 复用、v3 上锁、v4 续命、v5 掉头。它现在每天驮着一个个人平台从公网 VPS 打回内网,一直开着。
最后更新:2026-11-10