背景
Internet 的迅速发展以及 IPv4地址数量的限制使得网络地址翻译(NAT,Network Address Translation)设备得到广泛应
用。NAT 设备允许处于同一 NAT 后的多台主机共享一个公网(本文将处于同一 NAT 后的网络称为私网,处于 NAT
前的网络称为公网) IP 地址。一个私网 IP 地址通过 NAT 设备与公网的其他主机通信。公网和私网 IP 地址域,如下
图所示:
一般来说都是由私网内主机(例如上图中“电脑 A-01”)主动发起连接,数据包经过 NAT 地址转换后送给公网上的
服务器(例如上图中的 “Server”),连接建立以后可双向传送数据,NAT 设备允许私网内主机主动向公网内主机发
送数据,但却禁止反方向的主动传递,但在一些特殊的场合需要不同私网内的主机进行互联(例如 P2P 软件、网络会
议、视频传输等),TCP 穿越 NAT 的问题必须解决。网上关于 UDP 穿越 NAT 的文章很多,而且还有配套源代
码,但是我个人认为 UDP 数据虽然速度快,但是没有保障,而且 NAT 为 UDP 准备的临时端口号有生命周期的限
制,使用起来不够方便,在需要保证传输质量的应用上 TCP 连接还是首选(例如:文件传输)。
网上也有不少关于 TCP 穿越 NAT(即 TCP 打洞)的介绍文章,但不幸我还没找到相关的源代码可以参考,我利用
空余时间写了一个可以实现 TCP 穿越 NAT,让不同的私网内主机建立直接的 TCP 通信的源代码。
NAT 的类型
NAT 设备的类型对于 TCP 穿越 NAT,有着十分重要的影响,根据端口映射方式,NAT 可分为如下4类,前3种 NAT 类型
可统称为 cone 类型。
� 全克隆( Full Cone) : NAT 把所有来自相同内部 IP 地址和端口的请求映射到相同的外部 IP 地址和端口。任何
一个外部主机均可通过该映射发送 IP 包到该内部主机。
� 限制性克隆(Restricted Cone) : NAT 把所有来自相同内部 IP 地址和端口的请求映射到相同的外部 IP 地址和端
口。但是,只有当内部主机先给 IP 地址为 X 的外部主机发送 IP 包,该外部主机才能向该内部主机发送 IP 包。
� 端口限制性克隆( Port Restricted Cone) :端口限制性克隆与限制性克隆类似,只是多了端口号的限制,即只有内
部主机先向 IP 地址为 X,端口号为 P 的外部主机发送1个 IP 包,该外部主机才能够把源 端口号为 P 的 IP 包发送
给该内部主机。
� 对称式 NAT ( Symmetric NAT) :这种类型的 NAT 与上述3种类型的不同,在于当同一内部主机使用相同的端
口与不同地址的外部主机进行通信时, NAT 对该内部主机的映射会有所不同。对称式 NAT 不保证所有会话中的
私有地址和公开 IP 之间绑定的一致性。相反,它为每个新的会话分配一个新的端口号。
我们先假设一下:有一个服务器 S 在公网上有一个 IP,两个私网分别由 NAT-A 和 NAT-B 连接到公网,NAT-A 后
面有一台客户端 A,NAT-B 后面有一 台客户端 B,现在,我们需要借助 S 将 A 和 B 建立直接的 TCP 连接,即由
B 向 A 打一个洞,让 A 可以沿这个洞直接连接到 B 主机,就好像 NAT-B 不存在一样。实现过程如下(请参照源代
码):
1 S 启动两个网络侦听,一个叫【主连接】侦听,一个叫【协助打洞】的侦听。
2 A 和 B 分别与 S 的【主连接】保持联系。
3 当 A 需要和 B 建立直接的 TCP 连接时,首先连接 S 的【协助打洞】端口,并发送协助连接申请。同时在该
端口号上启动侦听。注意由于要在相同的网络终端上绑定 到不同的套接字上,所以必须为这些套接字设置
SO_REUSEADDR 属性(即允许重用),否则侦听会失败。
4 S 的【协助打洞】连接收到 A 的申请后通过【主连接】通知 B,并将 A 经过 NAT-A 转换后的公网 IP 地址
和端口等信息告诉 B。
5 B 收到 S 的连接通知后首先与 S 的【协助打洞】端口连接,随便发送一些数据后立即断开?,这样做的目的
是让 S 能知道 B 经过 NAT-B 转换后的公网 IP 和端口号。
6 B 尝试与 A 的经过 NAT-A 转换后的公网 IP 地址和端口进行 connect,根据不同的路由器会有不同的结果,
有些路由器在这个操作就能建立连接(例如我 用的 TPLink R402),大多数路由器对于不请自到的 SYN 请求
包直接丢弃而导致 connect 失败,但 NAT-A(NAT-B?)会纪录此次连接的源地址和端口号,为接下来真正的连 接
做好了准备,这就是所谓的打洞,即 B 向 A 打了一个洞,下次 A 就能直接连接到 B 刚才使用的端口号了。
7 客户端 B 打洞的同时在相同的端口上启动侦听。B 在一切准备就绪以后通过与 S 的【主连接】回复消息“我
已经准备好”,S 在收到以后将 B 经过 NAT-B 转换后的公网 IP 和端口号告诉给 A。
8 A 收到 S 回复的 B 的公网 IP 和端口号等信息以后,开始连接到 B 公网 IP 和端口号,由于在步骤6中 B 曾经
尝试连接过 A 的公网 IP 地址和端口,NAT-A(NAT-B?)纪录 了此次连接的信息,所以当 A 主动连接 B 时,
NAT-B 会认为是合法的 SYN 数据,并允许通过,从而直接的 TCP 连接建立起来了。(双方都知道了对方 NAT
外边的 IP 和端口)
整个实现过程靠文字恐怕很难讲清楚,再加上我的语言表达能力很差(高考语文才考75分,总分150分,惭愧),
所以只好用代码来说明问题了。
// 服务器地址和端口号定义
#define SRV_TCP_MAIN_PORT 4000 // 服务器主连接的端口号
#define SRV_TCP_HOLE_PORT 8000 // 服务器响应客户端打洞申请的端口号
这两个端口是固定的,服务器 S 启动时就开始侦听这两个端口了。
//
// 将新客户端登录信息发送给所有已登录的客户端,但不发送给自己
//
BOOL SendNewUserLoginNotifyToAll ( LPCTSTR lpszClientIP, UINT nClientPort, DWORD dwID )
{
ASSERT ( lpszClientIP && nClientPort > 0 );
g_CSFor_PtrAry_SockClient.Lock();
for ( int i=0; i<g_PtrAry_SockClient.GetSize(); i++ )
{
CSockClient *pSockClient = (CSockClient*)g_PtrAry_SockClient.GetAt(i);
if ( pSockClient && pSockClient->m_bMainConn && pSockClient->m_dwID > 0 && pSockClient->m_dwID !=
dwID )
{
if ( !pSockClient->SendNewUserLoginNotify ( lpszClientIP, nClientPort, dwID ) )
{
g_CSFor_PtrAry_SockClient.Unlock();
return FALSE;
}
}
}
g_CSFor_PtrAry_SockClient.Unlock ();
return TRUE;
}
当有新的客户端连接到服务器时,服务器负责将该客户端的信息(IP 地址、端口号)发送给其他客户端。
//
// 执行者:客户端 A
// 有新客户端 B 登录了,我(客户端 A)连接服务器端口 SRV_TCP_HOLE_PORT ,申请与客户端 B 建立直接的
TCP 连接
//
BOOL Handle_NewUserLogin ( CSocket &MainSock, t_NewUserLoginPkt *pNewUserLoginPkt )
{
printf ( "New user ( %s:%u:%u ) login server\n", pNewUserLoginPkt->szClientIP,
pNewUserLoginPkt->nClientPort, pNewUserLoginPkt->dwID );
BOOL bRet = FALSE;
DWORD dwThreadID = 0;
t_ReqConnClientPkt ReqConnClientPkt;
CSocket Sock;
CString csSocketAddress;
char szRecvBuffer[NET_BUFFER_SIZE] = {0};
int nRecvBytes = 0;
// 创建打洞 Socket,连接服务器协助打洞的端口号 SRV_TCP_HOLE_PORT
try
{
if ( !Sock.Socket () )
{
printf ( "Create socket failed : %s\n", hwFormatMessage(GetLastError()) );
goto finished;
}
UINT nOptValue = 1;
if ( !Sock.SetSockOpt ( SO_REUSEADDR, &nOptValue , sizeof(UINT) ) )
{
printf ( "SetSockOpt socket failed : %s\n", hwFormatMessage(GetLastError()) );
goto finished;
}
if ( !Sock.Bind ( 0 ) )
{
printf ( "Bind socket failed : %s\n", hwFormatMessage(GetLastError()) );
goto finished;
}
if ( !Sock.Connect ( g_pServerAddess, SRV_TCP_HOLE_PORT ) )
{
printf ( "Connect to [%s:%d] failed : %s\n", g_pServerAddess,
SRV_TCP_HOLE_PORT, hwFormatMessage(GetLastError()) );
goto finished;
}
}
catch ( CException e )
{
char szError[255] = {0};
e.GetErrorMessage( szError, sizeof(szError) );
printf ( "Exception occur, %s\n", szError );
goto finished;
}
g_pSock_MakeHole = &Sock;
ASSERT ( g_nHolePort == 0 );
VERIFY ( Sock.GetSockName ( csSocketAddress, g_nHolePort ) );
// 创建一个线程来侦听端口 g_nHolePort 的连接请求
dwThreadID = 0;
g_hThread_Listen = ::CreateThread ( NULL, 0, ::ThreadProc_Listen, LPVOID(NULL), 0, &dwThreadID );
if (!HANDLE_IS_VALID(g_hThread_Listen) ) return FALSE;
Sleep ( 3000 );
// 我(客户端 A)向服务器协助打洞的端口号 SRV_TCP_HOLE_PORT 发送申请,希望与新登录的客户端 B
建立连接
// 服务器会将我的打洞用的外部 IP 和端口号告诉客户端 B
ASSERT ( g_WelcomePkt.dwID > 0 );
ReqConnClientPkt.dwInviterID = g_WelcomePkt.dwID;
ReqConnClientPkt.dwInvitedID = pNewUserLoginPkt->dwID;
if ( Sock.Send ( &ReqConnClientPkt, sizeof(t_ReqConnClientPkt) ) != sizeof(t_ReqConnClientPkt) )
goto finished;
// 等待服务器回应,将客户端 B 的外部 IP 地址和端口号告诉我(客户端 A)
nRecvBytes = Sock.Receive ( szRecvBuffer, sizeof(szRecvBuffer) );
if ( nRecvBytes > 0 )
{
ASSERT ( nRecvBytes == sizeof(t_SrvReqDirectConnectPkt) );
PACKET_TYPE *pePacketType = (PACKET_TYPE*)szRecvBuffer;
ASSERT ( pePacketType && *pePacketType == PACKET_TYPE_TCP_DIRECT_CONNECT );
Sleep ( 1000 );
Handle_SrvReqDirectConnect ( (t_SrvReqDirectConnectPkt*)szRecvBuffer );
printf ( "Handle_SrvReqDirectConnect end\n" );
}
// 对方断开连接了
else
{
goto finished;
}
bRet = TRUE;
finished:
g_pSock_MakeHole = NULL;
return bRet;
}
这里假设客户端 A 先启动,当客户端 B 启动后客户端 A 将收到服务器 S 的新客户端登录的通知,并得到客户端 B
的公网 IP 和端口,客户端 A 启动线程 连接 S 的【协助打洞】端口(本地端口号可以用 GetSocketName()函数取得,
假设为 M),请求 S 协助 TCP 打洞,然后启动线程侦听该本地端口 (前面假设的 M)上的连接请求,然后等待服
务器的回应。
//
// 客户端 A 请求我(服务器)协助连接客户端 B,这个包应该在打洞 Socket 中收到
//
BOOL CSockClient::Handle_ReqConnClientPkt(t_ReqConnClientPkt *pReqConnClientPkt)
{
ASSERT ( !m_bMainConn );
CSockClient *pSockClient_B = FindSocketClient ( pReqConnClientPkt->dwInvitedID );
if ( !pSockClient_B ) return FALSE;
printf ( "%s:%u:%u invite %s:%u:%u connection\n", m_csPeerAddress, m_nPeerPort, m_dwID,
pSockClient_B->m_csPeerAddress, pSockClient_B->m_nPeerPort, pSockClient_B->m_dwID );
// 客户端 A 想要和客户端 B 建立直接的 TCP 连接,服务器负责将 A 的外部 IP 和端口号告诉给 B
t_SrvReqMakeHolePkt SrvReqMakeHolePkt;
SrvReqMakeHolePkt.dwInviterID = pReqConnClientPkt->dwInviterID;
SrvReqMakeHolePkt.dwInviterHoleID = m_dwID;
SrvReqMakeHolePkt.dwInvitedID = pReqConnClientPkt->dwInvitedID;
STRNCPY_CS ( SrvReqMakeHolePkt.szClientHoleIP, m_csPeerAddress );
SrvReqMakeHolePkt.nClientHolePort = m_nPeerPort;