从零开始的内网穿透

在跟朋友Terraria联机的过程中,我2G内存的小云服务器不堪重负,于是决定用内网穿透在我的树梅派上跑游戏服务器。
其实以前在Minecraft联机的时候,我就已经有想法用内网穿透搭搭麦块服务器了。当时尝试了github上的开源工具,不过被TX疯狂发短信和邮件报警。然后大家也不玩麦块了,所以就不再折腾了。
这次趁着闲,索性自己写一个工具。

然后在这里也介绍一下内网穿透的原理,和怎样自己编写这样一个工具。

什么是内网穿透

我们可以通过ip连接到购买的服务器,但从服务器无法主动连接到我们本地的机器。这是因为没有从服务器到我们自己机器的正确路由。
但是高配置的服务器总是很贵。所以不如把服务部署在本地,再通过云服务器来间接的访问本地的服务。其实就和普通的代理有一点类似。

普通的代理是,A访问不到C,但是A能访问到B,B能访问到C。所以我们可以在B搭建代理(例如ss5),然后A通过B这个代理去访问C。

1
2
A ------ × ------> C
A ---> B(ss5) ---> C

内网穿透类似。只不过变成了A和C能访问到B,但是需要通过A访问C:

1
2
A ------> B <------ C
A ---- B(穿透) ---> C

上面的A就是我们自己,C是我们的服务,而B就是我们的云服务器。
内网穿透可以有很多名字,比如什么隧道啊,NAT打洞啊之类。无论叫什么,其实都是差不多的意思,最终要实现的效果就是通过一个公网服务器,实现两个不同局域网的通信。

开始写代码吧!

正常情况下,客户端到服务的连接应该是这样:

如之前所说,由于客户端和服务在不同局域网,所以我们需要在云服务器使用一个程序连接这两端,并转发它们互相发送的数据包。
逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void packet_forward(SOCK src, SOCK dst) 
{
char buff[2048];
int n = 0;
while ((n = recv(src, buff, 2048, 0)) > 0) {
if (send(dst, buff, n, 0) < 0) return;
}
}
void srv()
{
...
SOCK cli = acceot_client();
SOCK svr = accept_server();
std::thread(std::bind(packet_forward, cli, svr)).detach();
std::thread(std::bind(packet_forward, svr, cli)).detach();

// 等待转发线程结束
close(cli);
close(svr);
}

在服务器使用转发程序后的连接应该是这个样子。

这时,如果服务程序自带反向能力,主动连接到我们的转发程序,就已经大功告成了。
可惜一般服务都没有这个功能(此处点名表扬ssh),所以我们还需要另一个转发程序主动连接云服务器和服务:

姑且把云服务器端的转发程序叫做hub, 服务端的转发程序叫做ep好了。此时hubep逻辑应该如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void hub()
{
SOCK cli = acceot_client();
SOCK ep = accept_ep();

// 转发数据
}

void ep()
{
SOCK hub = connect_hub();
SOCK svr = connect_svr();

// 转发数据
}

这样,一条完整的通道就构建完成了。对于客户端和服务来说,中间的两个转发程序是完全透明的。

但是,去穿透游戏服务也好,别的服务也好,不可能只有一个用户。并且我们无法预知用户连接的时间。所以我们需要在hubep之间建立一个链接,由hub通知ep何时去建立建立新的通道。
逻辑大致是这样:

1
2
3
4
5
1. hub启动并等待ep
2. ep连接至hub, 并且此连接用作控制
3. 用户连接至hub时,hub通过2中的控制连接通知ep
4. ep收到通知,主动连接至服务和hub, 并在这两个连接之间转发数据
5. hub收到ep的连接,并在此连接和用户连接转发数据

我的完整代码在这里。逻辑基本与上面一致。
不过为了方便编码,以及以后用于拓展,在hub监听了三个端口。

  • 用户访问的端口,对于用户来说,这就是它要访问的地址。
  • ep连接的端口,连接此端口的,作为ep对待。
  • 通道连接的端口,hub发出新连接的请求后,ep连接建立通道,并把一端连接至此端口。

另外一个需要注意的点是,我们如何把ep的通道,对应至客户端的请求呢。
对于客户端来说,只要有通道可以用就可以了,是哪条还真不打紧。所以我们可以设置一个队列,所有的通道扔到里面,客户端从里面取就好。同样为了避免连接被关闭,可以设置一个超时时间,超时的通道直接被关闭。

其他问题

  1. 负载/延时
    由于整条通道由三条连接构成,所以延时一定会增加。
    而且,每个数据包都要经过转发,也就是需要消耗两倍的上下行带宽。
    我个人仅用作游戏服务器,而泰拉瑞亚对延时和带宽要求都很低,所以基本上是全程流畅无影响。
    如果数据传输量比较大,可以使用splice等技术优化读写时间。
  2. 健康检测(断线重连)
    hubep之间的通信连接可能由于各种情况中断(例如腾讯云会关闭一条长时间不用的连接)。
    所以需要在此连接加入定期的心跳,发现连接断开后及时进行重连。
  3. 防止爬虫和攻击
    在加入日志后,我才发现每天都会有很多陌生的请求。这些请求大部分是一些网络爬虫。
    我们需要防止的是hub把这些连接当做ep,或是当成ep建立的通道。
    一方面,在ep连接到hub的时候,做一些简单的验证(例如我加上了一个简单的密码验证)。
    在正确的ep连接以后,就只接收ep地址发来的连接作为通道,过滤掉其他地址发来的连接。
  4. 连接池及连接复用
    对于http服务,其实可以使用连接池,并复用连接。
    但是对于一般的游戏服务器不行。

目前这个穿透工具仍在继续运行,并且陪伴我和朋友结束了两个Terraria世界的旅行。
之后可能会修改这个工具做别的用处。
PS: Terraria的服务器是用C#写的,且没有ARM版。所以我在树莓派运行的计划泡汤了,只能跑在我的小开发机上。