跳转至

隧道掉头:反向转发、DoS 封顶与单张证书吊销

上篇结尾预告了 v5:服务端 Open、客户端 Accept,隧道从"单向往外捅"变成"双向互通"。这篇兑现它,顺带补上生产化最后一块砖:单张证书吊销——mTLS 体系里一直缺位的那一环。

一、引子:终于轮到"往回捅"

前四篇的隧道都是单向的:客户端在内网,主动连到公网服务端,把内网服务"捅"出去。但真实需求经常反过来:内网机器不能被公网访问,却要能对外提供服务。

SSH 的答案是 -R。自研隧道的答案在 v5 之前一直欠着——不是做不了,是做之前先想清楚一件事:

反向转发不是新协议,是同一条数据面换了个方向用。 v2 设计 Session 时把它做成了完全对称的:谁 Open、谁 Accept,角色不写死。这个"提前埋的对称性"在 v5 兑现。

二、反向转发:-bind 的一表两读,数据面零新增

接口设计浓缩成 -bind 的一表两读,正反向共用同一个参数,-reverse 只是方向开关:

main.go
// 正向:-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 拨号再桥接:

reverse.go
// 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)
}
  1. rm.sess.Open(target)——服务端主动开流,这是整段里唯一"新"的调用

高亮那行之外,bridge(conn, st) 就是 v1 写的双向拷贝。v5 对数据面的净贡献是 Open 的一次调用加两行日志。

对称性的红利还不止省代码:正反向走同一段转发逻辑,意味着 v4 的心跳判死、会话热切换对反向流同样生效,一行不用改。

三、控制面:会话资产与声明式重放

数据面便宜,控制面才是 v5 的真正工作量的所在。两个设计决定撑起它。

决定一:服务端的监听是"会话资产"。 一个 bind 只属于一个会话,控制状态是 per-session 的——会话一死,服务端把它名下的监听全部关掉,否则端口泄漏,而且谁也说不清那个端口还通向哪:

reverse.go
// 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 给初值,运行时增删改的是这张表;每次(重)连成功后整体重放。漂移了怎么办?漂移了就再重放一遍——不需要任何持久化协议:

admin.go
// 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 面的行协议,一次一行:

admin 面用法
LISTEN :18001 127.0.0.1:8081   # 运行时加一条反向映射
UNLISTEN :18001                # 摘掉
LIST                           # OK 2 mapping(s) + 全表

register 的语义有一处讲究:会话掉线时先记账,回应 OK registered (session down, applied on next connect);立刻失败(如端口被占)则回滚,不留期望与现实不符的条目。错误分类不是装饰——"先记账"和"回滚"是两种未来。

四、生产化:三道闸

方向通了之后,剩下的是让它敢放在公网上跑。

1. -max-conns:在握手前拒绝。 名额用 channel 当计数器,占满后 Accept 到的连接 直接 Close,不做握手。TLS 握手是 CPU 操作,攻击者伪造 SYN 的成本远低于服务端做一次握手;把裁决挪到握手之前,DoS 的资源放大系数就锁死了:

main.go
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 清单,握手时对端叶子证书命中即拒,链有效也挡下:

revoked.go
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
    }
}
  1. rawCerts[0] 恒为对端叶子证书,这一步在标准链校验之后追加
  2. 只管"单张吊销"这一层裁决,不改标准校验流程

清单格式的解析讲究一个"手填什么都能认"——证书工具的打印风格本来就乱:

revoked.go
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

评论