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(&ifr, 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, &ifr)) { //管理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接收数据

评论