0:导读

这个程序是基于 c++实现的一个简单 VPN 服务器,搭配 ToyVPN 安卓客户端食用。为了从零开始了解 VPN 的原理,我决定花时间来解读服务端的代码,并在此记录。
代码原址:ToyVPNServer.cpp

1:库说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>			//标准输入输出库,例如printf和scanf函数
#include <stdlib.h> //标准库,将字符串变量转为其他类型变量
#include <string.h> //字符串库
#include <unistd.h> //Unix标准库,提供通用的文件、目录、程序及进程操作的函数
#include <arpa/inet.h> //提供IP地址转换函数
#include <netinet/in.h> //定义数据结构sockaddr_in
#include <sys/ioctl.h> //提供对I/O控制的函数
#include <sys/socket.h> //提供socket函数及数据结构
#include <sys/stat.h> //unix/linux系统定义文件状态所在的伪标准头文件
#include <sys/types.h> //数据类型定义
#include <errno.h> //提供错误号errno的定义,用于错误处理
#include <fcntl.h> //提供对文件控制的函数

#ifdef __linux__ //如果__linux__被定义了

#include <net/if.h> //套接字本地接口,
#include <linux/if_tun.h> //用于建立Tun,虚拟网卡

2:获取接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//此方法中用到的函数,均在linux/if_tun.h中
static int get_interface(char *name)
{
int interface = open("/dev/net/tun", O_RDWR | O_NONBLOCK); //打开tun字符设备
ifreq ifr; //初始化一个ifreq结构体,见2,1节
memset(픦, 0, sizeof(ifr)); //清空结构体ifr成员变量的值,或者叫初始化
ifr.ifr_flags = IFF_TUN | IFF_NO_PI; //指定网络设备的一些特性
strncpy(ifr.ifr_name, name, sizeof(ifr.ifr_name)); //把name复制给ifr.ifr_name,指定虚拟网络设备名称
if (ioctl(interface, TUNSETIFF, 픦)) { //管理IO设备函数ioctl,创建或打开一个虚拟网络设备,返回值<0则出错。见2.2节
perror("Cannot get TUN interface");
exit(1);
}
return interface; //返回接口
}

在 linux 操作系统中,可以直接向**/dev/net/tun**字符设备写入或读取 IP 数据包,写入的 IP 数据包,将被直接送到 TCP/IP 协议栈并进行发送,而接收到的 IP 数据包也可以从 tun 文件中读取。

  • O_RDWR 以可读写方式打开文件;
  • O_NONBLOCK 以不可阻断的方式打开文件, 也就是无论有无数据读取或等待, 都会立即返回进程之中。

2.1 ifreq 结构体

对于结构体 ifr,我们只关心其中的两个成员 ifr_name, ifr_flags。ifr_name 定义了要创建或者打开的虚拟设备名字,如果没有此名称的设备或者为空,则新建一个虚拟设备,并返回一个新建的虚拟网络上设备名称。ifr_flags 用来描述设备的一些属性:

  • IFF_TUN: 创建一个点对点设备
  • IFF_TAP: 创建一个以太网设备
  • IFF_NO_PI: 不包含包信息,默认的每个数据包当传到用户空间时,都将包含一个附加的包头来保存包信息
  • IFF_ONE_QUEUE: 采用单一队列模式,即当数据包队列满的时候,由虚拟网络设备自已丢弃以后的数据包直到数据包队列再有空闲

配置的时候,IFF_TUN 和 IFF_TAP 必须择一,其他选项则可任意组合。TAP 等同于一个以太网设备,它操作第二层数据包如以太网数据帧。TUN 模拟了网络层设备,操作第三层数据包比如 IP 数据包。可以理解为 TAP 比 TUN 封装时多了一个目标和本机的机器地址(MAC)。IFF_NO_PI 没有开启时所附加的包信息头如下:

1
2
3
4
struct tun_pi {
unsigned short flags;
unsigned short proto;
};

目前,flags 只在收取数据包的时候有效,当它的 TUN_PKT_STRIP 标志被置时,表示当前的用户空间缓冲区太小,以致数据包被截断。proto 成员表示发送/接收的数据包的协议。

2.2 ioctl()函数

上面代码中的文件描述符 fd 除了支持 TUN_SETIFF 和其他的常规 ioctl 命令外,还支持以下命令:

  • TUNSETNOCSUM: 不做校验和校验。参数为 int 型的 bool 值。
  • TUNSETPERSIST: 把对应网络设备设置成持续模式,默认的虚拟网络设备,当其相关的文件符被关闭时,也将会伴随着与之相关的路由等信息同时消失。如果设置成持续模式,那么它将会被保留供以后使用。参数为 int 型的 bool 值。
  • TUNSETOWNER: 设置网络设备的属主。参数类型为 uid_t。
  • TUNSETLINK: 设置网络设备的链路类型,此命令只有在虚拟网络设备关闭的情况下有效。参数为 int 型。

ioctl 函数的作用是,将我们要求的配置,转换为/dev/net/tun 能够识别的字符写入到字符设备中。来建立一个 tun 接口。

3:获取隧道

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
36
static int get_tunnel(char *port, char *secret)
{
// We use an IPv6 socket to cover both IPv4 and IPv6.
int tunnel = socket(AF_INET6, SOCK_DGRAM, 0); //创建一个socket描述符,见3.1节
int flag = 1;
setsockopt(tunnel, SOL_SOCKET, SO_REUSEADDR, &flag, sizeof(flag)); //打开地址复用功能
flag = 0;
setsockopt(tunnel, IPPROTO_IPV6, IPV6_V6ONLY, &flag, sizeof(flag)); //同时允许ipv6和ipv4
// 只接收本地端口消息
sockaddr_in6 addr; //IPV6结构体,成员包括地址,端口,flow information以及scope-id等
memset(&addr, 0, sizeof(addr)); //初始化addr
addr.sin6_family = AF_INET6;
addr.sin6_port = htons(atoi(port)); //atoi()字符串转整数,htons()将主机字节顺序转为网络字节顺序
// Call bind(2) in a loop since Linux does not have SO_REUSEPORT.
while (bind(tunnel, (sockaddr *)&addr, sizeof(addr))) {
if (errno != EADDRINUSE) { //错误如果不是端口被占用,则退出。否则继续循环
return -1;
}
usleep(100000); //停止0.1s,此函数包含在unistd.h中
}
// Receive packets till the secret matches.
char packet[1024];
socklen_t addrlen;
do {
addrlen = sizeof(addr);
int n = recvfrom(tunnel, packet, sizeof(packet), 0,
(sockaddr *)&addr, &addrlen); //接收数据,成功返回接收到的字符数
if (n <= 0) {
return -1;
}
packet[n] = 0;
} while (packet[0] != 0 || strcmp(secret, &packet[1])); //strcmp 字符串比较,相等返回0,否则为正或者负数
// Connect to the client as we only handle one client at a time.
connect(tunnel, (sockaddr *)&addr, addrlen); //连接客户端
return tunnel;
}

3.1 socket 函数

int socket(int domain, int type, int protocol);
对应普通文件的打开操作,文件打开返回文件描述符,而 socket 返回 soket 描述符,它唯一标识了一个 socket。通过指定不同的参数,来创建不同的 socket。
domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称 AF_UNIX,Unix 域 socket)、AF_ROUTE 等等。协议族决定了 socket 的地址类型,在通信中必须采用对应的地址。

  • AF_INET:决定了要用 ipv4 地址 host(32 位的)与端口号 port(16 位的)的组合。host 是一个表示为互联网域名表示法之内的主机名或者一个 IPv4 地址的字符串,例如 ‘daring.cwi.nl’ 或 ‘100.50.200.5’,port 是一个整数。对于 TCP/IP 协议族,该参数置 AF_INET。
  • AF_INET6:表示使用 ipv6 地址或者 ipv4 地址和端口。
  • AF_UNIX:决定了要用一个绝对路径名作为地址。

type:指定 socket 类型。常用的 socket 类型有,流套接字类型 SOCK_STREAM、数据报套接字类型 SOCK_DGRAM、原始套接字 SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET 等等。
protocol:就是指定协议。常用的协议有,IPPROTO_TCP、IPPROTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC 等,它们分别对应 TCP 传输协议、UDP 传输协议、STCP 传输协议、TIPC 传输协议。
type 和 protocol 是不能够随意搭配的,比如流套接字类型不能选用 UDP 协议。当 protocol 为 0 时,会自动选择 type 类型对应的协议。
而以下两句返回的肯定是无效套接字 INVALID_SOCKET,使用 WSAGetLastError()获取的错误代码为 10043:

1
2
SOCKET s = socket(AF_INET, SOCK_DGRAM,IPPROTO_TCP)
SOCKET s = socket(AF_INET, SOCK_STREAM,IPPROTO_UDP)

3.2 setsockopt 函数

int setsockopt( int socket, int level, int option_name,const void *option_value, size_t option_len);
第一个参数 socket 是套接字描述符。第二个参数 level 是被设置的选项的级别。
level 指定控制套接字的层次.可以取三种值:

  • SOL_SOCKET:通用套接字选项..
  • IPPROTO_IPV6:IPV6 选项
  • IPPROTO_IP:IP 选项
  • IPPROTO_TCP:TCP 选项 ​

如果想要在套接字级别上设置选项,就必须把 level 设置为 SOL_SOCKET。 option_name 指定准备设置的选项,option_name 可以有哪些取值,这取决于 level,以 linux 2.6 内核为例(在不同的平台上,这种关系可能会有不同),在套接字级别上(SOL_SOCKET),option_name 可以有以下取 值:

SO_BROADCAST 允许发送广播数据 int
SO_DEBUG 允许调试 int
SO_DONTROUTE 不查找路由 int
SO_ERROR 获得套接字错误 int
SO_KEEPALIVE 保持连接 int
SO_LINGER 延迟关闭连接 struct linger
SO_OOBINLINE 带外数据放入正常数据流 int
SO_RCVBUF 接收缓冲区大小 int
SO_SNDBUF 发送缓冲区大小 int
SO_RCVLOWAT 接收缓冲区下限 int
SO_SNDLOWAT 发送缓冲区下限 int
SO_RCVTIMEO 接收超时 struct timeval
SO_SNDTIMEO 发送超时 struct timeval​
SO_REUSERADDR 允许重用本地地址和端口 int​
SO_TYPE 获得套接字类型 int​
SO_BSDCOMPAT 与 BSD 系统兼容 int

3.3 htons 函数

将主机字节顺序转换为网络字节顺序。比如,数字 16 的 16 进制表示为 0x0010,数字 4096 的 16 进制表示为 0x1000。 由于 Intel 机器是小尾端,存储数字 16 时实际顺序为 1000,存储 4096 时实际顺序为 0010。因此在发送网络包时为了报文中数据为 0010,需要经过 htons 进行字节转换。如果用 IBM 等大尾端机器,则没有这种字节顺序转换,但为了程序的可移植性,也最好用这个函数。

3.4 bind 函数

在套接口中,一个套接字只是用户程序与内核交互信息的枢纽,它自身没有太多的信息,也没有网络协议地址和 端口号等信息,在进行网络通信的时候,必须把一个套接字与一个地址相关联,这个过程就是地址绑定的过程。许多时候内核会我们自动绑定一个地址,然而有时用 户可能需要自己来完成这个绑定的过程,以满足实际应用的需要,最典型的情况是一个服务器进程需要绑定一个众所周知的地址或端口以等待客户来连接。这个事由 bind 的函数完成。

1
int bind( int sockfd, struct sockaddr * addr, socklen_t addrlen )

成功返回 0,失败返回-1.

  • sockfd:指定地址与哪个套接字绑定,这是一个由之前的 socket 函数调用返回的套接字。调用 bind 的函数之后,该套接字与一个相应的地址关联,发送到这个地址的数据可以通过这个套接字来读取与使用。
  • addr:指定地址。这是一个地址结构,并且是一个已经经过填写的有效的地址结构。调用 bind 之后这个地址与参数 sockfd 指定的套接字关联,从而实现上面所说的效果。
  • addrlen:正如大多数 socket 接口一样,内核不关心地址结构,当它复制或传递地址给驱动的时候,它依据这个值来确定需要复制多少数据。这已经成为 socket 接口中最常见的参数之一了

3.5 recvfrom 函数

用来接收远程主机经指定的 socket 传来的数据, 并把数据存到由参数 buf 指向的内存空间, 参数 len 为可接收数据的最大长度. 参数 flags 一般设 0, 其他数值定义请参考 recv(). 参数 from 用来指定欲传送的网络地址, 结构 sockaddr 请参考 bind(). 参数 fromlen 为 sockaddr 的结构长度.

返回值:成功则返回接收到的字符数, 失败则返回-1, 错误原因存于 errno 中.
错误代码:

  • EBADF 参数 s 非合法的 socket 处理代码
  • EFAULT 参数中有一指针指向无法存取的内存空间.
  • ENOTSOCK 参数 s 为一文件描述词, 非 socket.
  • EINTR 被信号所中断.
  • EAGAIN 此动作会令进程阻断, 但参数 s 的 socket 为不可阻断.
  • ENOBUFS 系统的缓冲内存不足
  • ENOMEM 核心内存不足
  • EINVAL 传给系统调用的参数不正确.

4:构建参数

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
static void build_parameters(char *parameters, int size, int argc, char **argv)
{
// 为了简单,只是盲目的连接各个参数
int offset = 0;
for (int i = 4; i < argc; ++i) {
char *parameter = argv[i];
int length = strlen(parameter);
char delimiter = ',';
// If it looks like an option, prepend a space instead of a comma.
if (length == 2 && parameter[0] == '-') {
++parameter;
--length;
delimiter = ' ';
}
// This is just a demo app, really.
if (offset + length >= size) {
puts("Parameters are too large");
exit(1);
}
// Append the delimiter and the parameter.
parameters[offset] = delimiter;
memcpy(&parameters[offset + 1], parameter, length);
offset += 1 + length;
}
// Fill the rest of the space with spaces.
memset(&parameters[offset], ' ', size - offset);
// Control messages always start with zero.
parameters[0] = 0;
}

5:主程序

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
int main(int argc, char **argv)
{
if (argc < 5) {
printf("Usage: %s <tunN> <port> <secret> options...\n"
"\n"
"Options:\n"
" -m <MTU> for the maximum transmission unit\n"
" -a <address> <prefix-length> for the private address\n"
" -r <address> <prefix-length> for the forwarding route\n"
" -d <address> for the domain name server\n"
" -s <domain> for the search domain\n"
"\n"
"Note that TUN interface needs to be configured properly\n"
"BEFORE running this program. For more information, please\n"
"read the comments in the source code.\n\n", argv[0]);
exit(1);
}
// Parse the arguments and set the parameters.
char parameters[1024];
build_parameters(parameters, sizeof(parameters), argc, argv);
// Get TUN interface.
int interface = get_interface(argv[1]);
// Wait for a tunnel.
int tunnel;
while ((tunnel = get_tunnel(argv[2], argv[3])) != -1) {
printf("%s: Here comes a new tunnel\n", argv[1]);
// On UN*X, there are many ways to deal with multiple file
// descriptors, such as poll(2), select(2), epoll(7) on Linux,
// kqueue(2) on FreeBSD, pthread(3), or even fork(2). Here we
// mimic everything from the client, so their source code can
// be easily compared side by side.
// Put the tunnel into non-blocking mode.
fcntl(tunnel, F_SETFL, O_NONBLOCK);
// Send the parameters several times in case of packet loss.
for (int i = 0; i < 3; ++i) {
send(tunnel, parameters, sizeof(parameters), MSG_NOSIGNAL);
}
// Allocate the buffer for a single packet.
char packet[32767];
// We use a timer to determine the status of the tunnel. It
// works on both sides. A positive value means sending, and
// any other means receiving. We start with receiving.
int timer = 0;
// We keep forwarding packets till something goes wrong.
while (true) {
// Assume that we did not make any progress in this iteration.
bool idle = true;
// Read the outgoing packet from the input stream.
int length = read(interface, packet, sizeof(packet));
if (length > 0) {
// Write the outgoing packet to the tunnel.
send(tunnel, packet, length, MSG_NOSIGNAL);
// There might be more outgoing packets.
idle = false;
// If we were receiving, switch to sending.
if (timer < 1) {
timer = 1;
}
}
// Read the incoming packet from the tunnel.
length = recv(tunnel, packet, sizeof(packet), 0);
if (length == 0) {
break;
}
if (length > 0) {
// Ignore control messages, which start with zero.
if (packet[0] != 0) {
// Write the incoming packet to the output stream.
write(interface, packet, length);
}
// There might be more incoming packets.
idle = false;
// If we were sending, switch to receiving.
if (timer > 0) {
timer = 0;
}
}
// If we are idle or waiting for the network, sleep for a
// fraction of time to avoid busy looping.
if (idle) {
usleep(100000);
// Increase the timer. This is inaccurate but good enough,
// since everything is operated in non-blocking mode.
timer += (timer > 0) ? 100 : -100;
// We are receiving for a long time but not sending.
// Can you figure out why we use a different value? :)
if (timer < -16000) {
// Send empty control messages.
packet[0] = 0;
for (int i = 0; i < 3; ++i) {
send(tunnel, packet, 1, MSG_NOSIGNAL);
}
// Switch to sending.
timer = 1;
}
// We are sending for a long time but not receiving.
if (timer > 20000) {
break;
}
}
}
printf("%s: The tunnel is broken\n", argv[1]);
close(tunnel);
}
perror("Cannot create tunnels");
exit(1);
}

参考资料

Linux 的 TUN/TAP 编程
linux ioctl()函数详解
memset 函数及其用法,C 语言 memset 函数详解
C++ socket 函数解析
setsockopt 函数功能及参数详解
LINUX 下 getsockopt 和 setsockopt 函数
网络编程 socket 之 bind 函数
C++ —usleep()功能
C 语言 recvfrom()函数:经 socket 接收数据

评论