如何在 System.Net.Sockets C# 中实现与 TCPClient/TCPListener 的 "peer-to-peer" 连接(NAT 遍历,无端口转发,打孔)

How to achieve a "peer-to-peer" connection with TCPClient/TCPListener in System.Net.Sockets C# (NAT Traversal, no portforward, hole punching)

前言:

这个问题是我到目前为止所学知识的集合。好像讨论的不多(可能是用的不多吧?)

仅有的其他 post 已有数年历史,几乎没有可靠的答案,但确实引入了新的想法,我相信这些想法会引导我朝着正确的方向前进。所以我想在这个问题中把它们全部列出来。所以希望所有这些问题都可以在这里为我和其他同样提出这个问题的人回答。让这成为其他人的指南。

TLDR:跳到“我的主要问题”部分。这是我要说的主要内容。


我的问题:(上下文)

我最近发现了 TCPClient/TCPListener api (System.Net.Sockets),想用它来学习更多关于网络的知识。 Microsoft 文档提供了“起始代码”来向您介绍其基本功能。 https://docs.microsoft.com/en-us/dotnet/api/system.net.sockets.tcpclient?view=net-6.0

https://docs.microsoft.com/en-us/dotnet/api/system.net.sockets.tcplistener?view=net-6.0

(注意,我知道 FTP)

一旦我开始修修补补,我就决定制作一个程序,使用他们的私有 IP 通过我的 LAN(我的 PC 到我的笔记本电脑)发送文件。我终于成功了。

当我对自己说“现在让我们看看是否可以将文件发送给我的朋友”时,我的问题就开始了。

(注意,是的,我的文件没有加密,我只发送了 不敏感的 信息,例如带有涂鸦的 PNG,用于测试目的)

我的代码已设置为“文件接收方”也是服务器主机 (TCPListener)。我把程序发给了我的朋友,并向他展示了如何将其设置为文件接收器(主机)。当我试图在我这边发送文件时,我收到一条错误消息:

"A connection attempt failed because the connected party did not properly respond... etc". Which was a "SocketException (10060)"

我确实确保使用他的 public IP 进行连接,而且我们使用的是相同的端口。我做了各种其他事情来检查并查看可能导致问题的原因。没有修复。

然后我请我的朋友尝试向我发送一个文件(我将是服务器)。但是这次,我要求他使用端口“25565”,因为我很久以前就为 Minecraft 转发了那个端口。之前我们用的是“13000”,后来我们都换成25565了,我朋友成功给我传了一个文件。

所以在我看来问题出在端口转发上。我没有尝试测试它是否被防火墙阻止,但我对此表示怀疑。


我的主要问题:

对于 C# 的 System.Net.Socket 库,我怎样才能简单地实现两台 PC 之间的“点对点”WAN 连接,而不需要像端口转发这样的“干预”?

我在不同的地方查找示例代码,可能会显示如何以某种方式使用“套接字”class 来实现此目的,但经过各种 Google 搜索后我一无所获。我找到的是基本的 concepts/terminologies,声称是解决方案。

喜欢: Sending TCP Packets to outside computer without Port Forwarding

https://serverfault.com/questions/604644/access-to-a-network-server-without-port-forwarding

TCP NAT-Traversal /- Punching with .NET

但我正在寻找如何使用这个特定的库来实现这些概念。或者,如果 System.Net.Sockets 无法实现,是否有另一个 C# 库更适合我的情况?

TCPListeners 有一个“AllowNatTraversal”选项,但这并没有使它起作用。所以我对这个问题的状态是,也许我必须以非常确定的方式使用底层套接字 class 来进行“TCP 打孔”,我不确定。 这是我第一次搞砸网络 api 这么低的水平

我可以提供我的代码,但是这个 post 已经很长了。如果需要,我会用那个代码修改这个问题。


最终:

这个问题也与我遇到的关于网络的其他问题有关。由于我正在开发一个更大的游戏项目,我正在考虑加入多人游戏选项。

大多数带有多人游戏选项的游戏通常需要您转发端口(进入您的路由器设置)。但是有些人没有那个选项,但仍然希望能够在“私人设置的大厅”中与朋友一起玩游戏。

像泰拉瑞亚...我也曾尝试研究泰拉瑞亚如何实现其“主机和游戏”多人游戏,但没有得出明确的答案。每个人都只是称它为“点对点”或“托管点对点”,但没有提供有关其工作原理或他们使用哪些库的详细信息。

默认情况下,您不能只托管服务器而不必在路由器设置中公开端口(用户需要做更多工作),我知道这是首选方法,但这样做也可以让您漏洞。有些人没有辅助功能。

我想要一个“内置”选项以允许某种不需要端口转发的 WAN connection/communication。我知道这是可能的,在我目前的情况下,我想知道如何为 System.Net.Sockets.

做到这一点

我知道像 UPnP 这样的东西,我听说它不是那么好。


最后:

如果有更好的地方问这个问题,比如 Whosebug 的网络部分,我可以把我的 post 移到那里。但在这种情况下,我要问的是一个特定的图书馆。

我希望我已经解释得足够多了,这样任何专家都可以提供帮助。我希望这篇 post 可以为任何试图了解这种特殊网络形式的人铺平道路。因为我相信许多游戏开发者也希望在他们的游戏中有“玩家托管的点对点”多人游戏。因为 运行 不需要专用服务器。并且需要最少的设置工作量。

我做了一些研究并找到了自己的答案,但现在我想在这里与其他人分享。

这篇 post 很长,所以请先查看标题以确定您想阅读的内容。我已经更新了这个 post 好几天了。并添加了新内容,每个主标题可能会重复一些内容。

但此时此刻,我建议您阅读下面的“答案 #2”。并阅读“未来的一些重要信息”。

未来的一些重要信息:

你不能只有 2 个 UDP (或 TCP) 客户端通过 WAN (使用 public 相互发送数据包的原因IP 地址) 是因为接收数据包的设备,例如您的路由器,不知道要将其发送到 LAN (连接到路由器) 上的哪台计算机;它没有关于将它发送到哪里的足够信息。

您的路由器需要以某种方式被告知将数据包路由到哪里。实现 peer-to-peer 连接需要您使用某种方法或技术来告诉您的路由器将事物路由到哪里。

“干草路由器,我要发送一个数据包到 [这个 IP 和端口]。我期待一个响应,如果你从那个 IP 和端口得到任何东西,把它发送给我”

“干草路由器,如果你收到任何带有[THIS PORT]的数据包,请将它们发送给我”

以下是我发现的执行此操作的方法。就个人而言,我更喜欢像“NAT-PMP”和“UPnP”这样的 NAT 协议(答案 #2)


答案 #1(打孔)(最后的手段)

我确实找到了这个视频,它确实有助于解释很多关于该主题的事情。

https://www.youtube.com/watch?v=TiMeoQt3K4g

还有一个 sequel 他做了一个 python 实现

https://www.youtube.com/watch?v=IbzGL_tjmv4

据此,我设法为 UDP 制作了 NAT 打孔的 C# 实现。我假设同样的技术也可以应用于 TCP?如果有人可以对此发表评论,或者是否必须修改该技术以适用于 tcp。

// This code could be in Main, or another static function

Console.WriteLine("enter the other persons IP:");
string destinationIP = Console.ReadLine();
Console.WriteLine("type a message you want to send:");
string inputMessage = Console.ReadLine();

int sourcePort = 25000;
int destinationPort = 25001;

// Send a "dummy packet" which "punches a hole" in your router, so each router knows where to route the packets.
UdpClient sender = new UdpClient();
sender.Client.Bind( new IPEndPoint(IPAddress.Parse("0.0.0.0"), sourcePort) );
sender.Client.SendTo(new byte[] { 2 , 4 , 6 , 8 }, new IPEndPoint(IPAddress.Parse(destinationIP), destinationPort) );
sender.Close();
Console.WriteLine("Punched Hole...");

// Start a udp client to listen for incoming packets
Thread listenerThread = new Thread(() => {
    UdpClient listener = new UdpClient();
    listener.Client.Bind(new IPEndPoint(IPAddress.Parse("0.0.0.0"), sourcePort));
    byte[] buffer = new byte[1024];
    Console.WriteLine("Server Listening:");

    while(true) {
        int bufferSize = listener.Client.Receive(buffer);
        Console.WriteLine("Recieved Message:");

        string message = Encoding.UTF8.GetString(buffer, 0, bufferSize);
        Console.WriteLine(message);
        Console.WriteLine();
    }
});
listenerThread.Start();

// waiting a bit of time just in case
Thread.Sleep(5000);

// Start a different client that will be sending actual data between the clients, through the punched holes
UdpClient sender2 = new UdpClient();
sender2.Client.Bind(new IPEndPoint(IPAddress.Parse("0.0.0.0"), destinationPort));

byte[] sendData = Encoding.UTF8.GetBytes(inputMessage);

Console.WriteLine("Preparing to send data...");

for(int i = 0; i < 10; i++) {
    Thread.Sleep(1000);
    sender2.Client.SendTo(sendData, new IPEndPoint(IPAddress.Parse(destinationIP), sourcePort));
}

我觉得有一种更优雅的方法可以做到这一点,但这确实有效。我能够和朋友一起测试这段代码(因为我不想花钱在云服务器上做简单的测试)。

其他新人这样做的注意事项:

此代码必须在相对相同的时间 运行 两台计算机(您的和另一台计算机)上。虽然不是完全同时,但在几秒钟内。因为“打孔”的“保质期”很短。所以它们必须在时间上彼此接近。您可以通过 Discord 或其他聊天程序进行协调。

您也可以让程序每隔几秒发送一次“虚拟数据包”,直到建立连接。所以你不必为事情计时

关于这一点,即使在打孔成功之后,如果在很多秒内没有来回发送数据(这可能因路由器而异),那么打孔的“孔”将被“修补” ",您将不得不重新启动该过程。因此,您可能还需要每隔几秒发送一次虚拟数据包以保持连接有效。或者你可以聪明一点,如果在最后几秒内没有发送其他 'actual packets',则只发送一个虚拟数据包。


答案 #2(NAT 协议)(更好的方法)

“NAT”是当今大多数路由器(我想)都具备的功能。 NAT 负责确定将传入数据包发送到哪台 PC。但有时您的路由器需要来自其连接的 PC 的一些输入才能知道该做什么。

这个事实也适用于打孔,但理想情况下,您可能不希望发送“虚拟数据包”只是为了“打孔”,然后每 10 秒发送“keep-alive 数据包”以保持连接打开。很浪费网络资源。

理想情况下,您希望 PC 直接与路由器对话以设置可能持续几个小时的“临时端口转发”,并且您可以每小时“更新端口映射”一次向路由器发送消息;因此不会发送出站 WAN 数据包。

这就是“NAT 协议”允许您做的;直接从您的应用程序进行端口转发。

那么我如何在 C# 中执行此操作?

很高兴你提出这个问题!

https://en.wikipedia.org/wiki/Port_Control_Protocol#PCP_as_a_solution

这个维基百科页面在底部列出了很多不同编程语言的库。但对于 C#,我选择使用名为“Mono.Nat”的 NuGet 包,它支持“NAT-PMP”和“UPnP”。据我所知,“NAT-PMP”协议是首选协议,但两者都可能不错。

这是他们的 Github,其中也有指向他们的 NuGet 的链接。但是你可以只搜索“Mono.Nat”他 IDE(VS 或 Rider)的 NuGet 包管理器。

https://github.com/alanmcgovern/Mono.Nat

另外,这里是我用来测试 NAT-PMP 和实际执行软件 port-forward 的一些示例代码。此代码实质上向您的路由器发送一条消息(数据包)说:“嘿,你可以将带有 [THIS PORT] 的传入数据包转发给我吗?”.根据您的情况,它可能并不总是成功。而这段代码并没有完全正确地处理这些错误。但它会告诉你是否有某种错误。所以应该改进这段代码以供生产使用。

public static bool SetupPortForward() {

    int portToForward = 25000;

    bool portMapSet = false;
    bool fail = false;

    NatUtility.DeviceFound += async (object sender, DeviceEventArgs args) => {
        try {
            INatDevice device = args.Device;

            // Only interact with one device at a time. Some devices support both
            // upnp and nat-pmp.
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine("Device found: {0}", device.NatProtocol);
            Console.ResetColor();
            Console.WriteLine("Type: {0}", device.GetType().Name);

            Console.WriteLine("IP: {0}", await device.GetExternalIPAsync());

            Console.WriteLine("---");

            await device.CreatePortMapAsync(new Mapping(Protocol.Udp, portToForward, portToForward));
            //await device.DeletePortMapAsync(new Mono.Nat.Mapping(Mono.Nat.Protocol.Udp, 25000, 25000));

            //Mono.Nat.Mapping[] mappings = await device.GetAllMappingsAsync();

            //foreach(var mapping in mappings) {
            //  Console.WriteLine(mapping);
            //}
            portMapSet = true;

        } catch(Exception e) {
            Console.WriteLine(e.Message);
            fail = true;
        }
    };

    // Replace "192.168.0.1" with your default gateway, or use a function which finds your default gateway automatically
    string defaultGateway = "192.168.0.1";
    NatUtility.Search(IPAddress.Parse(defaultGateway), NatProtocol.Pmp);

    while(true) {
        if(portMapSet || fail) break;
        Thread.Sleep(1);
    }

    return !fail;
}

注意:此代码特别需要命名空间“Mono.Nat”和“System.Net”。因此,您还需要 NuGet 包“System.Net.Sockets”,以便您可以使用“IPAddress”class.

但是如果您使用不同的 C# 库进行网络连接(比如 SFML?),您也可以使用 NuGet 包“System.Net.Primitives”,它只提供 IP 地址 class.

我还应该注意,我的示例代码基于 Mono.Nat Github 存储库中的代码;在“Mono.Nat.Console/Main.cs”。所以里面可能有一些我错过的更有用的信息。

https://github.com/alanmcgovern/Mono.Nat/blob/master/Mono.Nat.Console/Main.cs

但是我在上面显示的示例代码应该 自动为端口 25000 设置端口转发,但您可以更改它。该代码还假设每个人的默认网关都是相同的“192.168.0.1”。但这对您来说可能有所不同,因此请务必在命令提示符下使用“ipconfig”进行检查。那个 ip 不是我的网关,我是从这个 youtube 视频中得到的。

https://www.youtube.com/watch?v=pCcJFdYNamc

最后,设置的port forward是TEMPORARY的,过一段时间就会EXPIRE。您可以在代码中更改这段时间,但默认(我认为)是 2 小时,这还不错。此外,您可以重新运行 这段代码来“更新”到期日期,并在程序的整个生命周期内保持有效。该程序不需要确保在退出时“关闭”端口转发。因此,如果程序崩溃,端口转发将“过期”并自行关闭。

但是我如何检查它是否真的有效???

#1 您可以在 Mono.Nat 库中查看。

注释掉的代码“device.GetAllMappingsAsync()”可以列出任何已创建的 PMP 或 UPnP 映射,但由于某些原因,它仅在使用“NatUtility.Search”函数时有效UPnP 选项。

#2 使用网络浏览器检查路由器的日志

或者,您可以查看路由器的 HTML 设置菜单是否有用于显示日志的部分,以及您是否可以查看所有现有的端口转发。

为此,您需要获取路由器的默认网关(使用 ipconfig)并将默认网关输入浏览器。它将带您到路由器生成的页面。对于每个路由器品牌和型号,此页面看起来会有所不同。

对于我的 ASUS 路由器,我点击了“系统日志”,然后点击了名为“端口转发”的选项卡,其中列出了有关现有端口转发的信息。它显示了手动制作的和用软件 (PMP) 制作的。请注意,它可能被称为“端口映射”。

您可以保持该页面打开,运行 我上面显示的示例代码 ^,如果它成功。您将在端口转发日志中看到一个新条目。如果屏幕上有刷新按钮,请务必刷新。它将显示您 PC 的私有 IP。这就是路由器如何知道将数据包转发到哪里。而且无论外部IP是什么它都会转发数据包。

#3 运行 外部 PC 上向您的 IP 发送数据包的程序

在没有任何现有端口转发的情况下,如果您从另一个 public IP 接收数据包,显然您不会收到它们。

因此,如果您有一个正在主动侦听 UDP 数据包的程序,但您使用的端口没有端口转发,则不会发生任何事情。 我知道,我听起来像破唱片!!!

但我的观点是,您可能测试它的最佳方法是首先验证您是否可以从外部 (public) IP 接收数据包;来自互联网上的另一台 PC。

如果您正在使用 System.Net.Sockets,这将涉及创建一个新的 UdpClient,将其绑定到您转发的端口(绑定 ip 将为 0.0.0.0),然后使用其中一个“ receive”函数来侦听传入的数据包。

棘手的部分是找到一台具有不同 public IP 的计算机,以及 运行 发送 UDP 数据包的程序。并查看您的 PC 是否收到数据包。如果成功了,恭喜,成功了!

对我来说,最简单的事情就是找一个愿意帮忙的朋友(也许是 Discord?)。您必须向他们发送一个程序,将 UDP 数据包发送到您的 PC。你可以让我们另一个现有的程序,如 Netcat 或 NCat(Windows 版本)来发送测试数据包。

https://sectools.org/tool/netcat/

https://nmap.org/ncat/

然后您可以 运行 您的程序(侦听数据包),但不要 运行 端口转发代码。然后,您的朋友将 运行 将数据包发送到您的电脑的程序。什么都不应该发生(显然)。

但是,您 re-run 您的程序,但在侦听数据包之前包含端口转发部分。当你的程序准备好后,告诉你的朋友 re-run 他们的程序,你应该会收到他们的数据包。太神奇了!!!

但是如果没有任何反应,那么端口转发可能没有工作。您可能需要进行一些调整或更改某些内容才能使其正常工作。

但最终,对于世界上的某些人来说。这可能根本不可能。有些人住在公寓、汽车旅馆或在酒店度假,使用酒店的 WiFi。因此,您可能不幸地被限制进行临时端口转发。对于可以正常“访问”家中路由器的人。这应该是可能的。但这种方法确实帮助更多人能够在他们的 PC 上托管 [游戏] 服务器,而无需直接访问他们的路由器设置。不幸的是,这绝对不会对每个人都有效。打孔将成为你们中一些人的后备方法。

最后,我想展示一些您可以用来测试端口转发的示例代码。

此代码用于服务器(数据包接收方,又名您)。

int sourcePort = 25000; // should be the same as your port forward

UdpClient udpServer = new UdpClient();
IPEndPoint localEndPoint = new IPEndPoint(IPAddress.Parse("0.0.0.0", sourcePort);
udpServer.Client.Bind(localEndPoint);

byte[] buffer = new byte[1024];

EndPoint incomingEndPoint = localEndPoint;

int bufferSize = udpServer.Client.ReceiveFrom(buffer, ref incomingEndPoint); // incomingEndPoint will contain the public ip of the sender

Console.WriteLine($"Incoming Packet From: {incomingEndPoint}");
string message = Encoding.UTF8.GetString(buffer, 0, bufferSize);
Console.WriteLine($"Message: {message}");

Console.ReadLine();

下面是将 运行 在您朋友的 PC(或另一台远程 PC)上的代码。

// You probably don't need to use this port for the sender
// The sender can use any port they want, and their router will use NAT hole punching to figure things out.
// But both the client and server could forward this port if they want. But it's not needed. Only the server needs to forward its ports.
int sourcePort = 25000; 

UdpClient udpClient = new UdpClient();
IPEndPoint localEndPoint = new IPEndPoint(IPAddress.Parse("0.0.0.0", sourcePort);
udpServer.Client.Bind(localEndPoint);

byte[] sendData = Encoding.UTF8.GetBytes("Hello! This is the UDP client on the other end! If you see this, it worked!!!");

Console.WriteLine("Please enter the Public IP you wish to send the packet to, and hit enter:");
string destinationIP = Console.ReadLine();

// Here, sourcePort is refering to the server's port. This does need to be the same as what's forwarded.
// So this port could be different than the port that was used in the "Bind" function, and "localEndPoint".
IPEndPoint destinationEndPoint = new IPEndPoint(IPAddress.Parse(destinationIP), sourcePort);

udpClient.Client.SendTo(sendData, destinationEndPoint); // send it off!

Console.WriteLine("sent!");
Console.ReadLine();

您可以 copy/paste 将这些行添加到您的编辑器中,但要做到只有一个或另一个 运行。取决于您是按“S”还是“R”。对于“发送”或“接收”。所以你可以只拥有 1 个程序而不是 2 个。你的朋友将必须输入你的 public IP。你可以通过谷歌搜索“我的 ip”或类似的东西找到你的 public IP。当你收到他们的数据包时,它也会打印他们的 ip。作为数据包来源的确认(它可以来自任何 ip)。这很正常,您的计算机还需要知道发件人的 ip,以便它可以根据需要发送 return 消息。

但仅此而已。

此示例代码应该让您对如何测试和确保您的“software-based 端口转发”实际工作有一个坚实的了解。它让您了解您可能需要设置什么才能使代码甚至首先工作。因为有时问题出在误解 API 而没有正确执行绑定,或者在错误的地方使用了错误的端口。所以这种方式保证有效。但是还有其他方法可以使用 UDPClient class.

此外,我还没有针对 TCP 测试过任何这些,但我怀疑(并希望)它会起作用。您只需将端口转发代码更改为 TCP。


结论:

我知道需要阅读的内容很多,但它详细说明了我在该主题中学到的所有内容。我想把它们都放在一个地方,并尽可能地组织起来。所以这可以作为其他 [游戏] 程序员的一个很好的资源,他们希望它成为他们应用程序中的一个选项。

我做了很多搜索,试图找到一个 article/post 来解释 需要的东西。他们中的许多人谈论的东西会引导你朝着正确的方向前进。但我觉得他们从来没有说够。

我想很多游戏开发者都希望在他们的游戏中有一个非常简单的多人游戏选项,不需要 pre-configuration,这样人们就可以和他们的朋友一起玩和享受他们的游戏。考虑到有多少游戏只是诉诸于使用“专用服务器”方法,我觉得这个信息不是超级 well-known 或共享那么多。这需要手动端口转发和直接访问路由器设置;不是很容易访问...但是抛开咆哮!

我认为这 post 至少会对使用 C# 的人有所帮助。如果您使用不同的语言进行编码,那么请采用我谈到的所有内容并找到 C++、C、Java、Python 等等效语言。因为它们确实存在。我谈到的概念是“语言不可知论”,这意味着它们可以适用于任何语言。您只需要一个允许您与这些“系统”交互的库,例如 NAT-PMP。 (您现在可以google“NAT PMP C++”,找到您所需要的)

最后,我还想用其他 options/methods 扩展此列表,这在某些情况下可能是首选。可能有比 NAT-PMP 更多的现代技术,但就目前而言,NAT-PMP 非常适合我。