Tunnelto 源码解析 #6:数据转发核心:远端 HTTP 请求如何被转发到本地 localhost

在前几篇文章里,我们已经分析了 tunnelto 的整体架构、客户端启动流程、Wormhole 控制通道,以及 ClientHelloServerHelloControlPacket 这些协议对象。

到这里,我们终于进入 tunnelto 最核心的问题:

复制代码
远端 HTTP 请求到底是如何被转发到本地 localhost 的?

例如你在本地启动了一个 Web 服务:

复制代码
python -m http.server 8000

然后执行:

复制代码
tunnelto --port 8000

tunnelto 给你分配了一个公网地址:

复制代码
https://abc123.tunnelto.dev

当外部用户访问这个地址时,请求最终会进入你的本地服务:

复制代码
localhost:8000

这篇文章就围绕这条链路展开:

复制代码
浏览器
  ↓
tunnelto_server
  ↓
ControlPacket::Data
  ↓
WebSocket 控制通道
  ↓
tunnelto 客户端
  ↓
TcpStream::connect(localhost:8000)
  ↓
本地 Web 服务

重点分析客户端和服务端如何配合,把远端 HTTP 请求转成本地 TCP 流,再把本地响应反向传回浏览器。


一、数据转发不是"HTTP 转发",而是"字节流转发"

先明确一个关键点:tunnelto 并不真正理解你的 HTTP 业务。

它不会关心:

复制代码
请求方法是 GET 还是 POST
路径是 /api/user 还是 /index.html
响应状态码是 200 还是 500
Content-Type 是 text/html 还是 application/json

这些信息对 tunnelto 来说,本质上只是 TCP 字节流中的内容。

tunnelto 真正关心的是:

复制代码
这段字节属于哪个连接?
应该转发给哪个客户端?
应该写入哪个本地 TCP 连接?
响应回来后应该写回哪个远端 socket?

因此,它的转发模型不是:

复制代码
HTTP Request -> HTTP Response

而是:

复制代码
远端 TCP 字节流 -> ControlPacket::Data -> 本地 TCP 字节流

这是理解 tunnelto 转发核心的第一步。


二、完整数据链路先看一遍

一次远端请求进入本地服务,大致会经历这几个阶段:

复制代码
1. 外部用户访问 abc123.tunnelto.dev
2. 请求到达 tunnelto_server 的远端 TCP listener
3. 服务端解析 HTTP Host,找到 abc123 对应的客户端
4. 服务端为这个连接创建 ActiveStream 和 StreamId
5. 服务端发送 ControlPacket::Init(stream_id)
6. 服务端读取远端 socket 的请求字节
7. 服务端发送 ControlPacket::Data(stream_id, request_bytes)
8. 客户端收到 Data,检查本地是否已有这个 stream
9. 如果没有,客户端连接 localhost:8000
10. 客户端把 request_bytes 写入本地 TcpStream
11. 本地服务返回 response_bytes
12. 客户端发送 ControlPacket::Data(stream_id, response_bytes)
13. 服务端收到响应数据,写回远端 socket
14. 浏览器收到响应
15. 连接结束后双方发送或处理 End,并清理 stream

如果画成图,就是:

复制代码
浏览器
  │
  │ HTTP 请求字节
  ▼
tunnelto_server remote.rs
  │
  │ ControlPacket::Data(stream_id, request_bytes)
  ▼
WebSocket 控制通道
  │
  ▼
tunnelto client main.rs
  │
  │ StreamMessage::Data(request_bytes)
  ▼
local.rs
  │
  │ TcpStream::connect(localhost:8000)
  ▼
本地 Web 服务
  │
  │ HTTP 响应字节
  ▼
local.rs
  │
  │ ControlPacket::Data(stream_id, response_bytes)
  ▼
tunnelto_server
  │
  │ 写回远端 socket
  ▼
浏览器

这个链路的核心就是两个方向的字节传输。


三、服务端第一步:接收远端连接

远端请求首先进入服务端的 remote.rs

服务端会接收一个 TCP 连接,然后调用:

复制代码
accept_connection(socket)

这个函数是公网流量进入 tunnelto 的入口。

它首先需要知道:这个请求访问的是哪个 tunnel?

因为同一个 tunnelto_server 可能同时服务很多用户:

复制代码
abc123.tunnelto.dev -> client_A
demo.tunnelto.dev   -> client_B
test.tunnelto.dev   -> client_C

所以服务端必须先从 HTTP 请求头里解析 Host

例如:

复制代码
GET / HTTP/1.1
Host: abc123.tunnelto.dev

服务端通过 Host 头提取出:

复制代码
abc123

然后查找:

复制代码
abc123 对应哪个 ConnectedClient?

如果找不到,就返回:

复制代码
Error: Tunnel Not Found

如果找到了,说明这个子域名当前有一个客户端在线,后续就可以把请求转发给它。


四、服务端为什么要先 peek HTTP Header?

remote.rs 中不是直接读取并消费整个请求,而是先 peek 一部分 HTTP 头。

原因是它需要读取 Host,但又不能把这些字节丢掉。

如果服务端读取了请求头并消费掉,后面转发给本地服务时,本地服务收到的请求就不完整了。

所以它使用类似"偷看"的方式:

复制代码
peek 前 4KB 数据
解析 Host
保留 socket 中的数据不被消费

这样既能知道请求应该转发给哪个客户端,又能保证后面完整请求仍然可以被转发。

这一步非常关键。

因为 tunnelto 要做的是透明转发:

复制代码
浏览器发了什么,本地服务就应该收到什么。

五、服务端创建 ActiveStream

找到对应客户端后,服务端会为当前远端连接创建一个 ActiveStream

可以把它理解成:

复制代码
一个远端 TCP 连接在服务端侧的抽象

每个 ActiveStream 都有自己的:

复制代码
StreamId
ConnectedClient
tx channel

其中最重要的是 StreamId

因为一条 WebSocket 控制通道可能同时转发多个请求,必须用 StreamId 区分它们。

例如浏览器访问首页时,可能同时请求:

复制代码
GET /index.html
GET /style.css
GET /main.js
GET /favicon.ico

服务端会给它们分别创建不同的 stream:

复制代码
stream_A -> /index.html
stream_B -> /style.css
stream_C -> /main.js
stream_D -> /favicon.ico

这些 stream 都会通过同一条客户端 WebSocket 转发,但每个数据包都带有自己的 StreamId


六、服务端把一个 socket 拆成两个方向

创建 ActiveStream 后,服务端会把远端 TCP socket 拆成读写两个方向:

复制代码
stream  读取浏览器发来的请求数据
sink    向浏览器写回本地服务的响应数据

然后启动两个异步任务。

第一个任务:

复制代码
process_tcp_stream()

负责:

复制代码
从远端 socket 读取数据
封装成 ControlPacket::Data
发送给客户端

第二个任务:

复制代码
tunnel_to_stream()

负责:

复制代码
从客户端返回队列读取数据
写回远端 socket

也就是说,服务端侧的数据转发天然分成两个方向:

复制代码
浏览器 -> 客户端
客户端 -> 浏览器

这种读写拆分,是异步网络代理很常见的结构。


七、服务端发送 Init

在真正转发数据之前,服务端会先发送:

复制代码
ControlPacket::Init(stream_id)

它的含义是:

复制代码
一个新的远端 stream 开始了。

这一步相当于告诉客户端:

复制代码
准备好,接下来会有一个新的连接数据过来。

不过,客户端收到 Init 后不一定马上连接本地服务。

在 tunnelto 的实现中,真正创建本地 TCP 连接通常发生在收到 Data 包时。

Init 更像是 stream 生命周期的起点通知。


八、服务端读取远端请求并发送 Data

接着,process_tcp_stream() 开始从远端 socket 读取数据。

读取到数据后,它会封装成:

复制代码
ControlPacket::Data(stream_id, data)

然后通过当前客户端的发送通道发给客户端。

这里的 data 就是远端浏览器发来的原始字节。

它可能包含:

复制代码
HTTP 请求行
HTTP Header
请求体
WebSocket upgrade 请求
其他基于 TCP 的内容

tunnelto 不解析这些业务内容,只是把它们作为字节转发。

如果远端 socket 读到 0 字节,说明连接结束。服务端会发送:

复制代码
ControlPacket::End(stream_id)

通知客户端这个 stream 结束。


九、客户端收到 Data 后进入本地转发

客户端的 WebSocket 读取循环在 main.rs 中。

当客户端收到服务端发来的二进制消息后,会调用:

复制代码
process_control_flow_message()

这个函数会先把 payload 反序列化成:

复制代码
ControlPacket

如果是:

复制代码
ControlPacket::Data(stream_id, data)

客户端会做两件事。

第一,检查当前 stream_id 是否已经存在于 ACTIVE_STREAMS

第二,如果不存在,就调用:

复制代码
local::setup_new_stream()

这说明本地 TCP 连接是按需创建的。

客户端启动时,并不会预先连接 localhost:8000。只有当远端请求真正进来时,才会为这个 StreamId 创建对应的本地连接。


十、ACTIVE_STREAMS:客户端侧的 stream 路由表

客户端全局维护了一个:

复制代码
ACTIVE_STREAMS

它可以理解成:

复制代码
StreamId -> 本地连接的发送通道

当远端数据到来时,客户端根据 StreamId 找到对应的本地连接。

如果没有,就新建。

如果有,就直接把数据发给这个本地连接。

这张表是客户端处理并发请求的关键。

例如:

复制代码
stream_A -> local connection A
stream_B -> local connection B
stream_C -> local connection C

每个远端请求都有自己独立的本地 TCP 连接。

这保证了并发请求之间不会互相串数据。


十一、setup_new_stream():真正连接本地 localhost

客户端连接本地服务的核心函数在 local.rs

复制代码
setup_new_stream()

它首先做的事情就是:

复制代码
TcpStream::connect(config.local_addr)

如果你执行的是:

复制代码
tunnelto --port 8000

那么这里的 config.local_addr 就对应:

复制代码
localhost:8000

如果本地服务没有启动,连接会失败。

失败后,客户端会做两件事:

复制代码
1. 记录 connect_failed
2. 向服务端发送 ControlPacket::Refused(stream_id)

也就是说,客户端不会假装转发成功。

它会明确告诉服务端:

复制代码
这个 stream 无法连接本地服务。

服务端收到后,可以向远端浏览器返回类似:

复制代码
Tunnel says: connection refused.

这就是为什么有时候 tunnel URL 能打开,但会提示本地连接失败:控制通道是成功的,但本地服务没有监听对应端口。


十二、本地 TLS:--use-tls 做了什么

setup_new_stream() 中还有一个分支:

复制代码
if config.use_tls

如果用户启动时指定:

复制代码
tunnelto --use-tls --port 8000

客户端连接本地 TCP 后,还会尝试用 TLS 包装这个连接。

也就是说,普通模式下:

复制代码
tunnelto client -> TCP -> localhost:8000

启用 TLS 后:

复制代码
tunnelto client -> TLS over TCP -> localhost:8000

这里要注意,--use-tls 指的是客户端连接本地服务时使用 TLS。

它不是指公网访问地址是否 HTTPS。

公网 HTTPS 是服务端暴露域名时的事情;本地 TLS 是客户端和本地服务之间的事情。

如果你的本地服务只是普通 HTTP,但你加了 --use-tls,本地 TLS 握手会失败,客户端也会发送 Refused


十三、客户端把本地连接拆成读写两半

成功连接本地服务后,客户端会把这个本地 TCP stream 拆成两部分:

复制代码
stream  从本地服务读取响应
sink    向本地服务写入请求

然后启动两个异步任务。

第一个任务:

复制代码
process_local_tcp()

负责:

复制代码
从本地服务读取响应数据
封装成 ControlPacket::Data
发回服务端

第二个任务:

复制代码
forward_to_local_tcp()

负责:

复制代码
从客户端本地队列读取远端请求数据
写入本地服务

这正好和服务端的两个任务形成镜像关系。

服务端:

复制代码
process_tcp_stream()  浏览器 -> 客户端
tunnel_to_stream()    客户端 -> 浏览器

客户端:

复制代码
forward_to_local_tcp()  服务端 -> 本地服务
process_local_tcp()     本地服务 -> 服务端

十四、forward_to_local_tcp():把远端请求写入本地服务

当客户端收到服务端发来的 Data 包后,最终会把字节发送到本地 stream 的队列里:

复制代码
StreamMessage::Data(data)

forward_to_local_tcp() 会从这个队列中不断读取数据。

如果读到的是:

复制代码
StreamMessage::Data(data)

就执行:

复制代码
sink.write_all(&data)

把远端请求字节写入本地服务。

也就是说,外部浏览器发出的 HTTP 请求,最终在这里进入:

复制代码
localhost:8000

如果队列关闭,或者收到:

复制代码
StreamMessage::Close

客户端就会关闭本地写方向。


十五、process_local_tcp():把本地响应发回服务端

本地服务处理请求后,会返回响应。

客户端通过 process_local_tcp() 从本地连接读取响应数据。

它每次读取一段字节,然后封装成:

复制代码
ControlPacket::Data(stream_id, data)

再通过 tunnel_tx 发回 WebSocket 写入任务。

WebSocket 写入任务会执行:

复制代码
packet.serialize()

然后通过 WebSocket 发给服务端。

这就是响应回传路径:

复制代码
localhost:8000
  ↓
process_local_tcp()
  ↓
ControlPacket::Data(stream_id, response_bytes)
  ↓
WebSocket
  ↓
tunnelto_server

如果本地服务关闭连接,读取到 0 字节,客户端会从 ACTIVE_STREAMS 中移除这个 stream_id

这一步是资源清理,避免 stream 表无限增长。


十六、服务端把本地响应写回浏览器

服务端收到客户端发回的:

复制代码
ControlPacket::Data(stream_id, response_bytes)

后,会根据 stream_id 找到对应的 ActiveStream

然后把数据放入这个 stream 的队列。

前面启动的 tunnel_to_stream() 任务会从队列中读取数据,并执行:

复制代码
sink.write_all(&data)

把本地服务响应写回浏览器。

因此,浏览器最终收到的响应,其实是本地服务返回的原始字节。

tunnelto 在中间只是搬运它。


十七、Refused:本地连接失败时如何处理

如果客户端无法连接本地服务,例如:

复制代码
localhost:8000 没有服务
端口写错了
本地服务崩了
TLS 握手失败

客户端会发送:

复制代码
ControlPacket::Refused(stream_id)

服务端收到后,会把这个 stream 标记为被拒绝。

然后远端浏览器会收到一个错误响应:

复制代码
Tunnel says: connection refused.

这类错误经常出现在使用内网穿透时。

很多人看到公网地址打不开,就以为是公网服务端问题。

但源码告诉我们,要区分两个阶段:

复制代码
控制通道是否建立成功?
本地服务是否能连接成功?

如果 CLI 已经显示公网 URL,说明控制通道通常已经建立成功。

如果访问时报 connection refused,更可能是:

复制代码
本地服务没有启动
端口指定错了
--use-tls 和本地服务协议不匹配

十八、End:一次远端连接如何结束

当远端 socket 结束时,服务端会向客户端发送:

复制代码
ControlPacket::End(stream_id)

客户端收到后,会找到对应本地 stream,并在短暂延迟后发送关闭消息:

复制代码
StreamMessage::Close

然后从 ACTIVE_STREAMS 中移除。

为什么需要 End

因为一条 WebSocket 控制通道上同时承载多个 stream。

某一个 HTTP 请求结束,不代表整个 WebSocket tunnel 结束。

所以必须用 End(stream_id) 明确告诉对方:

复制代码
这个 stream 可以收尾了,但 tunnel 还要继续运行。

这就是 stream 生命周期管理。


十九、为什么每个远端连接都要新建本地连接?

从源码看,服务端每接受一个远端 TCP 连接,就创建一个新的 StreamId

客户端收到这个 stream 的数据后,也会创建一个新的本地 TCP 连接。

这意味着:

复制代码
一个远端 TCP 连接
对应一个 StreamId
对应一个本地 TCP 连接

这种设计很直观,也符合 TCP 转发的语义。

浏览器和服务端之间有一条连接,本地客户端和本地服务之间也创建一条对应连接。

中间用 StreamId 把这两条连接关联起来:

复制代码
remote socket <-> StreamId <-> local socket

这样可以保持连接隔离,避免多个请求共享同一个本地 TCP 连接导致协议错乱。


二十、Introspection:顺手记录请求和响应

local.rs 里还有一个和转发相关的辅助功能:introspection。

当客户端创建本地 stream 时,会创建两条记录通道:

复制代码
introspect_request
introspect_response

当远端请求写入本地服务时,会把请求数据送入 request 记录通道。

当本地服务响应返回时,会把响应数据送入 response 记录通道。

后续 introspection 模块会解析 HTTP 请求和响应,记录路径、方法、头部、响应状态、请求体、响应体等信息。

这就是 tunnelto 本地调试面板能够展示请求记录的原因。

它不是在服务端抓包,而是在客户端本地转发时顺手记录。

这对调试 Webhook 特别有用。


二十一、完整转发流程源码级串联

现在我们把整个流程按函数串起来。

服务端侧

复制代码
accept_connection(socket)
  ↓
peek_http_request_host(socket)
  ↓
validate_host_prefix(host)
  ↓
Connections::find_by_host(host)
  ↓
ActiveStream::new(client)
  ↓
ACTIVE_STREAMS.insert(stream_id, active_stream)
  ↓
spawn process_tcp_stream()
  ↓
spawn tunnel_to_stream()

process_tcp_stream() 负责:

复制代码
send_client_stream_init()
读取远端 socket
发送 ControlPacket::Data 给客户端
远端结束时发送 ControlPacket::End

tunnel_to_stream() 负责:

复制代码
等待客户端返回的数据
写回远端 socket
处理 TunnelRefused / NoClientTunnel
清理 ActiveStream

客户端侧

复制代码
run_wormhole()
  ↓
读取 WebSocket message
  ↓
process_control_flow_message()
  ↓
ControlPacket::deserialize()
  ↓
如果是 Data:
      检查 ACTIVE_STREAMS
      如果没有,调用 setup_new_stream()
      把 data 发送给本地 stream

setup_new_stream() 负责:

复制代码
TcpStream::connect(config.local_addr)
如果 use_tls,包装成 TLS stream
split 本地连接
spawn process_local_tcp()
spawn forward_to_local_tcp()
ACTIVE_STREAMS.insert(stream_id, tx)

forward_to_local_tcp() 负责:

复制代码
读取 StreamMessage::Data
写入本地 TcpStream

process_local_tcp() 负责:

复制代码
读取本地服务响应
发送 ControlPacket::Data 回服务端
本地连接结束时清理 ACTIVE_STREAMS

这就是远端 HTTP 请求进入本地 localhost 的完整源码路径。


二十二、用一个例子理解

假设外部用户访问:

复制代码
https://abc123.tunnelto.dev/api/hello

浏览器发出的请求大致是:

复制代码
GET /api/hello HTTP/1.1
Host: abc123.tunnelto.dev

服务端收到后:

复制代码
解析 Host -> abc123
找到 client -> client_A
创建 StreamId -> stream_X
发送 Init(stream_X)
发送 Data(stream_X, request_bytes)

客户端收到后:

复制代码
发现 stream_X 不存在
连接 localhost:8000
创建本地 stream
把 request_bytes 写入 localhost:8000

本地服务处理后返回:

复制代码
HTTP/1.1 200 OK
Content-Type: application/json

{"message":"hello"}

客户端读取响应:

复制代码
Data(stream_X, response_bytes)

发给服务端。

服务端收到后:

复制代码
根据 stream_X 找到远端 socket
把 response_bytes 写回浏览器

浏览器最终看到:

复制代码
{"message":"hello"}

整个过程中,tunnelto 并不理解 /api/hello 的业务含义,它只是根据 StreamId 搬运字节。


二十三、这一篇的核心结论

远端 HTTP 请求被转发到本地 localhost,靠的是服务端和客户端两侧的协作。

服务端负责:

复制代码
接收公网 TCP 连接
解析 Host 找到客户端
创建 StreamId 和 ActiveStream
把远端请求字节封装成 ControlPacket::Data
通过 WebSocket 发给客户端
把客户端返回的数据写回浏览器

客户端负责:

复制代码
接收服务端的 ControlPacket::Data
根据 StreamId 管理本地 stream
按需连接 localhost:8000
把远端请求写入本地服务
读取本地响应
封装成 ControlPacket::Data 发回服务端

一句话总结:

复制代码
tunnelto 的数据转发核心,就是把一个远端 TCP 连接映射成一个 StreamId,
再在客户端创建对应的本地 TCP 连接,
通过 ControlPacket::Data 在 WebSocket 控制通道中双向搬运字节。

所以,tunnelto --port 8000 背后的本质不是神秘的"打洞",而是:

复制代码
公网服务端负责收请求
本地客户端负责连 localhost
中间用 WebSocket + StreamId + ControlPacket 做反向转发

二十四、下一篇预告

下一篇我们继续分析服务端入口:

Tunnelto 源码解析 #7:服务端入口:Host Header、子域名匹配与公网请求分发

下一篇会重点研究:

复制代码
remote.rs
peek_http_request_host()
validate_host_prefix()
Connections::find_by_host()
network::instance_for_host()
HTTP_NOT_FOUND_RESPONSE
HTTP_INVALID_HOST_RESPONSE

也就是公网请求进入 tunnelto_server 后,服务端如何根据 Host Header 判断这个请求应该交给哪个客户端。

相关推荐
志栋智能2 小时前
安全超自动化:应对海量安全警报的唯一解
网络·安全·自动化
dxxt_yy3 小时前
鼎讯信通 HD‑095B:能源行业高精度频谱测试解析
网络·能源
2601_959480155 小时前
Moneta Markets亿汇:“网络安全新盾快速登场”
网络
leo__5205 小时前
随机接入退避算法过程模拟实现
网络·算法
AI科技星5 小时前
基于光速螺旋第一性原理:$G,\varepsilon_0,\alpha$引电统一完整推导+严谨证明+高精度数值全维度分析
c语言·开发语言·网络·量子计算·agi
ICT系统集成阿祥6 小时前
ONU常见工作状态含义(PON设备通用:GPON/EPON)
网络
渴了喝洗衣液7 小时前
BGP作业
网络
jing.wang_20257 小时前
TI TMS320C6678芯片实现IP及端口在线修改并生效
网络·嵌入式硬件·tcp/ip·dsp开发
老高学长7 小时前
金融机构文档加密软件哪个好|合规与安全兼顾|2026新测评
网络·人工智能·安全