从 ssh -L 到自研多路复用:手写一个隧道协议
上次在服务器上 ps -ef | grep ssh 找不到 VSCode 的转发进程,搞明白它是"SSH 当加密网线、两端各挂一个桥接端点"之后,手就痒了——《ps -ef | grep ssh 查不到的端口转发》 那篇只讲了原理,这次亲手写一个。
第一步不搞花活:一条转发独占一条 TCP 连接,先把字节搬对,再谈多路复用。
一、v1 要造什么
就一件事:让本机的 18000 端口等价于远端机器视角下的 8000 端口。
flowchart LR
b["浏览器<br/>127.0.0.1:18000"] --> c["client<br/>本机监听"]
c -- "隧道 TCP 连接(一条转发一条)" --> s["server<br/>远端"]
s --> t["目标服务<br/>127.0.0.1:8000"] client 和 server 各是一个进程:server 跑在能访问目标服务的机器上,client 跑在你本机。一条本地连接进来,client 就往 server 新开一条 TCP,转发完就关——连接数与转发数 1:1,这是 v1 最笨的地方,也是它最好懂的地方。
二、握手:服务端得知道去哪
隧道连接建起来之后,server 面对的第一个问题是:你要我连谁?答案只能由 client 在连接建立时告诉它,这就是握手存在的唯一理由。
v1 的握手极简,请求四段、响应六字节:
| 字段 | 长度 | 说明 |
|---|---|---|
| magic | 4B | 固定 MBRD,用来一眼认出"对端不是自己人" |
| ver | 1B | 协议版本,v2 换帧协议时靠它区分 |
| len | 2B | 目标地址长度,大端 |
| target | N | 目标地址 host:port,从 server 视角 解析 |
响应是同一个套路:[magic:4][ver:1][status:1],status 为 0 表示 dial 成功,1 表示目标不可达。
func writeHandshake(w io.Writer, target string) error {
if len(target) > maxTargetLen {
return errTargetTooLong
}
buf := make([]byte, reqHeadLen+len(target))
copy(buf[0:4], magic)
buf[4] = protocolVersion
binary.BigEndian.PutUint16(buf[5:reqHeadLen], uint16(len(target)))
copy(buf[reqHeadLen:], target)
_, err := w.Write(buf)
return err
}
读的一侧有个所有自研协议都要过的坎:一次 conn.Read 拿到多少字节都不保证。几个字节的头部完全可能被拆成好几次到达。所以必须用 io.ReadFull,它会循环读满才返回:
func readHandshake(r io.Reader) (string, error) {
head := make([]byte, reqHeadLen)
if _, err := io.ReadFull(r, head); err != nil { // 不是 r.Read
return "", err
}
...
}
这条不变量值得单独写个测试——用一个每次只吐 1 字节的 reader 模拟拆包:
type dripReader struct {
data []byte
off int
}
func (d *dripReader) Read(p []byte) (int, error) {
if d.off >= len(d.data) {
return 0, io.EOF
}
n := copy(p, d.data[d.off:d.off+1]) // 每次只给 1 字节
d.off += n
return n, nil
}
readHandshake(&dripReader{...}) 必须照样成功。写过一次就知道,HTTP 库把这件事盖得有多好。
magic 是给"连错了"准备的
第一版没有 magic,客户端只读 1 字节状态。于是真实发生过一次:Mac 上的客户端连开发机的 9000 端口,日志刷出一片 tunnel server rejected, status=72。
72 = 0x48 = 'H'——那是 HTTP/1.1 ... 的首字节。链路上根本不是 minibridge,而是别的服务在回话,客户端却把人家响应的第一个字节当成状态位读了。日志里没有一个字提到 HTTP,只能靠推理。
加上 magic 之后,这类误连直接自报家门:
func badPeerErr(prefix []byte) error {
if len(prefix) >= 4 && string(prefix[:4]) == "HTTP" {
return fmt.Errorf("peer is not a minibridge server (looks like HTTP, first bytes %q)", prefix[:4])
}
return fmt.Errorf("%w (first bytes %q)", errBadMagic, prefix)
}
顺带还有第二个收获:读握手响应必须带 deadline。对端不是自己人时,它可能永远凑不齐 6 个字节,io.ReadFull 就一直等着——没有超时,这个 goroutine 会永久挂住。握手阶段设 SetReadDeadline,进入转发阶段再清掉(转发本身不能有超时)。
这类坑的代价不在修复,在排查:加 magic 只是几行代码,但那次为定位 status=72 花了整整一轮抓包和推理。
三、桥接:io.Copy 与半关闭
握手之后就是纯粹的搬运。两个方向各起一个 goroutine 对拷,等都结束再关闭连接:
func bridge(a, b net.Conn) (a2b, b2a int64, err error) {
var (
errA2B, errB2A error
wg sync.WaitGroup
)
wg.Add(2)
go func() {
defer wg.Done()
a2b, errA2B = copyAndHalfClose(b, a)
}()
go func() {
defer wg.Done()
b2a, errB2A = copyAndHalfClose(a, b)
}()
wg.Wait()
closeBoth(a, b)
return a2b, b2a, firstErr(errA2B, errB2A)
}
重点在 copyAndHalfClose:某一方向读到 EOF 时,对另一端要调 CloseWrite(半关闭),而不是 Close。
func copyAndHalfClose(dst, src net.Conn) (int64, error) {
n, err := io.Copy(dst, src)
if tcp, ok := dst.(*net.TCPConn); ok {
_ = tcp.CloseWrite()
} else {
_ = dst.Close()
}
return n, err
}
半关闭到底是什么
一条 TCP 连接是 两条独立的单向字节流:我的写方向、我的读方向。Close 把两个方向一起关掉,CloseWrite 只关写方向(内核发出 FIN),读方向还开着。
| 调用 | 发出的信号 | 还能读对端的数据吗 |
|---|---|---|
Close() | FIN;若接收缓冲区还有未读数据,内核直接发 RST | ❌ 不能 |
CloseWrite() | FIN | ✅ 能 |
两者的话外音不同:CloseWrite 是 "我发完了,你继续说",Close 是 "我挂了"。
场景:为什么非它不可
有一类服务必须收到 EOF 才知道"输入结束了",然后才开始干活——sort 是最典型的:输入没读完就输出,排序结果是错的。类比 wc -l、批量导入,以及本文测试里那个"收完再回"的服务。
场景:本地把 b\na\n 经隧道发给远端的 sort,期望拿回 a\nb\n。
用 Close,链路会在半路被掐断:
sequenceDiagram
participant A as 本地应用
participant C as client
participant S as server
participant T as 目标服务 sort
A->>C: "b\na\n"
A->>C: FIN(我发完了)
Note over C: 读到 EOF → 直接 Close 整条隧道
C->>S: 转发 "b\na\n"
C--xS: 连接被全关
S->>T: 转发 "b\na\n"
Note over T: 等 EOF …永远等不到
T->>S: "a\nb\n"(若侥幸产出)
S--xC: 隧道已关,回不去
Note over A: 什么都没收到 → 超时 用 CloseWrite,EOF 得以逐级传下去,反向通道也保住了:
sequenceDiagram
participant A as 本地应用
participant C as client
participant S as server
participant T as 目标服务 sort
A->>C: "b\na\n"
A->>C: FIN(我发完了)
Note over C: 读到 EOF → 对隧道 CloseWrite(发 FIN,保留读)
C->>S: 转发 "b\na\n" + FIN
Note over S: 读到 EOF → 对目标 CloseWrite(再传一级)
S->>T: 转发 "b\na\n" + FIN
Note over T: 收到 EOF → 输入结束 → 排序
T->>S: "a\nb\n"
S->>C: "a\nb\n"
C->>A: "a\nb\n"
Note over A: ✅ 拿到结果,正常收尾 核心:EOF 是一个必须逐级往下传的信号。 每一跳的动作都一样——读到 EOF,就对下一跳 CloseWrite。这也是上面那段代码在链路每个方向上都成立的原因。
为什么平时踩不到
多数 HTTP 请求不靠 EOF 判断请求结束,而是靠 Content-Length 或 chunked 终止块,客户端压根不会先发 FIN,双向几乎同时关闭,两种写法表现一致。
只有碰到"读到 EOF 才算输入结束"的语义时差别才暴露,而且是 偶发 的:多数请求恰好没有数据要补发,看起来没事;一旦有,响应就凭空消失,日志里还找不到任何错误——这类 bug 排查成本极高。
怎么验证
# 1. 隧道里的端到端用例
go test -run TestForwardPropagatesHalfClose -v ./v1-forward # 绿
# 把 tunnel.go 里 tcp.CloseWrite() 改成 tcp.Close() 再跑 → 变红(读超时)
# 2. nc 演示"服务端在等 EOF"(与隧道无关,只看 FIN 这个信号)
nc -l 9000 | sort # 终端 1
printf 'b\na\n' | nc -N 127.0.0.1 9000 # 终端 2:终端 1 立刻输出 a / b ✅
printf 'b\na\n' | timeout 3 nc 127.0.0.1 9000 # 去掉 -N:卡 3 秒,终端 1 无输出 ❌
nc -N 就是命令行版的 CloseWrite:少了那个 FIN,对端的 sort 永远不会动。
四、跑通
两端各一条命令:
# 远端
go run ./v1-forward server -addr 127.0.0.1:19000
# 本机
go run ./v1-forward client -listen 127.0.0.1:18000 \
-server 127.0.0.1:19000 -target 127.0.0.1:8000
curl http://127.0.0.1:18000/
真实跑通的日志(转发本机 8000 上一个 97KB 的页面):
http_code=200 size=97843
tunnel finished, target=127.0.0.1:8000, client_to_target=79, target_to_client=98008, err=<nil>
forward finished, target=127.0.0.1:8000, local_to_remote=79, remote_to_local=98008, err=<nil>
两个方向的字节数在链路上严格对称——请求 79 字节、响应 98008 字节,两端一致,err=<nil> 说明这条连接是正常终止而不是被 RST。
五、四个测试守住不变量
端到端转发最容易被写成"跑一下看看有没有回显",那不算测试。真正要钉住的是这四条:
- 回显往返:写进去的字节能原样回来
- 目标不可达:服务端回
statusDialFailed并关闭,本地必须读到EOF而不是永远挂着 - 半关闭穿透:客户端
CloseWrite之后,仍能收到目标端补发的响应——把CloseWrite改成Close,这条会立刻失败 - 并发 20 条:同时转发互不串包
第 2 条最容易被漏,而它恰恰是"僵尸连接"的来源:目标没了,本地连接还杵在那里等一个永远不来的响应。
六、v1 离 VSCode 还差三步
v1 能跑,但拿它跟 Remote-SSH 比,差距一目了然:
- 1:1 的连接模型。100 条转发就是 100 条 TCP + 200 个 goroutine,长连接场景下 fd 先扛不住。解决办法是 v2:把"一条连接一条流"改成"一条连接多条流",自己定义带
streamID的帧——[length:4][type:1][streamID:4][payload:N],SYN / DATA / FIN / RST这几种类型就够撑起 stream 的生命周期。 - 没有加密和鉴权。隧道裸奔,服务端的隧道端口谁都能连。
- 目标地址客户端说了算。客户端让服务端连哪就连哪,这是个标准的 SSRF 面,生产环境必须配白名单。
前两条是 v2、v3 的事。v1 的价值在于:它把"端口转发"这件事拆成了握手、拨号、搬运、收尾四步,每一步都能单独看见、单独测。多路复用再复杂,也只是把"搬运"这一步从一对一变成一对多。
小结
一句话概括 v1:端口转发没有魔法,就是握手告诉对端去哪 + 两个 io.Copy 对拷 + 结束时用半关闭收尾。难度不在代码量,在那些只有自己写才会撞上的细节——拆包、半关闭、目标不可达时的干净收尾。
代码在 example/2026/10/27,go test -count=1 -race ./... 全绿。下一步是 v2:一条 TCP 上跑 100 条流,手写帧协议和 stream 生命周期。
最后更新:2026-10-27