内网穿透原理及frp核心代码解析

一、前言

内网穿透作为程序员常用的调试手段之一,我们可以通过在个人电脑上运行花生壳或者 frp 等方式,让他人访问我们本地启动的服务,而且这种访问可以不受局域网的限制,当我们使用ngrok,frp等开源框架时,你是否有好奇过它神奇的作用?明明没有将服务部署到服务器,程序员们究竟是怎么通过这种特殊方式让所有人访问自己的主机的?下面将以frp开源框架为例,介绍内网穿透的原理。

二、公网IP与内网IP

能否在公网中访问服务器的决定性因素:公网 IP

1、IP地址的作用

众所周知, IP 地址是每一位使用互联网的网民都会拥有的标识, IP 地址在互联网中起到的作用是定位,通过 IP 地址我们可以精确的定位到所需资源所在的服务器,这是对于一般用户来讲的,而对于程序员而言,我们需要的则是让用户通过 IP 地址定位到我们部署的资源,既然每个互联网用户都拥有 IP 地址,为什么用户无法直接访问部署在个人PC上的服务呢?

1)IP地址分类

事实上, IP 地址分为两种:公网 IP 和内网 IP

  • 内网IP: 内网 IP 是用户在使用局域网时,由局域网的网关所分配的 IP 地址,每一个内网 IP 实际上都可以映射到当前所在局域网网关的某一端口( IPV4 地址通过 NAT 与端口映射方式实现,具体原理下文详解),拥有内网 IP 可以被同一局域网下的其他设备所访问到;
  • 公网IP: 内网的设备想要访问非同一局域网下的资源则必须通过公网 IP ,公网 IP 是没有经过 NAT 转换的由互联网供应商(ISP)提供的最原始的 IP 地址,每一个公网 IP 都可以直接在互联网中被直接定位到。

2)一个最简单的例子(以前端开发为例) 

当我们使用 webpack-dev-server 来启动一个 node 项目时,我们除了通过localhost:[端口号]的方式以外,与我们的开发设备处于同一局域网下的设备可以通过内网 IP :[端口号]的方式对我们的项目进行访问,但当我们使用自己的流量或者连接其他非当前开发设备所在局域网的设备使用内网 IP :[端口号]的方式进行进行访问时,则无法访问。

3)原因

内网 IP 地址仅在当前局域网下可以被定位并访问到,而当我们想要跨局域网访问时,我们的访问请求则需要先映射为公网 IP 然后访问到另一局域网的公网 IP ,最后由另一局域网的网关将其映射到相应的局域网设备,但我们访问的地址属于局域网中的内网 IP ,因此无法定位到其相应的公网 IP

综上所述,当我们想要让处于其他局域网下的设备访问到我们本地资源,必不可缺的就是公网 IP 。

2、公网IP的稀有程度

相较于内网 IP ,公网 IP 明显比内网 IP 更加有用,为什么不可以人手一个公网 IP 呢?

1)IPV4和 IPV6

尽管 IPV6 的概念在几年前已经被提出,但实际的普及程度并没有很高,现在大部分网络用户使用的依旧是 IPV4 的 IP 地址,这也是限制公网 IP 个数的最大原因。

内网穿透原理及frp核心代码解析插图
  • IPV4:IPV4 由 32 位二进制数组成,一共有 2^32 个不同的 IPV4 地址
  • IPV6:IPV6 由 128 位二进制数组成,理论上共有 2^128 个不同的 IPV6 地址

由此可见, IPV4地址的个数并不足以满足当前全世界网络用户的人手一个 IP 地址的需求,那么当前的网络为什么可以让这么多用户同时在网络上冲浪呢?

2)NAT(网络地址转换)技术

网络地址转化技术的核心作用在于实现对公网 IP 地址的复用,即所有的内网主机共用同一个 IP 地址,NAT 的实现方式共有三种:

a)静态转换:将内网 IP 直接转换为公网 IP 地址,形成一一对应的方式
内网穿透原理及frp核心代码解析插图2
b)动态转换:将内网 IP 地址转换为公网 IP 地址,与静态转换不同的是动态转换会在 IP 池中选择空闲 IP 地址进行转换,即每次同一个内网 IP 对应的公网 IP 会发生改变
内网穿透原理及frp核心代码解析插图4
c)端口多路复用(PAT 技术):将内网 IP 与公网 IP 的某一端口进行映射,通过公网 IP 的某一端口访问公网
内网穿透原理及frp核心代码解析插图6

可以看出以上三种形式中端口多路复用(PAT)技术可以最大程度上缓解 IPV4 地址紧张的现状,也是最为广泛使用的实现方式,三种 NAT 实现方式共同点在于:对于内网用户来说自己对应的公网 IP 是不可知的,就好像我们可以知道自己的门牌号但无法知道自己所在的小区,因此无法准确告诉别人我们的具体地址。

三、内网穿透

在已知了当前内外网工作方式后,我们再来看一看作为程序员常用的技术手段内网穿透。在此之前或许很多人都曾使用过如花生壳、ngrok、frp等方式在没有服务器的情况下将一些服务部署到网络上让别人使用。

那么内网穿透的原理究竟是怎么样的呢?

1、内网穿透原理解析

目前市面上主流的内网穿透工具实现的原理如下:

内网穿透原理及frp核心代码解析插图8

可见,内网穿透的核心原理在于将外网 IP 地址与内网 IP 地址建立联系,市面上常用的如花生壳工具其核心原理就是依靠一台具有公网 IP 的服务器作为请求的中转站以此来达到从公网访问内网主机的目的。

当我们启动花生壳的服务时,花生壳会将本地配置好的端口和服务器上的端口进行映射,告知服务器请求转发的路径,花生壳的公网服务器则会监听相应端口的请求,当用户访问花生壳提供的 IP 地址时,花生壳的对应 IP 地址的公网主机将会根据访问的端口映射到相应的内网主机,并通过预先配置好的服务端口将请求转发,以达到访问内网主机相应服务的效果。

2、实现内网穿透

花生壳作为一款商业产品,对于配置端口等一系列工作进行了封装,使得用户可以更快捷的使用内网穿透,但我们在了解原理后完全可以通过一些开源的框架以及一台公网服务器实现对应的内网穿透功能,我们以 frp 为例。

3、如何搭建最简单的frp服务

服务端设置(frps.ini):
[common]
bind_port = 7000       //此处填写客户端监听的服务端端口号
vhost_http_port = 8080 //此处填写用户访问的端口号

客户端配置(frpc.ini):
[common]
server_addr = x.x.x.x //此处填写服务端 IP 地址
server_port = 7000    //此处填写服务端配置的bind_port

[web]
type = http         //此处规定转发请求的协议类型
local_port = 80     //此处规定本地服务启动的地址
custom_domains = www.example.com   //此处可以填写自定义域名(需要在 IP 地址下配置域名解析)

当我们配置完上述的文件后,用户的访问请求将会经过如下的步骤:

内网穿透原理及frp核心代码解析插图10

用户的请求将会经过域名解析,公网端口的转发以及内网主机的监听三个步骤成功将请求发送到对应的内网服务,当然 frp 相较于花生壳提供了更多的自定义配置项,此处不做详细讲解,有兴趣的读者可以访问:frp中文文档

当我们使用 frp 去配置我们自己的内网穿透服务时,我们可以使用一台服务器为大量的内网主机提供公网访问的功能,以此来实现公网 IP 的复用,其原理与上文提到的 PAT 端口多路复用技术相类似,当我们临时需要使用服务器时,只需要向拥有公网服务器的朋友申请两个闲置端口即可。

四、frp核心代码解析

以 http 请求为例解析当一个公网请求发送到frp服务器后究竟会经过哪些步骤?

1、frps初始化

func runServer(cfg config.ServerCommonConf) (err error) {
  log.InitLog(cfg.LogWay, cfg.LogFile, cfg.LogLevel, cfg.LogMaxDays, cfg.DisableLogColor)

  if cfgFile != "" {
    log.Info("frps uses config file: %s", cfgFile)
  } else {
    log.Info("frps uses command line arguments for config")
  }
  
  // !important 核心代码1
  svr, err := server.NewService(cfg)
  if err != nil {
    return err
  }
  log.Info("frps started successfully")
  // !important 核心代码2
  svr.Run()
  return
}

在frp/cmd/frps/root.go中:

  • 核心代码1:server.NewService() 方法对我们在frps中的配置进行解析,初始化frp服务端
  • 核心代码2:serever.Run() 方法启动frp服务

2、frpc初始化

for{    
  // !important 核心代码3
conn, session, err := svr.login()
    if err != nil {
      xl.Warn("login to server failed: %v", err)

      // if login_fail_exit is true, just exit this program
      // otherwise sleep a while and try again to connect to server
      if svr.cfg.LoginFailExit {
        return err
      }
      util.RandomSleep(10*time.Second, 0.9, 1.1)
    } else {
      // login success
      // !important 核心代码4
      ctl := NewControl(svr.ctx, svr.runID, conn, session, svr.cfg, svr.pxyCfgs, svr.visitorCfgs, svr.serverUDPPort, svr.authSetter)
      ctl.Run()
      svr.ctlMu.Lock()
      svr.ctl = ctl
      svr.ctlMu.Unlock()
      break
    }
}

在frp/cmd/client/service.go中:

  • 核心代码3:for 循环不断去发起和服务端的连接,失败后会再次发起
  • 核心代码4:连接成功后,客户端会使用连接的信息调用 NewControl()

3、frpc和frps通信

1)frps发起连接

func (pxy *BaseProxy) GetWorkConnFromPool(src, dst net.Addr) (workConn net.Conn, err error) {
  xl := xlog.FromContextSafe(pxy.ctx)
  // try all connections from the pool
  for i := 0; i < pxy.poolCount+1; i++ {
    // !important 核心代码5
    if workConn, err = pxy.getWorkConnFn(); err != nil {
      xl.Warn("failed to get work connection: %v", err)
      return
    }
    xl.Debug("get a new work connection: [%s]", workConn.RemoteAddr().String())
    xl.Spawn().AppendPrefix(pxy.GetName())
    workConn = frpNet.NewContextConn(pxy.ctx, workConn)
    ......
    // !important 核心代码6
    err := msg.WriteMsg(workConn, &msg.StartWorkConn{
      ProxyName: pxy.GetName(),
      SrcAddr:   srcAddr,
      SrcPort:   uint16(srcPort),
      DstAddr:   dstAddr,
      DstPort:   uint16(dstPort),
      Error:     "",
    })
  }
}

在frp/server/proxy.go中:

  • 核心代码5:frps从多个连接中通过依次遍历的方式来获取第一个成功获取到的连接
  • 核心代码6:frps通过获取到的连接向 frpc 发出 &msg.StartWorkConn 的消息,告诉frpc建立连接的相应信息

2)frpc响应连接

func (pxy *TCPProxy) InWorkConn(conn net.Conn, m *msg.StartWorkConn) {
  // !important 核心代码7
  HandleTCPWorkConnection(pxy.ctx, &pxy.cfg.LocalSvrConf, pxy.proxyPlugin, pxy.cfg.GetBaseInfo(), pxy.limiter,
    conn, []byte(pxy.clientCfg.Token), m)
}

在frp/client/proxy/proxy.go中:

  • 核心代码7:frpc接收到frps的信息后发起 TCP 连接

3)frps发送消息

func (ctl *Control) writer() {
  xl := ctl.xl
  defer func() {
    if err := recover(); err != nil {
      xl.Error("panic error: %v", err)
      xl.Error(string(debug.Stack()))
    }
  }()

  defer ctl.allShutdown.Start()
  defer ctl.writerShutdown.Done()

  encWriter, err := crypto.NewWriter(ctl.conn, []byte(ctl.serverCfg.Token))
  if err != nil {
    xl.Error("crypto new writer error: %v", err)
    ctl.allShutdown.Start()
    return
  }
  for {
    m, ok := <-ctl.sendCh
    if !ok {
      xl.Info("control writer is closing")
      return
    }
    // !important 核心代码8
    if err := msg.WriteMsg(encWriter, m); err != nil {
      xl.Warn("write message to control connection error: %v", err)
      return
    }
  }
}

在frp/server/control.go中:

  • 核心代码8:frps发送信息到 crypto.NewWriter() 创建的 writer 中

4)frpc接收和响应

// !important 核心代码9
func (ctl *Control) reader() {
  xl := ctl.xl
  defer func() {
    if err := recover(); err != nil {
      xl.Error("panic error: %v", err)
      xl.Error(string(debug.Stack()))
    }
  }()
  defer ctl.readerShutdown.Done()
  defer close(ctl.closedCh)

  encReader := crypto.NewReader(ctl.conn, []byte(ctl.clientCfg.Token))
  for {
    m, err := msg.ReadMsg(encReader)
    if err != nil {
      if err == io.EOF {
        xl.Debug("read from control connection EOF")
        return
      }
      xl.Warn("read error: %v", err)
      ctl.conn.Close()
      return
    }
    ctl.readCh <- m
  }
}
  • 核心代码9:frpc 读取 frps 转发的信息

到这里,我们的 frps 已经成功将公网中接收到的请求转发到 frpc 相应的端口了,这就是一个最简单的请求通过 frp 进行代理转发的流程。

五、总结

本文所介绍的内网穿透技术相关的实现方式其实在我们的日常开发生活中有更多的使用场景,当我们深入了解了当前 IP 地址以及内外网的实现方式后,我们不难发现,当我们将内网穿透的图片稍加修改后就成为了我们常用的另一种功能的实现方式(VPN实现原理):

内网穿透原理及frp核心代码解析插图12

发表评论