如何在两个客户端连接到会议点服务器后,使它们直接相互连接?

12

我正在编写一个玩具会议点/中继服务器,监听端口5555,为两个客户端"A"和"B"提供服务。

它的工作原理是:服务器从首先连接的客户端A接收到的每个字节都将被发送到第二个连接的客户端B,即使A和B不知道彼此的IP地址:

A -----------> server <----------- B     # they both connect the server first
A --"hello"--> server                    # A sends a message to server
               server --"hello"--> B     # the server sends the message to B

这段代码目前可以正常工作:

# server.py
import socket, time
from threading import Thread
socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
socket.bind(('', 5555))
socket.listen(5)
buf = ''
i = 0

def handler(client, i):
    global buf
    print 'Hello!', client, i 
    if i == 0:  # client A, who sends data to server
        while True:
            req = client.recv(1000)
            buf = str(req).strip()  # removes end of line 
            print 'Received from Client A: %s' % buf
    elif i == 1:  # client B, who receives data sent to server by client A
        while True:
            if buf != '':
                client.send(buf)
                buf = ''
            time.sleep(0.1)

while True:  # very simple concurrency: accept new clients and create a Thread for each one
    client, address = socket.accept()
    print "{} connected".format(address)
    Thread(target=handler, args=(client, i)).start()
    i += 1

您可以通过在服务器上启动它并对其进行两个netcat连接来测试它:nc <SERVER_IP> 5555

那么我该如何将信息传递给客户端A和B,使它们能够直接交流而不必经过服务器?

有两种情况:

  • 一般情况,即使A和B不在同一个本地网络中

  • 特定情况下,这两个客户端在同一个本地网络中(例如:使用相同的家用路由器),当这两个客户端通过端口5555连接到服务器时,服务器将显示这一点:

('203.0.113.0', 50340) connected  # client A, router translated port to 50340
('203.0.113.0', 52750) connected  # same public IP, client B, router translated port to 52750

注:此前的一次不成功尝试在此:UDP或TCP打洞以连接两个分别位于路由器后面的对等方使用第三方进行UDP打洞


同一网络上的主机直接通过第二层LAN地址进行通信。除非数据包的目标网络不同,否则帧不会经过路由器。请参阅 此问题 的答案。 - Ron Maupin
作为一个简单的例子:通过WiFi连接到同一个家庭路由器的两台笔记本电脑需要路由器将数据传递给彼此,对吗?如果我的脚本(参见问题)检测到它们在同一个公共IP下,那么应该向它们中的每一个传递哪些信息? - Basj
1
不,家用路由器实际上是一个弗兰肯斯坦盒子。你所说的是一个路由器/防火墙/交换机/WAP,都在同一个盒子里。Wi-Fi上的帧被桥接,它们从未通过盒子中的路由器,只通过WAP。路由器在不同网络之间路由第3层数据包。桥接器(WAP和交换机也是桥接器)将同一网络上的第2层帧桥接起来。 - Ron Maupin
如果服务器注意到两个客户端使用相同的公共IP,则可以传递哪些信息以允许Client A和B之间的直接连接?他们应该如何互相连接,而无需Client A知道Client B的本地IP,反之亦然。软件SyncThing的工作原理是:如果两个客户端彼此相距很远,则它们可以相遇并交换数据。如果他们处于同一本地网络中,则连接将在它们之间直接建立(只有家庭路由器)!它运行得非常出色,您永远不必提供IP,它会自动检测。 - Basj
如果您将服务器配置为网络基础设施设备,则应配置桥接器以在同一网络上进行桥接帧,否则,您应配置路由器以在不同网络之间路由数据包。 - Ron Maupin
2个回答

6
由于服务器知道两个客户端的地址,因此它可以将该信息发送给它们,这样它们就会知道对方的地址。服务器可以以许多方式发送此数据 - 选取、JSON编码、原始字节。我认为最好的选择是将地址转换为字节,因为客户端将确切地知道要读取多少字节:4个IP(整数)和2个端口(无符号短整型)。我们可以使用以下函数将地址转换为字节并进行还原。
import socket
import struct

def addr_to_bytes(addr):
    return socket.inet_aton(addr[0]) + struct.pack('H', addr[1])

def bytes_to_addr(addr):
    return (socket.inet_ntoa(addr[:4]), struct.unpack('H', addr[4:])[0])

当客户端接收并解码地址后,它们就不再需要服务器,并且可以在它们之间建立新连接。
现在我们有两个主要的选择,据我所知。
一、一个客户端充当服务器。这个客户端将关闭与服务器的连接,并开始在同一端口上侦听。这种方法的问题是只有在两个客户端位于同一本地网络上或该端口对于传入连接打开时才能起作用。
二、穿孔技术。两个客户端同时开始发送和接受数据。客户端必须在连接到约会服务器时使用的相同地址上接受数据,每个客户端都知道这个地址。这将在客户端的nat中打洞,即使它们在不同的网络中,客户端也能够直接通信。这个过程在本文第3.4节对等跨网络地址翻译器进行通信中详细说明。
Python中的UDP穿透示例:
服务器:
import socket

def udp_server(addr):
    soc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    soc.bind(addr)

    _, client_a = soc.recvfrom(0)
    _, client_b = soc.recvfrom(0)
    soc.sendto(addr_to_bytes(client_b), client_a)
    soc.sendto(addr_to_bytes(client_a), client_b)

addr = ('0.0.0.0', 4000)
udp_server(addr)

客户端:

import socket
from threading import Thread

def udp_client(server):
    soc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    soc.sendto(b'', server)
    data, _ = soc.recvfrom(6)
    peer = bytes_to_addr(data)
    print('peer:', *peer)

    Thread(target=soc.sendto, args=(b'hello', peer)).start()
    data, addr = soc.recvfrom(1024)
    print('{}:{} says {}'.format(*addr, data))

server_addr = ('server_ip', 4000) # the server's  public address
udp_client(server_addr)

这段代码要求约会服务器开放一个端口(在本例中为4000),并且两个客户端都可以访问。客户端可以在同一局域网或不同的局域网上。该代码已在Windows上进行了测试,并且无论是使用本地IP还是公共IP,都可以很好地工作。
我尝试过TCP打洞,但是成功有限(有时它似乎有效,有时则不然)。如果有人想实验,我可以包含代码。该概念基本相同,两个客户端同时开始发送和接收,并且在穿越网络地址转换器的点对点通信第4节中详细描述了TCP打洞。
如果两个客户端在同一网络中,它们之间的通信将变得更加容易。他们必须选择其中一个作为服务器,然后可以创建正常的服务器-客户端连接。唯一的问题是客户端必须检测它们是否在同一网络中。同样,服务器可以帮助解决这个问题,因为它知道两个客户端的公共地址。例如:
def tcp_server(addr):
    soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    soc.bind(addr)
    soc.listen()

    client_a, addr_a = soc.accept()
    client_b, addr_b = soc.accept()
    client_a.send(addr_to_bytes(addr_b) + addr_to_bytes(addr_a))
    client_b.send(addr_to_bytes(addr_a) + addr_to_bytes(addr_b))

def tcp_client(server):
    soc = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    soc.connect(server)

    data = soc.recv(12)
    peer_addr = bytes_to_addr(data[:6])
    my_addr = bytes_to_addr(data[6:])

    if my_addr[0] == peer_addr[0]:
        local_addr = (soc.getsockname()[0], peer_addr[1])
        ... connect to local address ...

在这里,服务器向每个客户端发送两个地址,对等方的公共地址和客户端自己的公共地址。客户端比较这两个IP地址,如果它们匹配,则它们必须在同一本地网络中。


非常感谢您详细的回答,我会认真研究。关于您的代码“UDP打洞的Python示例”,我有一个问题。您能否说明一下我们应该如何在客户端A、客户端B和服务器上运行相同的代码(我是否需要使用参数来指定它是服务器还是客户端?)。此外,它不应该包括socket.close()和重新打开:一旦两个客户端通过约会服务器“相遇”,他们不应该关闭与服务器的连接(不再需要),然后直接在客户端A和客户端B之间重新打开套接字吗?您的解决方案是如何实现的?谢谢。 - Basj
不,你不应该关闭套接字并创建一个新的。新的套接字将被分配一个新的端口,客户端将无法通信。UDP客户端可以连接到多个服务器。我尝试清理了一下代码;服务器代码应该在服务器上运行,客户端代码应该在客户端A和客户端B上运行。服务器需要确切地两个客户端。当然,这只是一个非常基本的例子,基本上是一个POC。 - t.m.adam
非常感谢您的回答!这很有道理!我尝试从Wifi热点进行空洞打孔,但它没有起作用,但我会在家里使用2个不同的连接(4G vs Wifi)重试,这可能会起作用。我不知道一个由客户端A最初打开以到达服务器IP的套接字,从而在NAT路由器上打开一个端口(例如43210),是否也会接受来自其他IP(如客户端B)的字节返回。是这样工作的,还是我误解了什么? - Basj
是的,这就是想法。客户端连接到服务器并获取对等方的地址。然后它开始同时从该地址发送和接受数据,而另一个客户端也在做同样的事情。或者,您可以关闭与服务器的连接,创建一个新套接字,并将其绑定到原始套接字用于连接到服务器的相同端口(我们可以使用.getsockname()获得该端口),以便端口保持不变,对等方可以连接。这就是我尝试使用TCP套接字做的事情,但正如我所说,我几乎没有成功。也许客户端应该尝试多个连接。 - t.m.adam
好的,假设当我(客户端A)到达服务器123.123.123.123端口4000时,我的NAT会转换为端口22000。然后服务器将看到我的IP + 22000,并将此信息传递给您(客户端B)。然后您将尝试使用我的IP + 22000与我联系。但是,只有当我也向您发送字节时,我的路由器才会接受您的数据包。我也已经这样做了。但是,当我从向服务器发送字节(使用端口22000)切换到向您发送字节时,它是否仍将使用22000?如果不是,您将无法在22000上联系到我。 - Basj
直到你关闭套接字,分配给它的端口应该保持不变。因此,客户端应该具有连接到服务器时使用的相同IP和端口。 - t.m.adam

1
已有一份被接受的答案,以下是一些额外信息,假设“客户端A和客户端B在同一个本地网络中”。如果服务器注意到两个客户端具有相同的公共IP,则可以检测到这种情况。然后服务器可以选择将客户端A作为“本地服务器”,将客户端B作为“本地客户端”。
然后服务器会要求客户端A提供其“本地网络IP”。客户端A可以通过以下方法找到它
import socket
localip = socket.gethostbyname(socket.gethostname())  # example: 192.168.1.21

然后将其发送回服务器。服务器将这个“本地网络IP”通知客户端B。
然后,客户端A会运行一个“本地服务器”:
import socket
soc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
soc.bind(('0.0.0.0', 4000))
data, client = soc.recvfrom(1024)
print("Connected client:", client)
print("Received message:", data)
soc.sendto(b"I am the server", client)

而客户端B将作为“本地客户端”运行:

import socket
soc = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
server = ('192.168.1.21', 4000)   # this "local network IP" has been sent Client A => server => Client B
soc.sendto("I am the client", server)
data, client = soc.recvfrom(1024)
print("Received message:", data)

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接