5 分钟
frp 源码浅析
frp 介绍
frp 是一套通用的,基于 C/S 架构的内网穿透软件。软件分为两个部分:client (frpc) 和 server (frps)。
该软件的基本使用流程:
- 部署:首先需要将 frps 部署到具有公网 IP 的主机上(远端主机);
- 暴露端口:然后在需要暴露端口内网的设备上(本地主机),运行 frpc,并通过配置文件指定指定:
- 要暴露的本地主机的本地端口;
- 要暴露端口映射到的远端主机的远端端口;
- 访问端口:在任意一台设备,通过远端主机公网 IP 和 暴露到远端端口即可访问到本地主机上的本地端口。
更详细的使用教程参见:官方文档。
本文介绍 frp 版本为:v0.44.0。
frp 架构图
(图片来源 github)
该图主要描述的是访问端口的流量情况,用户流量经过部署在具有公网 ip 的主机上的 frps 中转,发送到位于任意内网主机上的 frpc,frpc 再将流量转发到内网主机上的端口。
下文,从 frps 的源码,本部分仅介绍暴露和访问 tcp 端口的流程,忽略鉴权和插件等细节。
frps 启动流程
- cmd/frps/main.go:27: frps main 函数,最终会调用
rootCmd.RunE
。 rootCmd.RunE
:- 首先,读取从命令行和配置文件读取配置。
- 最终,调用
svr.Run
svr.Run
: 调用svr.HandleListener
,接收来自客户端的请求,支持多种协议:kcp、websocket、TCP(tls)、TCP,下文主要介绍的是基于 TCP 协议。svr.HandleListener
: Accept 等待客户端的连接。具体连接处理流程参见下文。
frps 连接处理
该小结主要介绍 frpc 和 frps 之间进行端口暴露的流程。
tcp_mux
当 frpc 启动后,在需要暴露端口时,会建立和 frps 建立一个连接。此时 svr.HandleListener
Accept 会返回 net.Conn
,并会启动一个协程来处理请求,处理流程分为两种情况:
common.tcp_mux
配置如果为 true (默认),该net.Conn
将会通过yamux
进行建立一个 Server 侧的yamux.Session
,并等待客户端的在该 Session 建立逻辑连接,yamux.Session
获取到net.Conn
后,会启动一个协程调用svr.handleConnection
进行处理。common.tcp_mux
配置如果为 false,该net.Conn
会直接调用svr.handleConnection
进行处理。
说明:这里很关键,如果 tcp_mux
位 true, frpc 和 frps 间每个暴露的端口,只建立一个 tcp 连接,访问该端口的所有的逻辑连接的流量都在该物理连接上利用 yamux
多路复用起在该 tcp 连接上进行传输。也就是说,在操作系统层面,只能看到一个连接 TCP 连接。否则,每个请求 frpc 和 frps 之间会都会建立一个 tcp 连接。
连接类型
从 svr.handleConnection 可以看出,每个链接建立后,客户端会发送一个握手消息,这个消息标识了,frpc 和 frps 间的三种连接类型:
msg.Login
控制连接msg.NewWorkConn
工作连接msg.NewVisitorConn
访问连接
其中,访问连接应该是为了实现端到端加密场景使用的(stcp、sudp),在此不多深究了。
控制连接
针对 msg.Login
控制连接,会进入 svr.RegisterControl
进行处理,主要流程为:
- 进行权限校验
- 构造并启动一个控制器
ctl.Start
:- 回复 frpc 登录成功。
- 通过该控制连接给 frpc 发送命令,让 frpc 建立多个工作连接(即工作连接池)。
- 启动
ctl.manager
协程,该协程会读取 frpc 通过该控制连接发送给 frps 的一些消息,并处理,细节参见下文。 - 启动
ctl.reader
协程,读取控制连接发送的原始数据流,解析成控制命令,并通过 chan 交由ctl.manager
处理。 - 启动
ctl.stoper
协程,等待关闭信号,用于关闭并清理资源。
在此,重点介绍 ctl.manager
,即控制器的核心逻辑,主要处理三种类型消息:
msg.NewProxy
新建一个 Proxy,frpc 在接收到登录成功,会根据暴露端口的配置,告知 frps 创建一个指定类型的 proxy,逻辑位于 ctl.RegisterProxy,参见下文。msg.CloseProxy
关闭一个 Proxy。msg.Ping
心跳消息。
建立 proxy 的 ctl.RegisterProxy
主要流程如下:
proxy.NewProxy
初始化一个 Proxy 对象,以 TCP 为例,将构造一个proxy.TCPProxy
对象(其中核心参数为ctl.GetWorkConn
,后文有介绍)。- 然后调用
pxy.Run()
,以proxy.TCPProxy
为例:会在 frps 所在主机上监听一个 TCP 端口,并启动一个协程,该协程会 Accept 连接到该端口的 TCP 连接。这个端口就是提供给用户访问的端口,处理逻辑参见:访问 frps 暴露的端口.
工作连接
针对 msg.NewWorkConn
控制连接,会进入 svr.RegisterWorkConn
进行处理,主要流程为:
- 将该工作连接发送给
ctl.workConnCh
通道。 ctl.WorkConnCh
通道的接受处理函数位于ctl.GetWorkConn
,细节参见:访问 frps 暴露的端口。
访问 frps 暴露的端口
上文 连接处理 介绍了 frpc 和 frps 之间进行暴露端口的流程。本部分将介绍,端口暴露到 frps 的主机后,用户访问该端口的流程(以 TCP 为例)。
如上文提到,这个端口的处理函数函数位于: pxy.startListenHandler
当用户建立连接后,会调用:HandleUserTCPConnection
进行处理:
- 调用
pxy.GetWorkConnFromPool
函数,获取一条和 frpc 之间的工作连接。上文可以得知,该函数的实现位于ctl.GetWorkConn
。- 从
ctl.WorkConnCh
获取 工作连接 该连接是由svr.RegisterWorkConn
发送的。 - 调用
frpIo.Join
相互拷贝两个连接,完成流量从 frps 转发到 frpc。
- 从
frpc 创建好工作连接后,会调用 HandleTCPWorkConnection 函数,最终使用 net.Dial
打开一个访问 127.0.0.1 对应本地端口的本地连接。并调用 frpIo.Join
进行工作连接和该本地连接的进行相互拷贝。
至此流量就进入了本地的服务中了。
总结
上图介绍了一个 tcp 端口暴露到公网以及访问的主要流程,需要注意的是:
- 忽略鉴权和插件相关细节。
- 假设没有启用连接池的场景。
2.3
多条工作连接强调的是存在并发访问时,会创建多条工作连接,但:- 每个
2.2 请求建立工作连接
只会建立一个连接。 - 一个请求只会用到一个工作连接。
- 每个
- 如果开启了
tcp_mux
(默认),上图控制连接和工作连接都是跑在 yamux 封装的一条 TCP 连接。
其他说明
- Go 的 io.Copy 只会单向拷贝,在双向拷贝的场景可以参考:
frpIo.Join
。 tcp_mux
是多路复用,具体细节参见 hashicorp/yamux 实现。tcp_mux
可能存在无法跑满带宽的问题,具体参见:issue。- 如何想把 websocket 连接作为原生的
net.Conn
处理,建议使用golang.org/x/net/websocket
库,而非 github.com/gorilla/websocket。