手动模拟实现VPN

本项目旨在模拟VPN效果的一种实现,分为客户端和服务端两个程序,客户端程序是一个安卓APP,用Android Studio作为开发IDE,服务端程序是一个运行于Linux系统上的C语言程序,用Clion作为开发IDE。

了解Android Studio,就不得不了解Ktolin,早年间安卓开发虽然用的是Java,但是现在已经流行用Kotlin,``Ktolin`是一种有别于Java语法的基于JVM的编程语言,它的官网:Kotlin Programming Language (kotlinlang.org),可从此官网页面Kotlin command-line compiler | Kotlin Documentation (kotlinlang.org)获取命令行编译器,下载解压后如下

image-20240826225811968

bin目录包含kotlinc脚本,此脚本可以用于编译kotlin程序,可将此路径添加到PATH环境变量

image-20240826230027864

直接运行kotlinc会进入一个交互式环境,从输出中可知,kotlin编译器依赖于jvm,可以推断,kotlin编译器是用java或其它能编译成字节码的语言写的

image-20240826230210585

也可以将代码从源文件编译执行

image-20240826230852896

另一个需要了解的东西是Gradle,一个Java项目构建管理工具,不同于Java Web开发的Maven,安卓开发一般用它管理项目

Android Studio能从虚拟机运行构建后的APP,也能USB连接真机运行,真机需要开启开发者模式,并且允许USB调试,此外,还需确保电脑防火墙没有阻止adb通信,否则都会造成检测不到真机的情况。bulid菜单提供了项目构建相关操作,构建输出一般在app/build/outputs目录下

安卓的VpnService类提供了在安卓系统上建立vpn的能力,新建一个继承此类的类后,核心代码如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private fun setupVpn() {
val builder = Builder()
builder.setSession("My VPN Service")
.addAddress("10.0.0.2", 24) //客户端分配的IP地址
.addRoute("0.0.0.0", 0) //路由所有地址的流量
.addDnsServer("8.8.8.8") //如果不配,那么dns走的是本地
.addDisallowedApplication("com.termux") //排除此应用程序的流量

vpnInterface = builder.establish()
if (vpnInterface == null) {
Log.e(TAG, "Failed to establish VPN")
return
}

Log.d(TAG, "VPN Established")

// Start reading data in a separate thread
thread {
readPackets()
}
}

为了防止VPN程序自身的网络流量被路由,可以使用如下代码

1
2
3
4
val connectivityManager:ConnectivityManager =getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
//当前活动的网络,要么是wifi,要么是流量
val activeNetwork:Network? = connectivityManager.activeNetwork
connectivityManager.bindProcessToNetwork(activeNetwork) //防止vpn应用自身的流量被重定向

拿到vpnInterface后,可以用它获取输入输出流,输入流每次读取到的是一个IP数据包。需要注意的是,IP包的源地址会被替换成配置的IP地址,如上例10.0.0.2,写入IP包时,目的地址保持一致即可。接下来的逻辑就是,不断从输入流读取IP数据包,然后不断从输出流写入IP数据包,实现网络联通。至于读出来的IP包怎么用,完全由VPN客户端程序自己决定。

通常的处理方式是,将IP包通过某种方式发送到另一台服务器进程上,这种方式可以是使用原始的tcp字节流配合自定义的的应用层协议,也可以是采用ws这种不需要处理消息边界的协议,而且一般是加密通信,在数据传输开始之前,一般会协商此次连接客户端的虚拟IP。远端进程收到IP包后,通过某种方式写入到某个网络接口,并且监听这个网络接口关于写入的数据包的回包,读取并且发送回本地进程,本地进程收到数据包后,再写入它的vpn输出流

上述内容相关参考资料:

Linux系统上可以使用TUN/TAP机制创建一个虚拟网络接口,TUN设备工作在网络层,处理的是IP包数据,TAP设备工作在链路层,那么可以处理以太网帧数据。写入虚拟网络接口的数据也会被操作系统协议栈处理,当开启了ip转发后,可以被路由到其他真实网络接口,从而将数据包发送到真实网络上。为了能接收回包以实现双向通信,一般还要搞一个源地址转换的NAT,可以通过iptables实现。

C语言创建tun设备的核心代码如下,使用icotl系统调用在程序运行期间生成tun设备,并进行相关配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int create_tun() {
int fd;
struct ifreq ifr;
char *dev = "/dev/net/tun";

// 打开 TUN 设备 O_RDWR表示读写模式
fd = open(dev, O_RDWR);
if (fd < 0) {
perror("open");
exit(EXIT_FAILURE);
}

// 清空 ifreq 结构体
memset(&ifr, 0, sizeof(ifr));

// 设置设备名称
ifr.ifr_flags = IFF_TUN | IFF_NO_PI; // 使用 TUN 设备,不添加协议头
strncpy(ifr.ifr_name, "tun0", IFNAMSIZ);

// 创建 TUN 设备, 当程序结束时,tun设备也会消失
if (ioctl(fd, TUNSETIFF, (void *)&ifr) < 0) {
perror("ioctl TUNSETIFF");
close(fd);
exit(EXIT_FAILURE);
}

printf("Created TUN device: %s\n", ifr.ifr_name);

//配置它的网络
system("ip addr add 10.0.0.1/24 dev tun0"); //为tun设备分配ip
system("ip link set dev tun0 up"); //启用tun设备
system("ip route add 10.0.0.0/24 dev tun0"); //为tun设备添加路由规则

return fd;
}

iptables配置

1
iptables -t nat -A POSTROUTING -o ens33 -s 10.0.0.0/24 -j MASQUERADE #开启网络地址转换

此命令也可以用来查看路由表,而且非常直观形象

1
netstat -rn

查看ip转发状态,0表示关闭,1表示启用

1
cat /proc/sys/net/ipv4/ip_forward

永久开启,编辑此配置文件

1
nano /etc/sysctl/conf

添加

1
net.ipv4.ip_forward=1

应用更改

1
sysctl -p

最好重启确保一定生效

Linux系统关于TUN/TAP的虚拟网卡技术,相关参考资料

在C语言中,获取了tun设备的文件描述符后,就可以用read和write系统调用读取和写入数据了

在Windows系统上,可以使用Hyper-V创建虚拟网络接口,Hyper-V是微软提供的一个内置虚拟化工具,使用流程如下

启用 Hyper-V 功能

  1. 打开 “控制面板” > “程序” > “启用或关闭 Windows 功能”。
  2. 勾选 “Hyper-V” 选项,然后点击 “确定”。
  3. 系统会安装必要的组件并提示重启。

创建虚拟交换机

  1. 打开 “Hyper-V 管理器”。
  2. 在右侧的操作栏中,点击 “虚拟交换机管理器”。
  3. 选择 “新建虚拟网络交换机”。
  4. 选择 “内部” 作为交换机类型,然后点击 “创建虚拟交换机”。
  5. 为交换机命名,并确保选择了 “内部网络” 选项。
  6. 点击 “确定” 保存设置。

为虚拟交换机配置 NAT

  1. 打开 PowerShell,使用管理员权限运行。

  2. 创建一个新的 NAT 网络:

    1
    New-NetIPAddress -IPAddress 192.168.100.1 -PrefixLength 24 -InterfaceAlias "vEthernet (你的交换机名称)"
  3. 设置 NAT 配置:

    1
    New-NetNat -Name "MyNAT" -InternalIPInterfaceAddressPrefix "192.168.100.0/24"

上述流程完毕后,你会看到系统多了一个虚拟网络接口,并且ip地址是192.168.100.1,此虚拟网络接口能处理以太网帧数据,并且具有NAT功能,虚拟网络的网关就是本机,凡是发到本机此网络接口MAC地址的IP包都会经过NAT处理以与外部网络通信。但是要想在此网络接收到回包,还需要一个还需要有一个虚拟的ARP行为,以便网关根据ip找到mac地址

为方便,Windows系统上服务端程序的实现可以用Python语言配合scapy+websockets库,scapy是用于向网络接口发送和监听网络数据包的库,也可以解析原始二进制包数据。websockets是用于创建ws服务器的库。

顺便提一下,关于类似scapy的库Java有pcap4j,但用起来不如scapy方便,更主要的是,它貌似不能自动计算IP包头部和TCP包头部的校验和。关于tcp的checksum问题,需要注意的是,当网络地址转换后要相应地重新计算checksum,否则不正确的checksum的包会被丢弃,这点非常重要,尤其是当你在程序中要手动实现NAT时,关于如何计算checksum,见:http://bruce690813.blogspot.com/2017/09/tcpip-checksum.html,Java代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public static int calculateChecksum(byte[] header) {

if (header.length % 2 !=0){
byte[] checksum = new byte[header.length+1];
System.arraycopy(header, 0, checksum, 0, header.length);

header=checksum;
}

int length = header.length;
int sum = 0;

// Add 16-bit chunks to the sum
for (int i = 0; i < length - 1; i += 2) {
int firstByte = (header[i] << 8) & 0xFF00;
int secondByte = header[i + 1] & 0xFF;
int chunk = firstByte | secondByte;

sum += chunk;
}

if ((sum & 0xFFFF0000) != 0) {
sum += ((sum & 0xFFFF0000) >> 16)&0xFFFF;
}

sum= ~sum;
// Return only the lower 16 bits
sum= sum & 0xFFFF;

return sum;
}

TCP包是一定需要校验和的,包括下层的IP包也要有。

关于校验和通过抓包发现一个神奇现象

linux系统发的tcp包,ip头和tcp头的校验和都有,且计算方法就是上述的计算方法,这种是可以通过验证的

windows系统发的tcp包,ip头校验和可能为0,tcp头校验和有,但是不是通过上述的计算方法计算的(如果按上述计算方法计算,则通不过验证),不知道是怎么计算的,但是也能通过验证

这里所说的通过验证是指tcp的syn报文能收到对端的syn/ack回应

你可能会想,既然都有直接往网络接口发送和接收数据包的库了?为什么还要额外创建虚拟网络接口,而不是直接将其发送到真实的网络接口呢?这主要是为了避免手动处理NAT问题,手动NAT非常复杂(对于我这种菜鸟而言)且性能、稳定性、可靠性都不好,我在之前的尝试中把NAT想得太简单了,以为只是简单地替换ip和端口,实践才知道,有各种问题,所以不得不采用现成的能避免我解决NAT的方案。