26. Rust实现内网穿透工具:从原理到实现

1.前言

rust是一门非常适合写命令行工具的语言,本文将结合网络基础,带大家完成一个基本的内网穿透工具。

如果你对网络本身还不熟悉的,可以先参考文章: 网络编程

由于rust本身已经把很多网络细节封装好了,所以学习网络编程最好的方式其实是从C/C++入门:C++网络编程详解

有了基本的网络基础之后,我们就可以来开发一个最简单的内网穿透工具,其最终的效果就是,你在本地起一个web服务,远在异地的同学也能直接访问你本地启动的这个web网站。

2.内网穿透原理

众所周知的事实是,由于ipv4地址不够分配,所以当下绝大部分人的设备都处于局域网中,也就是常见的192.168.xxx.xxx这类网段。

它的基本原理是,在一个区域内只有一个顶层设备拥有公共ip地址,该区域内部的所有数据都通过这个公共ip地址收发数据。

由于这个公共ip设备下有许许多多的局域网设备,所以外网是无法直接通过单个公共ip找到局域网内部某台设备的。

一个基本的、身处内网的设备去访问公网web服务的过程如下:

graph LR
	A[内网设备A]-->E[公网ip设备]
	B[内网设备B]-->E[公网ip设备]
	n[内网设备n]-->E[公网ip设备E]
	E-->F[拥有公网ip的服务F]	

只有当内网设备首先向拥有公网ip的设备E发送向外部请求数据的请求时,外部的数据才能进入、并被设备E所识别,并将数据转发给局域网设备。

这就代表着,只要我们能在一个公网ip设备上起一个服务,然后等待内网设备首先向我们发送数据,那么我们就可以向内网设备回复数据了,流程如下:

graph LR
	A[内网设备A]--1.连接服务F-->E[公网ip设备E]
	E--2.转发请求给F-->F[拥有公网ip的服务F]
	F--3.返回数据-->E
	E--4.转发返回的数据-->A

上面只是一个简单的演示,事实上只要完成了1、2步,那么后续F设备就能任意向内网设备A发送数据了。

而这就是我们实现内网穿透的关键。

有了这里的基础之后,我们就可以将公网ip设备E省略掉了,因为它的作用仅仅只是一个转发而已。

现在假设我们在内网设备A的8888端口上启动了一个web服务,想要将其暴露出去就可以像下面这样做:

graph TD
	B[内网穿透客户端(安装于内网设备)]--1.连接服务器-->C[内网穿透服务器(安装于公网ip服务器F)]

上面的内网穿透客户端与服务器便是我们将要实现的工具,客户端安装于内网设备,其启动的时候自动连接到服务器,服务器要存放在一个拥有公网ip的设备上,比如云服务器,这可以去腾讯云、阿里云、华为云等服务器厂商租借。

完成了上面这个步骤之后,下面就简单了。

我们想要访问内网暴露的服务,实际上只需要访问设备F上的服务器程序,它将我们的访问请求转发给客户端,最后由客户端转发给本地web服务。

所以内网穿透的基本原理就是:在拥有一台含公共ip的云服务器基础之上,在服务器上安装内网穿透服务器程序,然后让处于内网的设备安装内网穿透客户端并主动的连上来,之后任何发送给服务器的流量就都可以转发给客户端,客户端处于内网之中,可以继续将该流量发送给真正需要暴露的服务。

一个更加具体的例子如下:

graph TD
	A[内网穿透客户端] --1.请求连接占用服务器的1234端口--> B[内网穿透服务器,ip为1.2.3.4]
	C[用户] --2.访问公网1.2.3.4:1234-->B
	B--3.转发流量-->A
	A--4.转发流量-->D[内网8888端口web服务]
	D--5.响应请求-->A
	A--6.转发流量-->B
	B--7.转发流量-->C

虽然图看起来比较麻烦,但实际上就是中间多了内网穿透客户端与服务器这两步的转发而已。

而我们本文要实现的便是这个转发过程。

3.丐版实现

为了能让大家更好的理解整个流程,本节先来实现一个最简单的丐版,不去考虑任何性能、易用性问题。

完整项目的下载地址为:proxy-rs-simple

首先创建一个目录,名字就叫proxy,然后在该目录中添加两个rust项目,一个为服务器server、一个为客户端client:

image.png

对于网络程序开发来说,下面几个依赖项一般都是必要的,你需要将这几个依赖项分别添加到Cargo.toml这个配置文件中:

tokio = { version = "1.41.0", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

tokio为异步运行基础,serde是数据结构库解析基础,serde_jsonjson数据格式的解析。

由于客户端与服务器需要互相通信,虽然我们可以自己写通信协议与代码逻辑,但那太麻烦了,所以我们可以直接使用现成的库:

tokio-util = { version = "0.7.12", features = ["codec"] }

这个库是tokio异步官方提供的单元功能库,启用codec特性就能使用其内的数据解析函数。

而该库内部很多函数还需要下面这个异步基础库:

futures = "0.3.31"

最后,还需要一个Uuid库用于生成唯一标识来确认每条链接:

uuid = { version = "1.11.0", features = ["serde", "v4"] }

3.1 share

然后我们就可以来写一个通用的、客户端与服务器之间消息通信的逻辑代码,将其作为一个share模块:

image.png

这段代码是服务器与客户端之间通用的逻辑,用于数据传输的。

其中主要有三个部分:

  • Msg:定义客户端与服务器之间允许发送的数据结构,也可以称之为协议
  • FrameStream:封装协议,让我们可以便捷的发送自定义数据
  • proxy:用于将两个Tcp连接之间的数据进行拷贝复制,完成数据代理。

首先是Msg这个结构,里面有两个枚举值:

#[derive(Debug, Serialize, Deserialize)]
pub enum Msg {
    InitPort(u16),
    Connect(Uuid),
}

第一个InitPort值的作用是用来初始化端口的,它携带的就是一个端口值,用于客户端首次连接到服务器时、告诉服务器本客户端想要使用服务器的哪个端口,比如端口P

而第二个Connect则是用来标识一个链接的到来、需要服务器与客户端建立一条新的数据传输链路,它携带的值是一个Uuid,用来唯一标识该链接。

之所以要这样做,是原因服务器使用的端口P很可能同时到来许多条链接,需要互相区分。

比如你随便访问一个网站,同一时间可能就会建立数十条Tcp链接用来传输数据。

该消息值的调用逻辑如下:

graph TD
	A[用户]--1.访问-->B[服务器端口P]
	B--2.发送Connect消息-->C[客户端]
	C--3.收到并发送Connect消息,建立一条新的数据链路-->B
	B--4.转发用户链路与数据链路之间的数据-->C
	C--5.转发数据链路与本地服务之间的数据-->D[本地web服务]

然后是FrameStream这个结构,首先为其定义了一个字段:

pub struct FrameStream {
    frame: Framed<TcpStream, AnyDelimiterCodec>,
}

这个frame字段的类型是通过下面为其实现的new函数内部得到的:

    pub fn new(stream: TcpStream) -> Self {
        let codec = AnyDelimiterCodec::new(b"\0".to_vec(), b"\0".to_vec());
        let frame = Framed::new(stream, codec);
        FrameStream { frame }
    }

AnyDelimiterCodectokio_util包中提供的一个功能函数,用于定义数据帧之间的分隔符,也就是当多个Msg一起发送时,我们应该如何分开两个Msg数据?

毕竟在套接字中,所有数据都是字节流,就和粘连在一起的水一样连续的流出,我们需要对其进行划分。

因此这里使用\0作为分隔符,因为一般我们自己定义的数据中不太可能出现发送字符\0的情况,当然你也可以换成别的字符。

然后就可以根据这个分隔符来定义一个帧Framed结构,这同样也是tokio_util包中提供的一个功能函数,让我们可以直接从套接字的数据流中根据分隔符直接取出一段数据。

我们需要用它从套接字数据流中直接取出一个Msg结构,而不是我们自己去读数据、然后一步一步解析,那太麻烦了。

然后在发送的时候,我们就可以直接发送一个帧数据,一个帧在这里就代表一条Msg,发送的方式是先将要发送的msg序列化为字符串:

    pub async fn send(&mut self, msg: &Msg) {
        self.frame
            .send(serde_json::to_string(msg).unwrap())
            .await
            .unwrap();
    }

序列化时就用到了serde_json库,它可以将结构体序列化为JSON格式的字符串,这也是互联网上目前使用范围最广的一个数据传输格式。

而想要用它对rust结构体进行序列化,一个必要的步骤就是在结构体上添加宏SerializeDeserialize

#[derive(Debug, Serialize, Deserialize)]
pub enum Msg {

这两个宏是序列化基石serde库中的。

然后接收的时候,也可以这样进行接收:

    pub async fn recv(&mut self) -> Option<Msg> {
        if let Some(msg) = self.frame.next().await {
            let byte_msg = msg.unwrap();
            let msg = serde_json::from_slice(&byte_msg).unwrap();
            return Some(msg);
        }
        None
    }

先调用next函数取出下一帧二进制数据,然后使用from_slice从该二进制数据中反序列化出我们的Msg结构传回。

至于最后一个函数,则是用来还原的,从帧流中还原为原本的套接字结构:

    pub fn stream(self) -> TcpStream {
        self.frame.into_inner()
    }