Present Day, Present Time

gobomb

ngrok 源码解析

背景

ngrok是我第一个完整阅读过源码的开源项目。一开始接触这套代码我几乎还是 go 语言零基础,之前只写过一点点 Web 后端 API,后来趁着做毕业设计的机会还跟着源码重新敲了一遍,按照我自己的需要小改了一下。所以我从中收获了不少东西,一直都想写一篇文章总结一下我对这套代码的理解。

ngrok 的目的是将本地的端口反向代理到公网,让你可以通过访问公网某个服务器,经过流量转发,访问到内网的机器。这个事情你可以有不同的叫法:反向代理、端口转发(映射)、内网穿透1……原理其实不难,解决的需求也简单。

我们个人的设备一般都在 NAT (公司、学校内网,运营商也会喜欢做 NAT)后面,不能被公网设备直接访问到。内网机器可以主动向公网发起连接,但公网却不能穿越 NAT 主动访问到内网机器。ngrok 就适合用来穿越 NAT 来临时暴露内网服务。在技术人员常聚集的社区 V2EX,经常能看到问怎么做内网穿透的月经贴。可见这个需求十分常见。

内网穿透的工具,现在大家用的比较多的是 frp了。frp 是中国人开发的,增加了很多新的功能,使用体验也比 ngrok 好很多,社区比较活跃。ngrok 虽然出现得比较早,但 2.0 转闭源, 1.0 不再维护了,用得人也少了。但这不妨碍我们扒源码学习。

项目结构

先来看一下 ngrok 整个项目的结构(忽略掉了一些不必要的文件):

.
├── ...
├── assets							// 存放 web 静态文件和 tls 文件
│   ├── ...
└── src
    └── ngrok
        ├── cache					// 缓存
        │   └── lru.go
        ├── client
        │   ├── cli.go				// 命令行参数定义
        │   ├── config.go			// 配置文件读取
        │   ├── controller.go		// MVC 控制器
        │   ├── main.go				// 程序入口
        │   ├── metrics.go			// 性能数据收集
        │   ├── model.go			// ClientModel,主要的转发逻辑
        │   ├── mvc					// MVC 接口
        │   │   ├── ...
        │   ├── tls.go				// tls 证书加载
        │   └── views				// view 层包括 Web 和终端 
        │   │   ├── ...
        |   |──...
        ├── conn						// tcp 连接相关的操作:标记连接的类型(http/tcp),发起监听(Listen)、发起主动连接(Dial),以及关键的交换两个连接的数据(Join)
        │   ├── conn.go
        │   └── tee.go
        ├── log						// 日志
        │   └── logger.go
        ├── main						// 程序入口
        ├── msg						// 消息的序列化和反序列化
        │   ├── conn.go
        │   ├── msg.go
        │   └── pack.go
        ├── proto					// 主要被 cli/model.go 调用
        │   ├── http.go
        │   ├── interface.go
        │   └── tcp.go
        ├── server
        │   ├── cli.go				// 命令行参数的解析
        │   ├── control.go			// control 的注册、代理连接的注册
        │   ├── http.go				// http 的监听和处理
        │   ├── main.go				// 程序入口:各资源池的初始化、监听控制连接和代理连接
        │   ├── metrics.go			// 性能数据统计
        │   ├── registry.go		// tunnel/control 池的维护、tunnel/control 实例的增删查
        │   ├── tls.go				// 读取 tls 配置
        │   └── tunnel.go			// tunnel 的创建和关闭、实现公有连接和代理连接之间的匹配
        ├── util
        │   ├── broadcast.go		// 被客户端的 MVC 模型用来更新数据
        │   ├── errors.go			// 错误处理
        │   ├── id.go				// 唯一 ID 的生成
        │   ├── ...
        │   └── shutdown.go		// 通过锁、channle、defer实现的关闭机制
        └── version
            └── version.go			// 输出版本号

服务端代码

我们从 main 函数开始来过一下服务端 ngrokd 的关键代码(为了简洁,以下代码大都忽略掉了错误处理和资源清理的语句;正文引用到的函数签名则省略了参数):

func Main() {
	...
	// init tunnel/control registry
	registryCacheFile := os.Getenv("REGISTRY_CACHE_FILE")
	// 初始化 tunnelRegistry 用来注册 tunnel
	tunnelRegistry = NewTunnelRegistry(registryCacheSize, registryCacheFile)
	// 初始化 controlRegistry 用来注册多个客户端
	controlRegistry = NewControlRegistry()

	// 初始化一个监听池,保证可以通过协议名来找到对应的监听
	listeners = make(map[string]*conn.Listener)
	...
	// 监听来自公网的http请求。https 和 http 的逻辑是类似的,只是多了一个加载 tls 配置的步骤,这里就先省略 https 的部分。
	if opts.httpAddr != "" {
		listeners["http"] = startHttpListener(opts.httpAddr, nil)
	}
	...
	// 监听控制连接和建立代理连接的请求
	tunnelListener(opts.tunnelAddr, tlsConfig)
	}

在这里一个control对应一个 ngrok 客户端,每个客户端可能处于不同的 NAT 之后。客户端会主动连接tunnelAddr端口,向服务端建立控制连接,注册自己,并保持心跳。通过控制连接两端会互相发送一些控制信息`,比如说开始新的代理、关闭连接、认证等等。

tunnel维护逻辑上的端口映射,分为两种:TCP 和 HTTP/HTTPS。一个 TCP 映射的路径是:服务端端口--tunnelAddr<--客户端-->内网目的端口,HTTP :服务端http端口(一般是80)--tunnelAddr<--客户端-->内网http服务tunnel记录了必要的信息,保证你从公网访问这个服务端端口/服务端http端口的时候,ngrokd 能够找到对应的客户端和内网里真正的被代理端口。(这里的箭头表明实际连接发起的方向,可以看到所有的连接,不管对内还是对外,都是客户端发起的)

ngrokd 启动的时候,会暴露三个主要端口,一个(httpAddr)用来监听 HTTP 连接,一个(httspAddr)监听 HTTPS 连接,最后一个(tunnelAddr)用来监听两类连接:控制连接代理连接。后面当tunnel注册的时候,还会随机或由客户端指定一些端口,用以给提供公网侧的 TCP 代理。

我们分三个阶段来分析 ngrokd 做的工作:

  1. 注册阶段
  2. 建立代理连接阶段
  3. 转发阶段

注册阶段

在这个tunnelListener()函数里,其实根据控制信息的不同做了分流,分别是注册阶段NewControl())和建立代理连接阶段NewProxy())的入口:

func tunnelListener(addr string, tlsConfig *tls.Config) {
	// 这里监听 tunnelAddr,进来的连接会被打上 tun 的标记,然后放到 listener.Conns 的 channel 里
	listener, err := conn.Listen(addr, "tun", tlsConfig)
	...
	// 而在这里则从上述的 listener.Conns 里取出连接,针对每个连接起一个 goroutine 并发地处理
	for c := range listener.Conns {
		go func(tunnelConn conn.Conn) {
			...
			// 从连接里读取控制信息
			var rawMsg msg.Message
			if rawMsg, err = msg.ReadMsg(tunnelConn); err != nil {
				tunnelConn.Warn("Failed to read message: %v", err)
				tunnelConn.Close()
				return
			}
			...
			// 根据不同的控制信息做不同的操作
			switch m := rawMsg.(type) {
			// 注册一个新的 control(进入注册阶段)
			case *msg.Auth:
				NewControl(tunnelConn, m)
			// 请求建立新的代理连接(进入建立代理连接阶段),后面再解释
			case *msg.RegProxy:
				NewProxy(tunnelConn, m)

			default:
				tunnelConn.Close()
			}
		}(c)
	}
}

- NewContorl() 做了什么

NewContorl(ctlConn conn.Conn, authMsg *msg.Auth)做了这么一些事情:

  1. 认证

  2. 建立control实例c,上面的参数ctlConn 会被作为c的属性c.conn,而control实例c会被注册到controlRegistry

  3. 启动发送消息的 goroutine,用来把从c.out读到的消息通过c.conn发给客户端:

    go c.writer()
    

    ``

  4. 要求客户端发起代理连接,将控制消息写到c.out里,这时候c.writer()就会异步地读取c.out里的信息

    c.out <- &msg.ReqProxy{}
    

    ``

  5. 启动三个 goroutine:c.reader()负责从控制连接里读信息并写到c.inc.manager()负责从c.in里读消息并做对应的操作;c.stopper()等待停止的信号,负责回收所有资源,包括把control实例从control池移除:controlRegistry.Del(c.id)

go c.manager() go c.reader() go c.stopper() ```

- goroutine 和 chan 的配合

这里的 goroutine 和 chan 的用法很经典。control实例里有两个chan,分别是inout,用来达成不同 goroutine 之间的通信。

比如上面提到的“要求客户端发起代理连接”的操作,消息的传递路径是这样的:

NewContorl()goroutine 内,msg.ReqProxy{}被塞到c.out这个 chan 里:

	c.out <- &msg.ReqProxy{}

c.writer()goroutine 内,不断从c.out读,然后把读到的信息m写到 c.conn(也就是该control对应的控制连接)里:

	for m := range c.out {
		c.conn.SetWriteDeadline(time.Now().Add(controlWriteTimeout))
		...
	}

而读取消息的路径是c.conn-->c.read()-->c.in-->c.manager()。各个 goroutine 都是并发、异步、解耦的,中间通过 channel 串联起来,十分优雅。在各个 chan 没有消息的时候,range操作是阻塞着的,但因为 goroutine 是异步的,所以不会影响到其他的 goroutine,go 的 runtime 会帮你做好各个执行流的调度。

还值得一提的是它的停止机制。c.stopper()利用了 chan 的一个特性:一旦 chan 被关闭,range chan的操作就会退出。如果我们把每个 goroutine “绑定”的 chan 都关闭了,实际上就解除了range chan的阻塞和循环状态,相当于关闭了对应的 goroutine。

// range c.out 的三种状态
for m := range c.out{			// 1. c.out 为空:阻塞
	...				// 2. 从 c.out 读到 m:执行大括号里的逻辑
}					
// 其他代码			
	...				// 3. c.out 被关闭,继续执行下面的代码,直到该函数结束

所以c.stopper()一旦接受到 stop 的指令,就会把所有相关的 chanc.in``c.out)关闭,则c.read()``c.manager()``c.writer()goroutine也就都执行完毕了,从而达到回收changoroutine资源的目的。

- c.manager() 做了什么

两件事:

  1. 和客户端维持心跳
  2. 注册tunnel
func (c *Control) manager() {
	...
	// 实例化一个计时器,每10秒发送一次消息到 reap.C
	reap := time.NewTicker(connReapInterval)
	defer reap.Stop()

	for {
		select {
		// 每10秒 reap.C 就会有内容到达,这里检查客户端发送过来的心跳包,如果大于30秒,就启动停止流程
		case <-reap.C:
			if time.Since(c.lastPing) > pingTimeoutInterval {
				c.conn.Info("Lost heartbeat")
				c.shutdown.Begin()
			}
		// 从 c.in 读取消息
		case mRaw, ok := <-c.in:
			// c.in 若被关闭,ok 的值是 false,直接结束该函数(gorotine)
			if !ok {
				return
			}
			// 根据控制消息的类型,作对应操作
			switch m := mRaw.(type) {
			// 收到来自客户端的 tunnel 信息,注册 tunnel
			case *msg.ReqTunnel:
				c.registerTunnel(m)
			// 发送心跳包(报告当前时间)
			case *msg.Ping:
				c.lastPing = time.Now()
				c.out <- &msg.Pong{}
			}
		}
	}
}

go 标准库里的计时器也是很典型的 channel 的应用。

- registerTunnel() 做了什么

registerTunnel()里,会检查tunnel的类型,做出不同的处理:

  1. 如果是 TCP 类型,则监听客户端指定的公网侧端口;如果指定端口号为0,会随机分配一个。每个TCP tunnel都会分配一个唯一的端口,也就是说我们会通过端口号来区分 TCP 类型的tunnel
  2. 如果是 HTTP/HTTPS 类型,则是共用端口(一般是80和443)。通过不同的 URL 来区分tunnel,URL 可能是自己指定的域名、当前服务器域名的指定子域名,或者当前服务器域名的随机子域名。

处理完tunnel,就将tunnel都注册到tunnelRegistry中。并向客户端报告注册成功。

至此,注册阶段就完毕了。此时服务端的状态是:

  1. 通过客户端的主动连接,知道了客户端的存在,为它注册了一系列资源;
  2. 向客户端请求了一条代理连接;
  3. 获知了客户端想要代理的服务信息(tunnel);
  4. 通过心跳包与客户端保持了联络,保证控制连接不断。

建立代理连接阶段

从上述注册阶段提到tunnelListener()控制连接里的消息对连接进行分流,当从控制连接收到*msg.RegProxy时,将进入NewProxy(tunnelConn, m)方法注册一条新的代理连接,这条连接会被存进对应的control实例所拥有的一个 channel 里:c.proxies <- conn。当需要进行转发和代理的时候,这个 channel 就会被消费。

转发阶段

真正的转发过程就比较简单了。服务端从公网侧端口得到用户主动发起的公网连接(pubConn),会调用 tunnel 的HandlePublicConnection方法,从 control 的proxies channel 里得到一条代理连接(如果 channel 为空,则再走一次建立代理连接阶段,让客户端再次发起一个代理连接),接着通过代理连接通知客户端:准备开始转发数据,然后用一个conn.Join(publicConn, proxyConn)方法,交换公网连接和代理连接的数据。这样,用户发送的数据就会被转发给客户端,客户端转发给真正的内网服务;从客户端发送过来的数据,也反过来转发给用户。

conn.Join()的代码如下,客户端的转发也调用了此方法:

func Join(c Conn, c2 Conn) (int64, int64) {
	var wait sync.WaitGroup

	pipe := func(to Conn, from Conn, bytesCopied *int64) {
		defer to.Close()
		defer from.Close()
		defer wait.Done()

		var err error
		*bytesCopied, err = io.Copy(to, from)
		if err != nil {
			from.Warn("Copied %d bytes to %s before failing with error %v", *bytesCopied, to.Id(), err)
		} else {
			from.Debug("Copied %d bytes to %s", *bytesCopied, to.Id())
		}
	}

	wait.Add(2)
	var fromBytes, toBytes int64
	go pipe(c, c2, &fromBytes)
	go pipe(c2, c, &toBytes)
	c.Info("Joined with connection %s", c2.Id())
	wait.Wait()
	return fromBytes, toBytes
}

可以看到,最终是在两个 goroutine 里调用官方库函数io.Copy来异步地复制数据。

客户端代码

客户端的代码就比较简单一点。客户端采用 MVC 的架构,因为客户端除了与服务端、内网服务进行交互,还有与用户交互的界面。这里主要是 terminal 和 web。与用户交互的部分我不想详细介绍,主要讲一讲与服务端相关的部分。

客户端也是创建一个ClientModel对象实例来和服务端进行交互,在ClientModelcontrol()方法中,客户端向服务端的tunnelAddr端口建立连接,发送认证消息注册自己,再发送用户指定的tunnel信息,同时创建一个 goroutine 用来维持心跳,保持此条控制连接不断开。这里对应服务端的注册阶段

如果服务端要求建立代理,客户端就向tunnelAddr端口新建一条代理连接(proxyConn)。并在此代理连接中等待服务端通知代理开始的信号,一旦接受到信号,就向对应的内网服务建立连接(localConn),作出请求。和服务端转发阶段类似,这里会调用conn.Join(localConn, remoteConn)交换两条连接的数据。用户向服务端的请求,就在这里被转发到内网服务,内网服务的响应,也在这里原路返回。

func (c *ClientModel) proxy() {
	...
	// 建立代理连接
	if c.proxyUrl == "" {
		remoteConn, err = conn.Dial(c.serverAddr, "pxy", c.tlsConfig)
	} else {
		remoteConn, err = conn.DialHttpProxy(c.proxyUrl, c.serverAddr, "pxy", c.tlsConfig)
	}
	...

	// 向服务端注册代理连接
	err = msg.WriteMsg(remoteConn, &msg.RegProxy{ClientId: c.id})
	...
	
	// 从代理连接中收到服务端开始代理的通知
	var startPxy msg.StartProxy
	if err = msg.ReadMsgInto(remoteConn, &startPxy); err != nil {
		remoteConn.Error("Server failed to write StartProxy: %v", err)
		return
	}

	// 通过 URL 找到对应的 tunnel,以及相应的内网服务
	tunnel, ok := c.tunnels[startPxy.Url]

	// 向内网服务发起连接
	start := time.Now()
	localConn, err := conn.Dial(tunnel.LocalAddr, "prv", nil)
	...
	
	m.connTimer.Time(func() {
		...
		// 内网连接和代理连接交换数据
		bytesIn, bytesOut := conn.Join(localConn, remoteConn)
		...
	})
}

总结

首先必须一提的就是 goroutine 和 channel,如上文提到的通过 channel 来传递消息和停止 goroutine,都是比较巧妙的用法,

在 ngrok 中,另一个被大量用到的用法就是type-switch了。往往我们从 chan 里拿到的数据结构,是一个空接口interface{},我们不确定它的类型,需要通过类型断言来得到它的类型。在网络编程中,经常会用到这个。

如果对网络编程有兴趣,或者对 go 有兴趣,不妨读一读这套代码。作为一个用于入门的小项目,难度不会过高,又有比较符合 go 风格的代码以快速学习,同时也可以熟悉一下 TCP 编程。用 go 进行网络编程有两个好处,一个是标准库一般足够用,网络相关的开发,使用官方标准库就足以应付大部分场景,另一个是并发的学习曲线低。


  1. ngrok 和 frp 这一类穿透,有两个缺点:1. 一直需要中间服务器;2. 转发很耗费流量。实现内网穿透还可以有其他方式:P2P、探测端口等方法,也有其利弊,本文就暂不讨论了。 ↩︎