UDP打洞实现容易,请问谁真正的实现了TCP打洞

解决方案 »

  1.   

    p2p网络 2010-11-13 22:55:51 阅读21 评论0  字号:大中小 

    这个标题用了两个顿号三个名称,其实说得是同一个东西,只是网上有不同的说法罢了,另外好像还有人叫TCP打孔(我的朋友小妞听说后问“要打孔啊,要不要我帮你去借个电钻过来啊?”“~!•¥%……•!”)。
    背景
    Internet的迅速发展以及IPv4 地址数量的限制使得网络地址翻译(NAT,Network Address Trans2lation)设备得到广泛应用。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;
    }
      

  2.   

    当有新的客户端连接到服务器时,服务器负责将该客户端的信息(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 );   
      

  3.   

     // 我(客户端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;
        if ( pSockClient_B->SendChunk ( &SrvReqMakeHolePkt, sizeof(t_SrvReqMakeHolePkt), 0 ) != sizeof(t_SrvReqMakeHolePkt) )
            return FALSE;    // 等待客户端B打洞完成,完成以后通知客户端A直接连接客户端外部IP和端口号
        if ( !HANDLE_IS_VALID(m_hEvtWaitClientBHole) )
            return FALSE;
        if ( WaitForSingleObject ( m_hEvtWaitClientBHole, 6000*1000 ) == WAIT_OBJECT_0 )
        {
            if ( SendChunk ( &m_SrvReqDirectConnectPkt, sizeof(t_SrvReqDirectConnectPkt), 0 ) 
                    == sizeof(t_SrvReqDirectConnectPkt) )
                return TRUE;
        }    return FALSE;
    }服务器S收到客户端A的协助打洞请求后通知客户端B,要求客户端B向客户端A打洞,即让客户端B尝试与客户端A的公网IP和端口进行connect。
    //
    // 执行者:客户端B
    // 处理服务器要我(客户端B)向另外一个客户端(A)打洞,打洞操作在线程中进行。
    // 先连接服务器协助打洞的端口号 SRV_TCP_HOLE_PORT ,通过服务器告诉客户端A我(客户端B)的外部IP地址和端口号,然后启动线程进行打洞,
    // 客户端A在收到这些信息以后会发起对我(客户端B)的外部IP地址和端口号的连接(这个连接在客户端B打洞完成以后进行,所以
    // 客户端B的NAT不会丢弃这个SYN包,从而连接能建立)
    //
    BOOL Handle_SrvReqMakeHole ( CSocket &MainSock, t_SrvReqMakeHolePkt *pSrvReqMakeHolePkt )
    {
        ASSERT ( pSrvReqMakeHolePkt );
        // 创建Socket,连接服务器协助打洞的端口号 SRV_TCP_HOLE_PORT,连接建立以后发送一个断开连接的请求给服务器,然后连接断开
        // 这里连接的目的是让服务器知道我(客户端B)的外部IP地址和端口号,以通知客户端A
        CSocket Sock;
        try
        {
            if ( !Sock.Create () )
            {
                printf ( "Create socket failed : %s\n", hwFormatMessage(GetLastError()) );
                return FALSE;
            }
            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()) );
                return FALSE;
            }
        }
        catch ( CException e )
        {
            char szError[255] = {0};
            e.GetErrorMessage( szError, sizeof(szError) );
            printf ( "Exception occur, %s\n", szError );
            return FALSE;
        }    CString csSocketAddress;
        ASSERT ( g_nHolePort == 0 );
        VERIFY ( Sock.GetSockName ( csSocketAddress, g_nHolePort ) );    // 连接服务器协助打洞的端口号 SRV_TCP_HOLE_PORT,发送一个断开连接的请求,然后将连接断开,服务器在收到这个包的时候也会将
        // 连接断开
        t_ReqSrvDisconnectPkt ReqSrvDisconnectPkt;
        ReqSrvDisconnectPkt.dwInviterID = pSrvReqMakeHolePkt->dwInvitedID;
        ReqSrvDisconnectPkt.dwInviterHoleID = pSrvReqMakeHolePkt->dwInviterHoleID;
        ReqSrvDisconnectPkt.dwInvitedID = pSrvReqMakeHolePkt->dwInvitedID;
        ASSERT ( ReqSrvDisconnectPkt.dwInvitedID == g_WelcomePkt.dwID );
        if ( Sock.Send ( &ReqSrvDisconnectPkt, sizeof(t_ReqSrvDisconnectPkt) ) != sizeof(t_ReqSrvDisconnectPkt) )
            return FALSE;
        Sleep ( 100 );
        Sock.Close ();    // 创建一个线程来向客户端A的外部IP地址、端口号打洞
        t_SrvReqMakeHolePkt *pSrvReqMakeHolePkt_New = new t_SrvReqMakeHolePkt;
        if ( !pSrvReqMakeHolePkt_New ) return FALSE;
        memcpy ( pSrvReqMakeHolePkt_New, pSrvReqMakeHolePkt, sizeof(t_SrvReqMakeHolePkt) );
        DWORD dwThreadID = 0;
        g_hThread_MakeHole = ::CreateThread ( NULL, 0, ::ThreadProc_MakeHole, 
            LPVOID(pSrvReqMakeHolePkt_New), 0, &dwThreadID );
        if (!HANDLE_IS_VALID(g_hThread_MakeHole) ) return FALSE;    // 创建一个线程来侦听端口 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;    // 等待打洞和侦听完成
        HANDLE hEvtAry[] = { g_hEvt_ListenFinished, g_hEvt_MakeHoleFinished };
        if ( ::WaitForMultipleObjects ( LENGTH(hEvtAry), hEvtAry, TRUE, 30*1000 ) == WAIT_TIMEOUT )
            return FALSE;
        t_HoleListenReadyPkt HoleListenReadyPkt;
        HoleListenReadyPkt.dwInvitedID = pSrvReqMakeHolePkt->dwInvitedID;
        HoleListenReadyPkt.dwInviterHoleID = pSrvReqMakeHolePkt->dwInviterHoleID;
        HoleListenReadyPkt.dwInvitedID = pSrvReqMakeHolePkt->dwInvitedID;
        if ( MainSock.Send ( &HoleListenReadyPkt, sizeof(t_HoleListenReadyPkt) ) != sizeof(t_HoleListenReadyPkt) )
        {
            printf ( "Send HoleListenReadyPkt to %s:%u failed : %s\n", 
            g_WelcomePkt.szClientIP, g_WelcomePkt.nClientPort,
                hwFormatMessage(GetLastError()) );
            return FALSE;
        }
        
        return TRUE;
    }
    客户端B收到服务器S的打洞通知后,先连接S的【协助打洞】端口号(本地端口号可以用GetSocketName()函数取得,假设为X), 启动线程尝试连接客户端A的公网IP和端口号,根据路由器不同,连接情况各异,如果运气好直接连接就成功了,即使连接失败,但打洞便完成了。同时还要启动 线程在相同的端口(即与S的【协助打洞】端口号建立连接的本地端口号X)上侦听到来的连接,等待客户端A直接连接该端口号。
    //
    // 执行者:客户端A
    // 服务器要求主动端(客户端A)直接连接被动端(客户端B)的外部IP和端口号
    //
    BOOL Handle_SrvReqDirectConnect ( t_SrvReqDirectConnectPkt *pSrvReqDirectConnectPkt )
    {
        ASSERT ( pSrvReqDirectConnectPkt );
        printf ( "You can connect direct to ( IP:%s  PORT:%d  ID:%u )\n", pSrvReqDirectConnectPkt->szInvitedIP,
            pSrvReqDirectConnectPkt->nInvitedPort, pSrvReqDirectConnectPkt->dwInvitedID );    // 直接与客户端B建立TCP连接,如果连接成功说明TCP打洞已经成功了。
        CSocket Sock;
        try
        {
            if ( !Sock.Socket () )
            {
                printf ( "Create socket failed : %s\n", hwFormatMessage(GetLastError()) );
                return FALSE;
            }
            UINT nOptValue = 1;
            if ( !Sock.SetSockOpt ( SO_REUSEADDR, &nOptValue , sizeof(UINT) ) )
            {
                printf ( "SetSockOpt socket failed : %s\n", hwFormatMessage(GetLastError()) );
                return FALSE;
            }
            if ( !Sock.Bind ( g_nHolePort ) )
            {
                printf ( "Bind socket failed : %s\n", hwFormatMessage(GetLastError()) );
                return FALSE;
            }
            for ( int ii=0; ii<100; ii++ )
            {
                if ( WaitForSingleObject ( g_hEvt_ConnectOK, 0 ) == WAIT_OBJECT_0 )
                    break;
                DWORD dwArg = 1;
                if ( !Sock.IOCtl ( FIONBIO, &dwArg ) )
                {
                    printf ( "IOCtl failed : %s\n", hwFormatMessage(GetLastError()) );
                }
                if ( !Sock.Connect ( pSrvReqDirectConnectPkt->szInvitedIP, pSrvReqDirectConnectPkt->nInvitedPort ) )
                {
                    printf ( "Connect to [%s:%d] failed : %s\n", 
                        pSrvReqDirectConnectPkt->szInvitedIP, 
                        pSrvReqDirectConnectPkt->nInvitedPort, 
                        hwFormatMessage(GetLastError()) );
                        Sleep (100);
                }
                else break;
            }
            if ( WaitForSingleObject ( g_hEvt_ConnectOK, 0 ) != WAIT_OBJECT_0 )
            {
                if ( HANDLE_IS_VALID ( g_hEvt_ConnectOK ) ) SetEvent ( g_hEvt_ConnectOK );
                printf ( "Connect to [%s:%d] successfully !!!\n", 
                pSrvReqDirectConnectPkt->szInvitedIP, pSrvReqDirectConnectPkt->nInvitedPort );
                
                // 接收测试数据
                printf ( "Receiving data ...\n" );
                char szRecvBuffer[NET_BUFFER_SIZE] = {0};
                int nRecvBytes = 0;
                for ( int i=0; i<1000; i++ )
                {
                    nRecvBytes = Sock.Receive ( szRecvBuffer, sizeof(szRecvBuffer) );
                    if ( nRecvBytes > 0 )
                    {
                        printf ( "-->>> Received Data : %s\n", szRecvBuffer );
                        memset ( szRecvBuffer, 0, sizeof(szRecvBuffer) );
                        SLEEP_BREAK ( 1 );
                    }
                    else
                    {
                        SLEEP_BREAK ( 300 );
                    }
                }
            }
        }
        catch ( CException e )
        {
            char szError[255] = {0};
            e.GetErrorMessage( szError, sizeof(szError) );
            printf ( "Exception occur, %s\n", szError );
            return FALSE;
        }    return TRUE;
    }
      

  4.   

    TCP连接只比UDP复杂一点点,TCP协议的“打洞”从协议层来看是与UDP的“打洞”过程非常相似的。尽管如此,基于TCP协议的打洞至今为止还没有被很好的理解,这也造成了对其提供支持的NAT设备不是很多。在NAT设备支持的前提下,基于TCP的 “打洞”技术实际上与基于UDP的“打洞”技术一样快捷、可靠。实际上,只要NAT设备支持的话,基于TCP的p2p技术的健壮性将比基于UDP的技术的更强一些,因为TCP协议的状态机给出了一种标准的方法来精确的获取某个TCP session的生命期,而UDP协议则无法做到这一点。4.1 套接字和TCP端口的重用实现基于TCP协议的p2p“打洞”过程中,最主要的问题不是来自于TCP协议,而是来自于来自于应用程序的API接口。这是由于标准的伯克利(Berkeley)套接字的API是围绕着构建客户端/服务器程序而设计的,API允许TCP流套接字通过调用connect()函数来建立向外的连接,或者通过listen()和accept函数接受来自外部的连接,但是,API不提供类似UDP那样的,同一个端口既可以向外连接,又能够接受来自外部的连接。而且更糟的是,TCP的套接字通常仅允许建立1对1的响应,即应用程序在将一个套接字绑定到本地的一个端口以后,任何试图将第二个套接字绑定到该端口的操作都会失败。为了让TCP“打洞”能够顺利工作,我们需要使用一个本地的TCP端口来监听来自外部的TCP连接,同时建立多个向外的TCP连接。幸运的是,所有的主流操作系统都能够支持特殊的TCP套接字参数,通常叫做“SO_REUSEADDR”,该参数允许应用程序将多个套接字绑定到本地的一个endpoint(只要所有要绑定的套接字都设置了SO_REUSEADDR参数即可)。BSD系统引入了SO_REUSEPORT参数,该参数用于区分端口重用还是地址重用,在这样的系统里面,上述所有的参数必须都设置才行。4.2 打开p2p的TCP流假定客户端A希望建立与B的TCP连接。我们像通常一样假定A和B已经与公网上的已知服务器S建立了TCP连接。服务器记录下来每个联入的客户端的公网和内网的endpoints,如同为UDP服务的时候一样。从协议层来看,TCP“打洞”与UDP“打洞”是几乎完全相同的过程。1、客户端A使用其与服务器S的连接向服务器发送请求,要求服务器S协助其连接客户端B。2、S将B的公网和内网的TCP endpoint返回给A,同时,S将A的公网和内网的endpoint发送给B。3、客户端A和B使用连接S的端口异步地发起向对方的公网、内网 endpoint的TCP连接,同时监听各自的本地TCP端口是否有外部的连接联入。4、A和B开始等待向外的连接是否成功,检查是否有新连接联入。如果向外的连接由于某种网络错误而失败,如:“连接被重置”或者“节点无法访问”,客户端只需要延迟一小段时间(例如延迟一秒钟),然后重新发起连接即可,延迟的时间和重复连接的次数可以由应用程序编写者来确定。5、TCP连接建立起来以后,客户端之间应该开始鉴权操作,确保目前联入的连接就是所希望的连接。如果鉴权失败,客户端将关闭连接,并且继续等待新的连接联入。客户端通常采用“先入为主”的策略,只接受第一个通过鉴权操作的客户端,然后将进入p2p通信过程不再继续等待是否有新的连接联入。
     (图 7)
    与UDP 不同的是,使用UDP协议的每个客户端只需要一个套接字即可完成与服务器S通信,并同时与多个p2p客户端通信的任务,而TCP客户端必须处理多个套接字绑定到同一个本地TCP端口的问题,如图7所示。现在来看更加实际的一种情景,A与B分别位于不同的NAT设备后面,如图5所示,并且假定图中的端口号是 TCP协议的端口号,而不是UDP的端口号。图中向外的连接代表A和B向对方的内网endpoint发起的连接,这些连接或许会失败或者无法连接到对方。如同使用UDP协议进行“打洞”操作遇到的问题一样,TCP的“打洞”操作也会遇到内网的IP与“伪”公网IP重复造成连接失败或者错误连接之类的问题。客户端向彼此公网endpoint发起连接的操作,会使得各自的NAT设备打开新的“洞”允许A与B的TCP数据通过。如果NAT设备支持TCP“打洞” 操作的话,一个在客户端之间的基于TCP协议的流通道就会自动建立起来。如果A向B发送的第一个SYN包发到了B的NAT设备,而B在此前没有向A发送 SYN包,B的NAT设备会丢弃这个包,这会引起A的“连接失败”或“无法连接”问题。而此时,由于A已经向B发送过SYN包,B发往A的SYN包将被看作是由A发往B的包的回应的一部分,所以B发往A的SYN包会顺利地通过A的NAT设备,到达A,从而建立起A与B的p2p连接。4.3 从应用程序的角度来看TCP“打洞”从应用程序的角度来看,在进行TCP“打洞”的时候都发生了什么呢?假定A首先向B发出SYN包,该包发往B的公网 endpoint,并且被B的NAT设备丢弃,但是B发往A的公网endpoint的SYN包则通过A的NAT到达了A,然后,会发生以下的两种结果中的一种,具体是哪一种取决于操作系统对TCP协议的实现:(1)A的TCP实现会发现收到的SYN包就是其发起连接并希望联入的B的SYN包,通俗一点来说就是“说曹操,曹操到”的意思,本来A要去找B,结果B自己找上门来了。A的TCP协议栈因此会把B做为A向B发起连接connect的一部分,并认为连接已经成功。程序A调用的异步connect()函数将成功返回,A的listen()等待从外部联入的函数将没有任何反映。此时,B联入A的操作在A程序的内部被理解为A联入B连接成功,并且A开始使用这个连接与B开始p2p通信。由于收到的SYN包中不包含A需要的ACK数据,因此,A的TCP将用 SYN-ACK包回应B的公网endpoint,并且将使用先前A发向B的SYN包一样的序列号。一旦B的TCP收到由A发来的SYN-ACK包,则把自己的ACK包发给A,然后两端建立起TCP连接。简单的说,第一种,就是即使A发往B的SYN包被B的NAT丢弃了,但是由于B发往A的包到达了A。结果是,A认为自己连接成功了,B也认为自己连接成功了,不管是谁成功了,总之连接是已经建立起来了。(2)另外一种结果是,A的TCP实现没有像(1)中所讲的那么“智能”,它没有发现现在联入的B就是自己希望联入的。就好比在机场接人,明明遇到了自己想要接的人却不认识,误认为是其它的人,安排别人给接走了,后来才知道是自己错过了机会,但是无论如何,人已经接到了任务已经完成了。然后,A通过常规的listen()函数和accept()函数得到与B的连接,而由A发起的向B的公网endpoint的连接会以失败告终。尽管A向B的连接失败,A仍然得到了B发起的向A的连接,等效于A与B之间已经联通,不管中间过程如何,A与B已经连接起来了,结果是A和B的基于TCP协议的p2p连接已经建立起来了。第一种结果适用于基于BSD的操作系统对于TCP的实现,而第二种结果更加普遍一些,多数linux和windows系统都会按照第二种结果来处理。