内网穿透你真的了解吗?

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

前言

内网穿透作为程序员常用的调试手段之一我们可以通过在个人电脑上运行花生壳或者 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 的实现方式共有三种

  • 静态转换将内网 IP 直接转换为公网 IP 地址形成一一对应的方式

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

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

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

内网穿透

在已知了当前内外网工作方式后我们再来看一看作为程序员常用的技术手段内网穿透

在此之前或许很多人都曾使用过如花生壳、ngrok、frp等方式在没有服务器的情况下将一些服务部署到网络上让别人使用

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

内网穿透原理解析

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

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

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

更多C++后台开发技术点知识内容包括C/C++LinuxNginxZeroMQMySQLRedisMongoDBZK流媒体音视频开发Linux内核TCP/IP协程DPDK多个高级知识点。

C/C++Linux服务器开发高级架构师/C++后台开发架构师​免费学习地址

【文章福利】另外还整理一些C++后台开发架构师 相关学习资料面试题教学视频以及学习路线图免费分享有需要的可以点击领取

实现内网穿透

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

原文作者内网穿透你真的了解吗 - 掘金

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6