文章目录
前言
为了使服务后台程序能够在内网机器里有更好的体验,可以使用内网穿透,让外网用户也可以访问到该机器上的服务后台程序。下面说说我的内网穿透方案,并用C++实现。
思路
我的想法是通过公网服务器转发的方式实现内网穿透。需要实现两个工具,我分别称它们为:代理服务后台
、代理客户端
。我把实际上的服务器称为:真正的服务后台
。
代理服务后台,顾名思义就是在公网服务器上跑一个服务程序,外网用户只需要连接该服务程序即可达到和直接连接真正的服务后台相同的效果;外网用户直接给该服务程序发送消息,将会原封不动的让真正的服务后台收到;而真正的服务后台给用户发送消息时,该服务程序会原封不动的把真正的服务后台要给用户发送的消息发送给指定用户。
代理客户端,就是,在一台既可以连接到代理服务后台,又可以连接到真正的服务后台,的机器上运行的辅助客户端。
用户发送消息转发给真正的服务后台流程如下:
真正的服务后台反馈消息给用户流程如下:
部署
需要在一台既可以连接内网机器的真正的服务后台又可以连接外网服务器上跑的代理服务后台的机器上(下面称为:用户模拟机)运行代理客户端。
首先,在公网服务器上启动代理服务后台,然后在模拟用户机上启动代理客户端连接到代理服务后台。
此后,凡是处于外网的用户需要连接到真正的服务后台的,只需要连接代理服务后台即可,这样代理服务后台和代理客户端就成为了用户和真正的服务后台之间联系的中间人。
示例(以TCP为例)
交互逻辑
不能奢望用户和真正的服务后台之间的交互协议一定会满足我们的内网穿透工具的约定,所以,我们要设计一种无论用户和真正的服务后台发送什么样的消息,都能原原本本地转发给对端的交互逻辑。
满足这样的需求的交互逻辑有无数种,下面说说我的设计方式:
因为要满足用户和真正的服务后台可以随意发送消息,所以,用户和代理服务后台之间不设计交互指令,真正的服务后台和代理客户端之间也不设计交互指令,这两条信道发送的消息都不要动它。因此我们只能在代理服务后台和代理客户端之间的通信做文章。
我在代理服务后台和代理客户端的通信之间,设计了一个结构体Message
:
struct Message
{
String type; //类型
String id; //用户ID
ByteArray data; //发送的消息
};
其中类型(type)分为4种:c(connected)、d(disconnected)、m(message)、a(alive)
c表示用户连接,d表示用户(或服务器)断开连接,m表示用户(或服务器)发送消息给对端,a表示代理客户端给代理服务后台发送心跳包。
用户ID(id)表示用户标识符,当用户要给真正的服务后台发送消息时,id为用户的标识符,当真正的服务后台要给用户发送消息时,id为接收消息的用户的标识符。
发送的消息(data)为用户和真正的服务后台之间实际上发送的消息内容。
代理服务后台和代理客户端之间发送这4种类型的消息的格式如下:
类型 | 格式 |
---|---|
c | c:<用户标识符># |
d | d:<用户标识符># |
m | m:<用户标识符>;<消息内容长度>#<消息内容> |
a | a# |
1、用户连接
因为使用了代理服务后台,所以用户会连接到代理服务后台(假设该用户在代理服务后台的套接字对象的指针为0x00000001),然后代理服务后台向代理客户端发送消息:c:0x00000001#
,代理客户端在收到这个消息后,就构造结构体Message("c", "0x00000001", "")
加入到待处理消息队列(或其他容器)。当代理客户端处理到这条消息后,添加一个虚拟用户(id为0x00000001),创建一个套接字连接到真正的服务后台。
2、断开连接
2.1、用户断开连接
这种情况下,代理服务后台会获取到用户断开连接的信号(假设该用户的id为0x00000001),但是代理客户端还不知道用户已经断开了连接,所以需要代理服务后台代理客户端发送消息通知代理客户端该用户断开了连接。代理服务后台给代理客户端发送消息:d:0x00000001#
。代理客户端收到这条消息后,构造结构体Message("d", "0x00000001", "")
添加到消息队列,当处理到这条消息后,断开虚拟用户id为0x00000001的用户的套接字与真正的服务后台的连接。这样就做到的连接状态的转发。
2.2、真正的服务后台断开连接
这种情况下,代理客户端会获取到断开信号(假设产生断开信号的虚拟用户id为0x00000001),但是代理服务器和用户都不知道已经断开连接了,所以需要代理客户端发送消息通知代理服务器,然后代理服务器再断开和用户的连接,实现连接状态的转发。代理客户端将给代理服务后台发送消息:d:0x00000001#
,代理服务后台构造结构体Message("d", "0x00000001", "")
添加到消息队列,然后处理到这条消息时,断开用户id为0x00000001的套接字的连接。
3、发送消息
3.1、用户给真正的服务后台发送消息
当用户要给真正的服务后台发送消息时(假设这个用户id为0x00000001),会发送到代理服务后台,此时,代理服务后台会得到用户发送的消息内容msgdata,然后代理服务后台给代理客户端发送消息:
"m:0x00000001;"+numberToByteArray(size(msgdata))+"#"+msgdata
代理客户端查找第一个#
的位置,把#
之前的消息取出,并设置剩余消息大小为size(msgdata)。之后,把接下来接收到连续size(msgdata)这么多字节的消息,都保存到一个缓存消息字节数组里(假设是buffer),每接收1个字节,剩余消息大小就减1,当剩余消息大小为0时,构造结构体:Message("m", "0x00000001", buffer)
并添加到待处理消息队列。当代理客户端处理到这条消息时,用id为0x00000001的虚拟用户连接到真正的服务后台的套接字来给真正的服务后台发送buffer。
3.2、真正的服务后台给用户发送消息
和3.1同理,当真正的服务后台要给用户发送消息时,肯定会发送给代理客户端,这样,代理客户端按照3.1的方式,构造消息发送给代理服务后台。代理服务后台接收到该消息后,再通过设置剩余消息大小来接收整条消息,接收完毕后,构造结构体添加到待处理的消息队列,处理到该消息后,再把内容发送给用户。
4、心跳包
每间隔指定时间,代理客户端给代理服务后台发送消息:a#
即可。
代码实现
C++网络编程要是从头写起,一旦需要实现复杂的并发,则会需要各种多线程、线程锁、多路复用、状态变量,还需要设计收发消息的机制,要处理很多底层的问题,比较麻烦。这里我们只是想实现内网穿透,所以底层方面可以调库。如果你目前没有什么网络方面的库,可以用我写的C++网络库,链接:Eyre Turing 的网络模块库。因为我比较喜欢用命令行编译,所以我的库都是通过编写Makefile文件,使用make(或mingw32-make)的方式编译的,如果你是用Visual Studio等IDE编译,请自行配置。
提取交互信息
void messageRead(ByteArray &message)
{
while(message.size())
{
if(restBufferSize)
{
unsigned long long len = message.size();
if(restBufferSize > len)
{
buffer += message;
message = "";
restBufferSize -= len;
}
else
{
buffer += message.mid(0, restBufferSize);
message = message.mid(restBufferSize);
restBufferSize = 0;
}
if(!restBufferSize)
{
Message m = {"m", sender, buffer};
pthread_mutex_lock(&messagesMutex);
m_messages.push_back(m);
pthread_mutex_unlock(&messagesMutex);
sender = "";
buffer = "";
}
else
{
continue;
}
}
message = head+message;
head = "";
int endSymPos = message.indexOf("#");
if(endSymPos == -1)
{
head += message;
message = "";
}
else
{
ByteArray _head = message.mid(0, endSymPos);
vector<ByteArray> headInfo = _head.split(":");
pthread_mutex_lock(&messagesMutex);
if(headInfo[0] == "c") // client connected
{
// c:id
if(headInfo.size() == 2)
{
String id = String::fromUtf8(headInfo[1]);
Message m = {"c", id, ""};
m_messages.push_back(m);
}
}
else if(headInfo[0] == "d") // client disconnected
{
// d:id
if(headInfo.size() == 2)
{
String id = String::fromUtf8(headInfo[1]);
Message m = {"d", id, ""};
m_messages.push_back(m);
}
}
else if(headInfo[0] == "m") // send message
{
// m:id;length
if(headInfo.size() == 2)
{
vector<ByteArray> info = headInfo[1].split(";");
if(info.size() == 2)
{
sender = String::fromUtf8(info[0]);
restBufferSize = String::fromUtf8(info[1]).toUInt64();
}
}
}
else if(headInfo[0] == "a")
{
// a
Message m = {"a", "", ""};
m_messages.push_back(m);
}
pthread_mutex_unlock(&messagesMutex);
message = message.mid(endSymPos+1);
}
}
}
处理交互信息
void handleEvent()
{
pthread_mutex_lock(&messagesMutex);
unsigned int len = m_messages.size();
for(unsigned int i=0; i<len; ++i)
{
Message m = m_messages[i];
if(m.type == "m")
{
if(printMessage)
{
cout<<"real server send message to user "<<m.id<<"."<<endl;
cout<<m.data.toString(CODEC_UTF8)<<endl;
}
pthread_mutex_lock(&usersMutex);
map<String, TcpSocket *>::iterator it = users.find(m.id);
TcpSocket *target = NULL;
if(it != users.end())
{
target = it->second;
}
pthread_mutex_unlock(&usersMutex);
if(target)
{
target->write(m.data);
}
#ifdef _WIN32
Sleep(1);
#else
usleep(1000);
#endif
}
else if(m.type == "a")
{
cout<<"virtual client alive."<<endl;
}
else if(m.type == "d")
{
cout<<"virtual client disconnect from real server."<<endl;
TcpSocket *temp = NULL;
pthread_mutex_lock(&usersMutex);
map<String, TcpSocket *>::iterator it = users.find(m.id);
if(it != users.end())
{
temp = it->second;
}
pthread_mutex_unlock(&usersMutex);
if(temp)
{
temp->abort();
}
}
}
m_messages.clear();
pthread_mutex_unlock(&messagesMutex);
}
由于完整代码较长,贴出影响阅读体验,上面只给出核心代码,需要完整工程移步github:Eyre Turing 的内网穿透工具。
示例程序说明
机缘巧合,我以前搭建了一个文件存储服务器,但是那个服务器没有开启公网IP,导致外网无法访问。而且,为了实现我的毕业设计,我用C++从socket开始实现了一个网络库,并实现了INI文件读写、封装了ByteArray和String类。由于有这些基础,我就开始研究实现一个内网穿透工具,来使得外网用户也可以访问该文件存储服务器。
经过几天的开发、几个星期的Debug和一段时间的测试、Bug重现、Bug修改、继续测试。。。现在,这个工具已经可以正常使用了。
使用方式如下:
编译好后,在server文件夹里,会有一个server可执行文件(windows下是server.exe,linux下是server),编辑好配置文件就可以在有公网IP的机器上运行server了。
编译好后,在client文件夹里,会有一个client可执行文件(windows下是client.exe,linux下是client),编辑好配置文件就可以在能够同时连接到公网以及真正的服务器的机器上运行client了。
配置文件字段说明:
1、server 配置
[listen]
client=1234
; listen/client 字段表示给代理客户端开启的连接端口
user=5678
; listen/user 字段表示给用户开启的连接端口
2、client 配置
[real]
host=127.0.0.1
; real/host 表示真正的服务后台IP地址
port=8000
; real/port 表示真正的服务后台端口号
connectTimeout=500
; real/connectTimeout 表示代理客户端创建的虚拟用户连接真正的服务器时间上限(单位:毫秒)
[virtual]
host=127.0.0.1
; virtual/host 表示代理服务后台IP地址
port=1234
; virtual/port 表示代理服务后台给代理客户端开启的连接端口
heart=15
; virtual/heart 表示心跳包发送时间间隔(目前不准确,大体上单位是秒)