好久没阅读源码学习了,闲暇之余,找了下frp早期源码看了看并做下阅读记录。
简介
frp 是一个专注于内网穿透的高性能的反向代理应用,支持 TCP、UDP、HTTP、HTTPS 等多种协议,且支持 P2P 通信。可以将内网服务以安全、便捷的方式通过具有公网 IP 节点的中转暴露到公网。
发展
FRP项目从github仓库来看,最早起源在2016年,作者发布了v 0.1.0版本,也是最早FRP的雏形,截止到现在,已经发布了104个release,当前版本为v 0.62.1.
前言
FRP从最早的C/S命令行格式,发展到现在的web应用,其代码的架构已经发生了很大的变化,整体可以从作者的代码文件结构上看出很大的区别,那么本文主要还是通过作者早期写的代码来查看FRP的工作原理,是如何实现内网穿透。
代码分析
文件结构
从代码的大致文件结构可以看出,第一版的FRP代码是比较简约的,比较通俗易懂。
在src文件夹下,各个文件夹的功能分工明确,cmd主要是用于工具的入口,model模块则是客户端和服务端实现相应功能的具体实现,如客户端/服务端配置信息,请求和返回信息的结构等等。
utils则是工具类,如网络连接,日志处理,协议定义等等。
conf文件夹则是客户端或者是服务端需要加载的配置文件。
阅读方式
整体代码主要是围绕工具的核心功能进行分析,如连接处理、加密处理、服务端和控制的连接处理进行分析,来了解其功能特点。
配置文件
服务器配置文件
# common是必须的section
[common]
bind_addr = 0.0.0.0
bind_port = 7000
log_file = ./frps.log
# debug, info, warn, error
log_level = debug
# file, console
log_way = console
# test1即为name
[test1]
passwd = 123
bind_addr = 0.0.0.0
listen_port = 6000
-
[common]
部分:这是公共配置,bind_addr
表示服务器监听的地址,0.0.0.0
表示监听所有地址;bind_port
是服务器监听的端口,这里是7000;log_file
指定日志文件的位置;log_level
是日志的级别,debug
表示输出详细的调试信息;log_way
是日志输出的方式,console
表示输出到控制台。 -
[test1]
部分:这是一个代理服务的配置,passwd
是客户端连接的密码,listen_port
是服务器为这个代理服务监听的端口,这里是6000。
客户端配置文件
# common是必须的section
[common]
server_addr = 127.0.0.1
server_port = 7000
log_file = ./frpc.log
# debug, info, warn, error
log_level = debug
# file, console
log_way = console
# test1即为name
[test1]
passwd = 123
local_port = 22
-
[common]
部分:server_addr
是服务器的地址,这里是本地地址;server_port
是服务器监听的端口,要和服务器配置中的bind_port
一致;其他日志相关配置和服务器类似。 -
[test1]
部分:passwd
要和服务器配置中的密码一致,local_port
是内网服务的端口,这里是22,通常是SSH服务的端口。
核心功能
连接处理(conn.go)
在conn.go文件中,作者定义了一个Listen的公共函数,负责监听和处理网络连接。
funcListen(bindAddr string, bindPort int64)(l *Listener, err error) {
tcpAddr, err := net.ResolveTCPAddr("tcp4", fmt.Sprintf("%s:%d", bindAddr, bindPort))
listener, err := net.ListenTCP("tcp", tcpAddr)
if err != nil {
return l, err
}
l = &Listener{
addr: listener.Addr(),
l: listener,
conns: make(chan *Conn),
closeFlag: false,
}
gofunc() {
for {
conn, err := l.l.AcceptTCP()
if err != nil {
if l.closeFlag {
return
}
continue
}
c := &Conn{
TcpConn: conn,
closeFlag: false,
}
c.Reader = bufio.NewReader(c.TcpConn)
l.conns <- c
}
}()
return l, err
}
-
首先, Listen
函数会将传入的地址和端口解析成TCP地址,然后开始监听这个地址和端口。 -
接着,创建一个 Listener
对象,这里面有一个通道conns
,用于存储新的连接。 -
最后,启动一个goroutine(可以理解为一个轻量级的线程),不断接受新的连接,将其封装成 Conn
对象,然后发送到conns
通道中。
加密处理(pcrypto.go)
自定义加密模块中,一共通过三次加密来保证数据的安全性(有点像现阶段FRP通信协议的雏形,就是少了一些字段验证)
func(pc *Pcrypto)Encrypto(src []byte)([]byte, error) {
// aes
src = PKCS7Padding(src, aes.BlockSize)
blockMode := cipher.NewCBCEncrypter(pc.paes, pc.pkey)
crypted := make([]byte, len(src))
blockMode.CryptBlocks(crypted, src)
// gzip
var zbuf bytes.Buffer
zwr := gzip.NewWriter(&zbuf)
defer zwr.Close()
zwr.Write(crypted)
zwr.Flush()
// base64
return []byte(base64.StdEncoding.EncodeToString(zbuf.Bytes())), nil
}
-
加密过程分为三步: -
第一步,对数据进行PKCS7填充,方便后续的加密操作。 -
第二步,使用AES的CBC模式进行加密,将数据变成密文。 -
第三步,使用gzip进行压缩,减少数据的体积,最后进行Base64编码,将二进制数据转换为可打印的文本。
funcLoadConf(confFile string)(err error) {
var tmpStr string
var ok bool
conf, err := ini.LoadFile(confFile)
if err != nil {
return err
}
// common
tmpStr, ok = conf.Get("common", "bind_addr")
if ok {
BindAddr = tmpStr
}
// 其他配置代码,整体也是使用get方法来获取
// servers
for name, section := range conf {
if name != "common" {
proxyServer := &ProxyServer{}
proxyServer.Name = name
proxyServer.Passwd, ok = section["passwd"]
if !ok {
return fmt.Errorf("Parse ini file error: proxy [%s] no passwd found", proxyServer.Name)
}
// 同理
proxyServer.Init()
ProxyServers[proxyServer.Name] = proxyServer
}
}
iflen(ProxyServers) == 0 {
return fmt.Errorf("Parse ini file error: no proxy config found")
}
returnnil
}
-
首先,使用 ini.LoadFile
函数加载配置文件。 -
然后,读取 common
部分的配置,将配置项的值赋给相应的变量。 -
接着,遍历所有的代理服务器配置,创建 ProxyServer
对象,读取配置项的值并初始化对象,最后将对象存储在ProxyServers
映射中。
控制连接处理(control.go)
服务器端(frps/control.go)
服务器端的ProcessControlConn
函数,负责处理客户端的连接请求。
funcProcessControlConn(l *conn.Listener) {
for {
c, err := l.GetConn()
if err != nil {
return
}
log.Debug("Get one new conn, %v", c.GetRemoteAddr())
go controlWorker(c)
}
}
-
不断从监听的连接中获取新的连接,如果出现错误则返回。 -
每获取到一个新的连接,就启动一个新的 controlWorker
函数来处理这个连接。
controlWorker
函数具体处理连接的细节:
funccontrolWorker(c *conn.Conn) {
// 读取客户端发送的消息
res, err := c.ReadLine()
if err != nil {
log.Warn("Read error, %v", err)
return
}
log.Debug("get: %s", res)
// 解析消息
clientCtlReq := &msg.ClientCtlReq{}
if err := json.Unmarshal([]byte(res), &clientCtlReq); err != nil {
log.Warn("Parse err: %v : %s", err, res)
return
}
// 检查代理信息
succ, info, needRes := checkProxy(clientCtlReq, c)
if !succ {
clientCtlRes := &msg.ClientCtlRes{}
clientCtlRes.Code = 1
clientCtlRes.Msg = info
buf, _ := json.Marshal(clientCtlRes)
err = c.Write(string(buf) + "n")
if err != nil {
log.Warn("Write error, %v", err)
time.Sleep(1 * time.Second)
return
}
}
// 处理其他消息
s, ok := server.ProxyServers[clientCtlReq.ProxyName]
if !ok {
log.Warn("ProxyName [%s] is not exist", clientCtlReq.ProxyName)
return
}
// 读取客户端的控制消息
go readControlMsgFromClient(s, c)
// 向客户端发送工作连接请求
serverCtlReq := &msg.ClientCtlReq{}
serverCtlReq.Type = consts.WorkConn
for {
closeFlag := s.WaitUserConn()
if closeFlag {
log.Debug("ProxyName [%s], goroutine for dealing user conn is closed", s.Name)
break
}
buf, _ := json.Marshal(serverCtlReq)
err = c.Write(string(buf) + "n")
if err != nil {
log.Warn("ProxyName [%s], write to client error, proxy exit", s.Name)
s.Close()
return
}
log.Debug("ProxyName [%s], write to client to add work conn success", s.Name)
}
log.Info("ProxyName [%s], I'm dead!", s.Name)
return
}
-
首先,读取客户端发送的消息并解析。 -
然后,调用 checkProxy
函数检查代理信息,包括代理名称是否存在、密码是否正确等。 -
如果检查通过,启动一个新的goroutine来读取客户端的控制消息。 -
最后,不断等待用户连接,向客户端发送工作连接请求。
客户端端(frpc/control.go)
客户端的ControlProcess
函数负责与服务器建立连接并处理服务器的消息。
funcControlProcess(cli *client.ProxyClient, wait *sync.WaitGroup) {
defer wait.Done()
c, err := loginToServer(cli)
if err != nil {
log.Error("ProxyName [%s], connect to server failed!", cli.Name)
return
}
connection = c
defer connection.Close()
for {
// 读取服务器消息并处理
content, err := connection.ReadLine()
if err == io.EOF || nil == connection || connection.IsClosed() {
// 处理连接关闭情况
// 尝试重新连接
} elseif err != nil {
log.Warn("ProxyName [%s], read from server error, %v", cli.Name, err)
continue
}
// 解析消息并处理
clientCtlRes := &msg.ClientCtlRes{}
if err := json.Unmarshal([]byte(content), clientCtlRes); err != nil {
log.Warn("Parse err: %v : %s", err, content)
continue
}
if consts.SCHeartBeatRes == clientCtlRes.GeneralRes.Code {
// 处理心跳响应
}
cli.StartTunnel(client.ServerAddr, client.ServerPort)
}
}
-
首先,调用 loginToServer
函数与服务器建立连接。 -
然后,不断读取服务器发送的消息,解析并处理。 -
如果收到心跳响应,进行相应的处理。 -
最后,启动隧道,建立与本地服务的连接。
FRP工作流程
服务器启动
服务器端在main
函数中首先加载配置文件,然后初始化日志,方便记录系统的运行情况,接着监听指定的地址和端口,开始处理控制连接。
funcmain() {
err := server.LoadConf("./frps.ini")
if err != nil {
os.Exit(-1)
}
log.InitLog(server.LogWay, server.LogFile, server.LogLevel)
l, err := conn.Listen(server.BindAddr, server.BindPort)
if err != nil {
log.Error("Create listener error, %v", err)
os.Exit(-1)
}
log.Info("Start frps success")
ProcessControlConn(l)
}
客户端启动
客户端在main
函数中同样先加载配置文件,初始化日志,然后为每个代理客户端启动一个控制处理流程。
funcmain() {
err := client.LoadConf("./frpc.ini")
if err != nil {
os.Exit(-1)
}
log.InitLog(client.LogWay, client.LogFile, client.LogLevel)
// wait until all control goroutine exit
var wait sync.WaitGroup
wait.Add(len(client.ProxyClients))
for _, client := range client.ProxyClients {
go ControlProcess(client, &wait)
}
log.Info("Start frpc success")
wait.Wait()
log.Warn("All proxy exit!")
}
连接建立与数据传输
-
客户端向服务器发送控制连接请求,包含代理名称和密码。 -
服务器验证请求,检查代理名称和密码是否正确,如果不正确则拒绝连接。 -
如果验证通过,服务器启动代理服务,监听用户连接。 -
当有用户连接到服务器的代理端口时,服务器通知客户端建立工作连接。 -
客户端建立与本地服务的连接和与服务器的工作连接,将两个连接进行数据转发,实现内网服务的暴露。
总结
0.1.0版本的核心代码都是使用原生库实现,而配置解析代码使用github.com/vaughan0/go-ini实现,用于获取键对值相关的配置文件。github.com/astaxie/beego/logs则用于代码的日志管理和输出。
编译就没编译了,看了下配置文件go版本是早期的1.4。个人电脑是1.20版本,就不再重新弄环境了。
原文始发于微信公众号(事件响应回忆录):FRP源码阅读,学习FRP工作原理
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论