用Elixir写个简单的内网穿透

1,738 阅读14分钟

内网穿透是一个在开发工作中经常需要使用的工具,先看看这个工具的定义:

内网穿透即NAT穿透,网络连接时术语,计算机是局域网内时,外网与内网的计算机节点需要连接通信,有时就会出现不支持内网穿透。就是说映射端口, 能让外网的电脑找到处于内网的电脑

简而言之,就是利用一个固定的对外ip来访问内网的机器。 现有的内网穿透的方案有很多,例如花生壳frpngrokshootback等等,如果你想要稳定高性能的穿透产品,建议还是付费用花生壳这种,如果是用于测试和开发,那么一些开源的产品也可以考虑。

本篇文章就简单介绍下,如何用Elixir语言编写一个简单的内网穿透工具,该项目的所有代码在这个仓库


WHY

: 市面上已经有了很多功能强大的穿透工具,为什么还要重复造轮子?

: 1. 因为有趣; 2. 市面上的解决方案大多功能大而全,而这个轮子追求小而美。

: 为什么选择Elixir语言编写

: 1. Elixir号称是socket编程的最佳选择,socket编程体验nice;2. Elixir是erlang虚拟机上的语言,面向并发编程,免去了很多多路复用的实现细节。


PREPARE

理解本篇文章需要以下预备知识:

  1. 计算机网络基础;
  2. Elixir基础或Erlang基础;

HOW

STEP 1: 建立项目骨架

先在脑海中理清软件的物理形态,内网穿透是个典型的CS架构,那么一定有客户端和服务端:

大致的物理形态如图:

structure

  • 客户端部署在内网中,主要工作是连接内网应用,并将内网应用的流量转发至服务端,同时需要承接服务端发来的流量,转发至内网应用;
  • 服务端部署在一台可网络直接触达的机器上,既要承接客户端发来的流量也要承接外部访问的流量,将外部流量转发至客户端,同时要将客户端的流量转发给外部连接;
  • 客户端与服务端之间维持着tcp连接,如果多个穿透应用想要复用客户端与服务端的连接的话,就需要设计好协议来区分单个连接中的流量归属;

那么先建立两个项目:

mix new tunnel_ex --umbrella # 项目顶层,设计成一个umbrella项目,不这样也行,两端都弄成独立app
cd tunnel_ex/apps
mix new client --sup # 客户端
mix new server --sup # 服务端
mix new commmon # 通用组件,utils,helpers之类的东西

目前项目看起来是这样:

.
├── apps
│   ├── client
│   │   ├── config
│   │   ├── lib
│   │   ├── mix.exs
│   │   ├── mix.lock
│   │   ├── README.md
│   │   └── test
│   ├── common
│   │   ├── lib
│   │   ├── mix.exs
│   │   ├── README.md
│   │   └── test
│   └── server
│       ├── config
│       ├── lib
│       ├── mix.exs
│       ├── mix.lock
│       ├── README.md
│       └── test
├── config
│   └── config.exs
├── LICENSE
├── mix.exs
├── mix.lock
└── README.md

当代码编制完毕后,只需要在client和server目录下分别编译打包即可。

STEP 2: 协议部分

在CS编程的实践中,最最重要的就是协议的设计,好的协议可以节约资源提升性能。当然这个项目的协议设计不用太严谨,毕竟只是个实验性质的项目。 首先我们思考协议的目标:

  1. 将外部流量在服务端的行为,在客户端到内部应用上重放。例如:外部与服务端监听的某个端口建立了tcp连接,那么客户端也要马上与内部相应的服务建立tcp连接。外部向服务端端口输送的流量,客户端也要原封不动的输送到内部应用;
  2. 由于需要复用客户端与服务端的连接,协议要能区分不同穿透组合的流量。例如上图中,外部8080端口接收的流量只能转发至内部80口,不能转发至81口或82口;

为了完成上述的目标,我设计了一个简单的协议:

1. 客户端与服务端建立连接

我们设计的穿透可以是一个服务端对应多个客户端,所以客户端在发起连接的时候理应上报自己的相关信息,我们可以如下设计:

在客户端发起连接的时候,发送自己的ip地址(这个ip地址不一定是真实ip,只要不与其他客户端ip地址碰撞即可,其实这里就是一个客户端id的作用,用ip地址还能有点物理意义)给服务端,服务端将存储一个ip地址对应socket的映射关系,格式如下:

| 0x09 | 0x01 | ip0 | ip1 | ip2 | ip3 |

前面固定2个字节为<<9, 1>>,后面跟着4个字节的ip地址。只要服务端接收到这个格式的包,自然就可以知道这是一个客户端ip地址上报的行为, 此时server会做一个回执给客户端,表示自己已经收到了ip上报,回执格式如下:

| 0x09 | 0x02 |

固定为<<9, 2>>,如果客户端一段时间内没有收到回执,说明哪里出问题了,应当重试上报,如果多次重试没成功,说明server应该是挂了。这一部分的异常处理情况复杂,第一个版本我就没考虑这些,编程应当先关注主流程。

2. 外部与服务端建立tcp连接

当外部有流量与服务端监听的某个端口建立了tcp连接,此时服务端应当通知客户端这一事件,我们做如下设计:

| 0x09 | 0x03 | key::16 | client_port::16 |

前面固定2字节<<9, 3>>,后面会跟上两字节的key和一个2字节的端口号。其中,key用于表示某个外部连接的id,client_port表示这个包要发往内部的哪个端口。当外部与服务端建立了连接,我们会为每个tcp连接,分配一个2字节的key,并存储key到连接的映射关系。再转发至客户端的时候,会带上这个key,此时客户端会根据client_port与内部应用建立连接,并存储key到内部连接的映射关系。这样我们就可以根据key来区分服务端与客户端通信包中的归属。

特别需要注意的是,外部与服务端的交互是没法感知客户端这边的情况,很有可能,外部完成tcp连接后,马上开始发送流量,但是从服务端发往客户端的网络包未必是按照顺序来的,所以在客户端成功与内部应用建立tcp连接之前,服务端最好将外部流量缓存,等待客户端回执一个连接建立成功的信号,再将缓存中的流量按顺序发给客户端。为了简单起见,将回执做如下设计:

| 0x09 | 0x03 | key::16 |

3. 通信阶段

当服务端与客户端都建立了tcp连接,接下来就是发送应用层的数据流,我们做如下设计:

| key::16 | real packet |

很容易理解,根据key找到对应的内部连接,然后将真正的流量转发过去。

4. 连接关闭

当外部与服务端的连接关闭,服务端也应该通知客户端关闭内部连接。格式如下:

| 0x09 | 0x04 | key :: 16 |

前面是固定的<<9, 4>>开头,后面是key标明哪个连接需要被关闭。

STEP 3: TALK IS CHEAP, SHOW ME THE CODE!

接下来,我们按照协议内容开始编码。

代码骨架

客户端的部分比较简单,我们先着手写这部分。首先先构思客户端的形态,客户端需要主动连接两方,既要连接内网应用,又要连接服务端,那么客户端应当有两个client组成:

  • 对内部应用的client命名为worker,这一进程应该是动态创建的;
  • 对服务端的我们命名为selector(叫selector是因为服务端发来的流量是复用tcp连接的,这一层的代码需要做一些选择分发的工作);

那么client的结构应该如下:

├── client
│   ├── application.ex  # 相当于main
│   ├── selector.ex     # 承接server端流量
│   ├── socket_store.ex # 存储端口=>socket的映射关系
│   ├── utils.ex        # 工具函数
│   └── worker.ex       # 承接本地服务流量

至于服务端要考虑的东西更多,服务端会承担以下工作:

  • 监听客户端连接;
  • 监听外部连接;
  • 将外部流量转发至客户端;
  • 将客户端发来的流量转发回外部。

可见,服务端里面会有两类tcp server,那么结构可以如下设计:

├── server
│   ├── application.ex        # 相当于main
│   ├── external_listener.ex  # 外部监听入口
│   ├── external_worker.ex    # 外部socket的代理进程
│   ├── internal_listener.ex  # 客户端的监听进程
│   ├── internal_worker.ex    # 客户端socket的代理进程
│   ├── socket_store.ex       # 存储端口=>socket映射关系以及key=>socket映射关系
│   ├── typespec.ex           # 类型枚举
│   └── utils.ex              # 工具函数

这个层级结构设计的相当粗糙,可以更加精细,当然,第一版本先着眼主要矛盾。

客户端服务端建立连接部分

在Erlang虚拟机上,可以将一个socket的代理权交给一个Erlang的GenServer进程,让GenServer来全权代理socket的一些事件反应(果然,Erlang才是最好的oo模型),在这个项目中,所有的socket我们都用GenServer进程来代理,有点类似于其他oo语言的class实例。

我们先着眼于客户端selector,首先这是一个客户端进程,会主动连接服务端的端口。那么我们先拉一个GenServer起来。

defmodule Client.Selector do
  def start_link(opts) do
    {name, opts} = Keyword.pop(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  def init(_opt) do
    send(self(), :connect) # 进程启动后给自己发送一个连接服务端的命令
    {:ok, %{socket: nil}}
  end

  def handle_info(:connect, state) do
    {host, port} = server_cfg() # 读取配置文件中服务端的地址和端口信息
    Logger.info("Connecting to #{host}:#{port}")

    with {:ok, ip} <- host |> to_charlist |> :inet.parse_address(), # 地址解析
         {:ok, sock} <- :gen_tcp.connect(ip, port, [:binary, active: true, packet: 2]), # 建立连接
         localhost <- client_cfg(), # 获取配置本地的ip地址
         {:ok, {ip0, ip1, ip2, ip3}} <- localhost |> to_charlist |> :inet.parse_address() do
      # handshake
      :gen_tcp.send(sock, <<0x09, 0x01, ip0, ip1, ip2, ip3>>) # ip地址上报
      {:noreply, Map.put(state, :socket, sock)}
    else
      {:error, reason} ->
        Logger.warn("reason -> #{inspect(reason)}")
        Process.send_after(self(), :connect, 1000) # 尝试重连
        {:noreply, state}

      _ ->
        {:stop, :normal, state}
    end
  end
end

这部分代码主要就是建立服务端连接,同时按协议上报自己的ip地址。

按照协议,服务端会回执一个包,这里需要做一下应答:

def handle_info({:tcp, _socket, <<0x09, 0x02>>}, state) do
    # handshake finished
    Logger.info("handshake finished")
    {:noreply, state}
end

在socket代理进程中,发给socket的信息会被转成一个发给erlang微进程的msg,简化了我们的编程模型,免去了自己主动recv再去做相应的event handle。

服务端需要监听客户端的连接请求,并且做出相应回应。

对应的服务端部分代码在如下:

defmodule Server.InternalListener do
  @moduledoc """
  内部监听
  """
  require Logger
  use GenServer
  alias Server.{InternalWorker}

    def start_link(opts) do
    {name, opts} = Keyword.pop(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  def init(_opts) do
    port = server_port()
    {:ok, acceptor} = :gen_tcp.listen(port, [:binary, active: false, reuseaddr: true, packet: 2])
    send(self(), :accept)

    Logger.info("Accepting connection on port #{port}...")
    {:ok, %{acceptor: acceptor}}
  end

  def handle_info(:accept, %{acceptor: acceptor} = state) do
    {:ok, sock} = :gen_tcp.accept(acceptor)

    # 启动一个进程来代理来自客户端的连接
    {:ok, pid} = GenServer.start_link(InternalWorker, socket: sock)

    # 转交给GenServer来处理socket事件
    :gen_tcp.controlling_process(sock, pid)

    send(self(), :accept)
    {:noreply, state}
  end
end


defmodule Server.InternalWorker do
  @moduledoc """
  内部数据交互进程
  """
  use GenServer
  require Logger

  def start_link(opts) do
    {name, opts} = Keyword.pop(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  def init(socket: socket) do
    :inet.setopts(socket, active: true)
    {:ok, %{socket: socket}}
  end

  # 接收到<<9, 1>> 开头表示ip地址上报
  def handle_info({:tcp, socket, <<0x09::8, 0x01::8, ip::32>> = data}, state) do
    Logger.info("internal recv => #{inspect(data)}")
    
    IPSocketStore.add_socket(<<ip::32>>, self()) # 存储ip=>socket进程的映射
    # handshake
    :gen_tcp.send(socket, <<0x09, 0x02>>) # 按照协议回执一个 <<9, 2>>
    {:noreply, Map.put(state, :ip, <<ip::32>>)}
  end
end

至此,建立连接握手的部分就完成了。

服务端对外部服务的监听

服务端除了监听客户端的连接,还需要监听外部的映射端口,例如我们的配置文件格式如下:

# 转发配置
nat:
  - name: "server0"
    from: localhost:8080
    to: 192.168.10.101:80

  - name: "server1"
    from: localhost:8081
    to: 192.168.10.101:81

我们需要监听8080口和8081口,可以根据配置文件动态创建多个监听进程,具体代码如下:

defmodule Server.ExternalListener do
  @moduledoc """
  外部监听
  """

  require Logger
  use GenServer
  alias Server.{ExternalWorker, SocketStore, Utils}

  def start_link(opts) do
    {name, opts} = Keyword.pop(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  @doc """
  nat 示例
  %{
    "from"=> "localhost:8080"
    "to"=> "192.168.10.101:80"
  }
  """
  def init(nat: nat) do
    [_, port_str] = nat |> Map.get("from") |> String.split(":")
    port = String.to_integer(port_str)

    # 监听
    {:ok, acceptor} = :gen_tcp.listen(port, [:binary, active: false, reuseaddr: true])
    send(self(), :accept)
    Logger.info("Accepting connection on port #{port}...")

    {:ok, %{acceptor: acceptor, nat: nat, port: port}}
  end

  def handle_info(:accept, %{acceptor: acceptor, port: port} = state) do

    # 建立连接
    {:ok, sock} = :gen_tcp.accept(acceptor)
    Logger.info("new connection established from port #{port}")

    sock_key = Utils.generete_socket_key()

    # 创建一个worker 来处理外部数据
    {:ok, pid} = GenServer.start_link(ExternalWorker, socket: sock, nat: state.nat, key: sock_key)
    :gen_tcp.controlling_process(sock, pid)

    # 注册至 key => socket 仓库
    SocketStore.add_socket(sock_key, pid)

    send(self(), :accept)
    {:noreply, state}
  end
end

处理外部流量的Worker在创建后需要立即通知客户端,具体细节如下:

defmodule Server.ExternalWorker do
  @moduledoc """
  数据处理进程
  """

  use GenServer
  require Logger
  alias Server.{InternalWorker, SocketStore, IPSocketStore, Typespec}

  def start_link(opts) do
    {name, opts} = Keyword.pop(opts, :name, __MODULE__)
    GenServer.start_link(__MODULE__, opts, name: name)
  end

  def send_message(pid, message), do: GenServer.cast(pid, {:message, message})

  @spec init(socket: Typespec.socket(), nat: map(), key: Typespec.sock_key()) :: {:ok, pid()}
  def init(socket: socket, nat: nat, key: key) do

    # 解析配置文件,获取对应的内网端口号和地址
    [client_ip_raw, client_port] =
      nat
      |> Map.get("to")
      |> String.split(":")

    {:ok, {ip0, ip1, ip2, ip3}} = client_ip_raw |> to_charlist() |> :inet.parse_address()

    # 先设置为被动模式,不接收packet
    :inet.setopts(socket, active: false)
    send(self(), :tcp_connection_req)

    {:ok,
     %{
       socket: socket,
       key: key,
       client_ip: <<ip0, ip1, ip2, ip3>>,
       client_port: String.to_integer(client_port),
       status: 0, # 状态0表示未握手
       buffer: :queue.new()
     }}
  end

  def handle_info(:tcp_connection_req, state) do
    Logger.info("send tcp connecntion request")

    # 通知客户端,建立tcp连接
    send_msg(state.client_ip, <<0x09, 0x03, state.key::16, state.client_port::16>>)

    # 将socket设置为主动模式,开始接收流量
    :inet.setopts(state.socket, active: true)

    # 将状态置为 1=>握手中
    {:noreply, Map.put(state, :status, 1)}
  end

  def handle_info({:tcp, _, data}, state) do
    Logger.info("external recv => #{inspect(data)}")

    new_state =
      case state.status do
        2 -> # 若已建立连接,则直接发送
          # already set
          :ok = send_msg(state.client_ip, <<state.key::16>> <> data)
          state

        _ ->
          # not set, go to buffer
          # 状态还在握手中,或未握手,应该将外部流量存在队列中,等待状态变为2后再将缓存吐出
          Map.put(state, :buffer, :queue.in(data, state.buffer))
      end

    {:noreply, new_state}
  end  
  ...
end

服务端发送建立tcp连接的通知后,客户端要对此做出反应,按照协议,代码如下:

# 建立连接通知
def handle_info({:tcp, socket, <<0x09, 0x03, key::16, client_port::16>>}, state) do
  Logger.debug("selector recv tcp connection request")
  create_local_conn(key, client_port) # 建立对内部应用的连接
  :gen_tcp.send(socket, <<0x09, 0x03, key::16>>) # 回执服务端,告知本地已连接成功
  {:noreply, state}
end

当服务端收到客户端回执后,会触发后续状态变化,一方面是将状态改为握手完成,另一方面是将缓存中的流量发向客户端。

def handle_info(:tcp_connection_set, state) do
  Logger.info("recv tcp connecntion finished")

  # send all buffer to client
  # 将缓存发向client
  flush_buffer(state.buffer, state.key, state.client_ip)

  # 状态置为握手完成,缓存清空
  new_state =
    state
    |> Map.put(:status, 2)
    |> Map.put(:buffer, :queue.new())

  {:noreply, new_state}
end

缓存我使用的是erlang自带的queue结构,flush_buffer这个工具函数的实现如下:

defp flush_buffer(buffer, key, ip) do
  buffer
  |> :queue.out()
  |> case do
	{{:value, msg}, buf} ->
	  send_msg(ip, <<key::16>> <> msg)
	  flush_buffer(buf, key, ip)

	{:empty, _} ->
	  nil
  end
end

映射关系管理

在客户端和服务端都有需要管理key到socket或者ip到socket的映射关系,实际上这就是个全局的dict,在Elixir中,可以用Registry或者Agent来管理这个数据,以服务端ip到socket映射为例:

defmodule Server.IPSocketStore do
  use Agent
  alias Server.Typespec

  def start_link(_opts) do
    Agent.start_link(fn -> %{} end, name: __MODULE__)
  end

  # 随机选择一个socket
  @spec get_socket(Typespec.ip()) :: Typespec.socket()
  def get_socket(ip) do
    __MODULE__
    |> Agent.get(& &1)
    |> Map.get(ip)
    |> (fn
          nil -> nil
          socks -> Enum.random(socks)
        end).()
  end

  @spec add_socket(Typespec.ip(), Typespec.socket()) :: :ok
  def add_socket(ip, pid),
    do: Agent.update(__MODULE__, fn x -> Map.update(x, ip, [pid], &[pid | &1]) end)

  @spec rm_socket(Typespec.ip()) :: :ok
  def rm_socket(ip), do: Agent.update(__MODULE__, fn x -> Map.delete(x, ip) end)
end

通信阶段

通过前面几个阶段的铺垫,通信阶段反而简单了,服务端只需要知道内网的ip地址和端口,然后丢给客户端即可:

defp send_msg(ip, msg) do
  # 选择一个可用的客户端链接
  case IPSocketStore.get_socket(ip) do
    nil ->
      Logger.warn("no socket avaiable")
      {:error, "no socket avaiable"}

    pid ->
      InternalWorker.send_message(pid, msg) # 实际上调用的是:gen_tcp.send/2 方法
  end
end

总结

我们利用Elixir语言简洁快速的构建了一个多端口对多端口,且多路复用的内网穿透工具。在这个项目中,我们自己设计了一个简单的协议,完成tcp事件在客户端的重放和单个逃跑链接对多对peer流量的多路复用。在具体实现中,我们使用了一些erlang socket编程的技巧,利用beam虚拟机的进程来代理socket的收发动作,利用Agent来管理socket的映射关系。最后使整个软件的脉络清晰,结构简单。当然,这个穿透软件还有很多不足,例如:

  1. 协议中没有考虑异常处理;
  2. 服务端不支持动态配置;
  3. 利用:gen_tcp代理socket没有做限流处理;

当然,作为一个玩具项目,不能要求尽善尽美,用来作为CS编程的练习项目非常合适。