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

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

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

事实上,IP地址分为两种:公网IP和内网IP
内网IP:内网IP是用户在使用局域网时,由局域网的网关所分配的IP地址,每一个内网IP实际上都可以映射到当前所在局域网网关的某一端口(IPV4地址通过NAT与端口映射方式实现,具体原理下文详解),拥有内网IP可以被同一局域网下的其他设备所访问到;

公网IP:内网的设备想要访问非同一局域网下的资源则必须通过公网IP,公网IP是没有经过NAT转换的由互联网供应商(ISP)提供的最原始的IP地址,每一个公网IP都可以直接在互联网中被直接定位到。

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

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

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

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

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

IPV4:IPV4由32位二进制数组成,一共有2^32个不同的IPV4地址
IPV6:IPV6由128位二进制数组成,理论上共有2^128个不同的IPV6地址

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

NAT(网络地址转换)技术
网络地址转化技术的核心作用在于实现对公网IP地址的复用,即所有的内网主机共用同一个IP地址,NAT的实现方式共有三种:
1.静态转换:将内网IP直接转换为公网IP地址,形成一一对应的方式

2.动态转换:将内网IP地址转换为公网IP地址,与静态转换不同的是动态转换会在IP池中选择空闲IP地址进行转换,即每次同一个内网IP对应的公网IP会发生改变

3.端口多路复用(PAT技术):将内网IP与公网IP的某一端口进行映射,通过公网IP的某一端口访问公网

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

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

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

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

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

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

如何搭建最简单的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.duidaima.com   //此处可以填写自定义域名(需要在IP地址下配置域名解析)
当我们配置完上述的文件后,用户的访问请求将会经过如下的步骤:

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

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

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

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服务
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()
frpc 和 frps 通信
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建立连接的相应信息
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连接
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中
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实现原理):

用户评论