详细阅读FRP-1-未完待续

写Go好几年,基本没有看过开源项目代码,只是搬砖,说来惭愧,偶然和一个朋友开启Side Project,基于开源项目 FRP 用来做一个内网穿透功能的工具,此项目在Github获得10.7K star,属于是超级硬核Project了。阅读一下大佬的源码,梳理一下思路,顺便写一下文章记录。

前言

由于官方好像并没有文档,所以知乎上找了几篇文章,基于参考文档的思路,往下继续梳理。

基础认知

frp属于C/S架构,实现的功能是将内网某个端口提供的服务通过公网服务器的辅助,穿透绑定到公网IP的某个开放端口,提供给他人使用。Server端要求是放在公网服务器,Client端放在内网环境,能联网,但没有公网IP。所以基础逻辑是,两者在登录完成后 建立长连接,而由于Client的网络环境,导致Server侧是无法向Client直接发送消息的,除非是建连之后,通过长连接发送消息。

详细介绍

有了基础的认知以后,我们来看一下代码。 Client端和Server端 都是使用这个库来封装的,github.com/spf13/cobra 这个库是一个Go做命令行工具比较常用的库。

C端

多个代理配置

抛去cobra框架的代码不看,主要的业务逻辑,是来自 这里: cmd/frpc/sub/root.go:runClient 这个方法接收一个配置,去启动每一个单独的代理配置,传入cfgDir的情况下
使用runMultipleClients并发去起多个goroutinue,并在协程内部调用 runClient 启动每个配置内的代理,本质上还是调用的一个方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
func runMultipleClients(cfgDir string) error {
var wg sync.WaitGroup
err := filepath.WalkDir(cfgDir, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return nil
}
wg.Add(1)
time.Sleep(time.Millisecond)
go func() {
defer wg.Done()
err := runClient(path)
if err != nil {
fmt.Printf("frpc service error for config file [%s]\n", path)
}
}()
return nil
})
wg.Wait()
return err
}

如果是多个client,那么程序会阻塞在这里,等待进程退出的信号,优雅关闭协程,回到cobra的命令行提示符,或是退出程序。

启动单个代理

从配置文件加载到配置,并且在内部调用了 每个config struct的 Complete方法 设定初始值。每一个 client的配置分为 common_cfg,proxyCfgs, visitorCfgs 三种,每一种都会有不同的用途。

1
2
3
4
5
6
7
8
9
10
func runClient(cfgFilePath string) error {
cfg, proxyCfgs, visitorCfgs, isLegacyFormat, err := config.LoadClientConfig(cfgFilePath, strictConfigMode)
if err != nil {
return err
}
if err != nil {
return err
}
return startService(cfg, proxyCfgs, visitorCfgs, cfgFilePath)
}

cfg是 client的连接信息,代表这个客户端和服务端的连接配置,一般放在 client配置里,比如 serverAddr, serverPort, TLS, transport 等这些信息,他是长连接的配置,比如说 Server端的地址和端口,传输配置等等。

而 proxyCfgs 则是 每一个代理通道配置,你比如一个TCP服务端口80,另外一个TCP服务端口81,再有一个HTTPS服务端口443,一个SSH服务端口22,等等,每个proxyCfg指向的Client地址端口背后一定要有个对应的服务,这样子才能对外映射,如果这个端口没有监听对应协议的服务,并不会报错,只是你这个通道就没有用。

这里的内容还没有确认,官方也没有文档描述他,所以我暂时只能靠我自己的理解去描述。
visitorCfgs 暂时我还没了解到,就暂时不管,下面放一个我按照他配置文件理解来的含义。
visitorCfgs 的作用按照配置文件来看的话,意思是比如你映射了一个http的网站或服务,这个网站是要求对外提供服务的,那使用你这个服务的人 都叫做visitor,那么每个 visitor 可以配置一个角色 以及用户名密码做鉴权。 不过这个不重要,等真的了解到哪个地步,再说。

startService 传入了 配置,并且把配置路径也给传递给service对象,方便后面使用。

startService方法,判断协议 是 kcp 或是 quic时,注册优雅关闭协程的 channel,订阅 SIGINT/SIGTERM 这两个信号,出现信号 将 当前 ctx 取消,此时当前请求的父子请求 均会被取消,当前程序即可退出。

startService 方法中 注册的 webServer 并不是我们期望的主服务器,后续调用 Run方法时,才是启动主服务器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
func (svr *Service) loopLoginUntilSuccess(maxInterval time.Duration, firstLoginExit bool) {
xl := xlog.FromContextSafe(svr.ctx)

loginFunc := func() (bool, error) {
xl.Infof("try to connect to server...")
conn, connector, err := svr.login()
if err != nil {
xl.Warnf("connect to server error: %v", err)
if firstLoginExit {
svr.cancel(cancelErr{Err: err})
}
return false, err
}

svr.cfgMu.RLock()
proxyCfgs := svr.proxyCfgs
visitorCfgs := svr.visitorCfgs
svr.cfgMu.RUnlock()
connEncrypted := true
if svr.clientSpec != nil && svr.clientSpec.Type == "ssh-tunnel" {
connEncrypted = false
}
sessionCtx := &SessionContext{
Common: svr.common,
RunID: svr.runID,
Conn: conn,
ConnEncrypted: connEncrypted,
AuthSetter: svr.authSetter,
Connector: connector,
}
ctl, err := NewControl(svr.ctx, sessionCtx)
if err != nil {
conn.Close()
xl.Errorf("NewControl error: %v", err)
return false, err
}
ctl.SetInWorkConnCallback(svr.handleWorkConnCb)

ctl.Run(proxyCfgs, visitorCfgs)
// close and replace previous control
svr.ctlMu.Lock()
if svr.ctl != nil {
svr.ctl.Close()
}
svr.ctl = ctl
svr.ctlMu.Unlock()
return true, nil
}

// try to reconnect to server until success
wait.BackoffUntil(loginFunc, wait.NewFastBackoffManager(
wait.FastBackoffOptions{
Duration: time.Second,
Factor: 2,
Jitter: 0.1,
MaxDuration: maxInterval,
}), true, svr.ctx.Done())
}

Run方法中,比较重要的是 loopLoginUntilSuccess 这个方法,会执行登录操作,并从 Server拿到 runID,进行token校验等操作。这个是个TCP协议的接口。

他是我们Client和 Server建立连接的第一个请求,如果失败则无法建立通信,只有成功才可以建立,作者也知道这个方法很重要,使用了 Backoff 来 循环调用login,直到成功。
当登录成功后,会创建一个 Control 对象,并且执行Run方法,Run方法会启动本地Client端口监听,代表连接正式建立,然后执行 handleWorkConnCb 方法,这个方法会启动一个协程,用来监听本地端口,然后通过conn发送给Server,等待Server返回一个workConn,然后通过workConn和本地端口进行通信。


未完待续…

参考文档

https://jiajunhuang.com/articles/2019_06_11-frpc_source_code_part1.md.html

https://jiajunhuang.com/articles/2019_06_19-frp_source_code_part2.md.html