11

I'm writing a toy meeting-point/relay server listening on port 5555 for two clients "A" and "B".

It works like this: every byte received by the server from the firstly-connected client A will be sent to the secondly-connected client B, even if A and B don't know their respective 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

This code is currently working:

# 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

and you can test it by launching it on a server, and do two netcat connections to it: nc <SERVER_IP> 5555.

How can I then pass the information to the clients A and B that they can talk directly to each other without making the bytes transit via the server?

There are 2 cases:

  • General case, i.e. even if A and B are not in the same local network

  • Particular case where these two clients are in the same local network (example: using the same home router), this will be displayed on the server when the 2 clients will connect to the server on port 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
    

Remark: a previous unsuccesful attempt here: UDP or TCP hole punching to connect two peers (each one behind a router) and UDP hole punching with a third party

Basj
  • 41,386
  • 99
  • 383
  • 673
  • Hosts on the same network communicate directly by the layer-2 LAN address. The frames do not pass through a router unless the packets are destined for a different network. See the answer to [this question](https://stackoverflow.com/q/42097214/3745413). – Ron Maupin Nov 26 '18 at 14:47
  • @RonMaupin As a simple example: two laptops connected via WiFi on the same home router require the router to pass the data to each other, right? *What information should my script (see the question) pass to each of them if it detects there are on the same public IP?* – Basj Nov 26 '18 at 21:48
  • 1
    No. A home router is really a Frankenstein box. What you are talking about is a router/firewall/switch/WAP, all in one box. The frames on Wi-Fi are bridged, and they never pass through the router in the box, only the WAP. Routers route layer-3 packets between different networks. Bridges (WAPs and switches are also bridges) will bridge layer-2 frames on the same network. – Ron Maupin Nov 26 '18 at 21:52
  • What kind of information can the server pass if it notices the same public IP is used by the two clients, in order to allow a direct connection between Client A and B? How should they then connect to each other? Without Client A having to know Client B's local IP and vice versa. The software [SyncThing](https://syncthing.net/) works like this: two clients can meet, and exchange data if they are far from each other. And if they are in the same local network, the connection is then made direct between them (only home router)! It works wonderfully, you never have to give IP, it auto detects it. – Basj Nov 26 '18 at 21:58
  • If you are configuring a server as a network infrastructure device, then you would configure a bridge to bridge frames on the same network, otherwise, you configure a router to route packets between different networks. – Ron Maupin Nov 26 '18 at 22:00

2 Answers2

6

Since the server knows the addresses of both clients, it can send that information to them and so they would know each others adress. There are many ways the server can send this data - pickled, json-encoded, raw bytes. I think the best option is to convert the address to bytes, because the client will know exactly how many bytes to read: 4 for the IP (integer) and 2 for the port (unsigned short). We can convert an address to bytes and back with the functions below.

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])

When the clients receive and decode the address, they no longer need the server, and they can establish a new connection between them.

Now we have two main otions, as far as I know.

  • One client acts as a server. This client would close the connection to the server and would start listening on the same port. The problem with this method is that it will only work if both clients are on the same local network, or if that port is open for incoming connections.

  • Hole punching. Both clients start sending and accepting data from each other simultaneously. The clients must accept data on the same address they used to connect to the rendezvous server, which is knwn to each other. That would punch a hole in the client's nat and the clients would be able to communicate directly even if they are on different networks. This proccess is expleined in detail in this article Peer-to-Peer Communication Across Network Address Translators, section 3.4 Peers Behind Different NATs.

A Python example for UDP Hole Punching:

Server:

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)

Client:

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)

This code requires for the rendezvous server to have a port open (4000 in this case), and be accessible by both clients. The clients can be on the same or on different local networks. The code was tested on Windows and it works well, either with a local or a public IP.

I have experimented with TCP hole punching but I had limited success (sometimes it seems that it works, sometimes it doesn't). I can include the code if someone wants to experiment. The concept is more or less the same, both clients start sending and receiving simultaneously, and it is described in detail in Peer-to-Peer Communication Across Network Address Translators, section 4, TCP Hole Punching.


If both clients are on the same network, it will be much easier to communicate with each other. They would have to choose somehow which one will be a server, then they can create a normal server-client connection. The only problem here is that the clients must detect if they are on the same network. Again, the server can help with this problem, as it knows the public address of both clients. For example:

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 ...

Here the server sends two addresses to each client, the peer's public address and the client's own public address. The clients compare the two IPs, if they match then they must be on the same local network.

t.m.adam
  • 15,106
  • 3
  • 32
  • 52
  • Thanks a lot for your detailed answer, I'll study this precisely. A question about your code "A Python example for UDP Hole Punching". Could you add a precision about how we should run this same code on Client A, Client B, Server? (should I use an argument to specify it it's a server or client?). Also shouldn't it include a socket.close() and a re-open: once the two clients have "met" thanks to the rendezvous server, shouldn't they close the connection with the server (no longer needed), and THEN re-open a socket between Client A and Client B directly? How is this done in your solution? Thanks – Basj Nov 30 '18 at 13:32
  • No, you shouldn't close the socket and create a new one. A new port would be assigned to the new soket and the clients will not be able to communicate. UDP clients can connect to multiple servers. I've tried to clean the code a little; the server code should run on Server, and the client code on Client A and Client B. The server requires exactly two clients. Of course this is a very basic example, a POC basically. – t.m.adam Nov 30 '18 at 14:27
  • Thanks a lot for your answer! It makes sense! I tried hole punching from a Wifi hotspot, it didn't work, but I'll retry at home with 2 different connections (4G vs Wifi), and it'll probably work. I didn't know that a socket *initially opened by Client A to reach the server's IP*, thus opening a port (say, 43210) on the NAT router, would also accept bytes in return *from another IP*, such as Client B. Is that how it works, or did I misunderstand something? – Basj Nov 30 '18 at 15:14
  • Yes, that's the idea. The client connects to the server and gets the peer's address. Then it starts sending and accepting data from that address simultaneously, while the other client does the same. Alternatively, you could close the connection to the server, create a new socket, and bind it to the same port the original socket used to connect to the server (we can get that with `.getsockname()`), so the port remains the same and the peer can connect. That's what I tried to do with TCP sockets, but as I said, I had very little success.Perhaps the clients should attempt multiple connections. – t.m.adam Nov 30 '18 at 15:47
  • Ok thanks. Let's say that when I (client A) reach the server 123.123.123.123 port 4000, my NAT translates to port 22000. Then the server will see my IP + 22000, and will pass this info to you (Client B). Then you will try to contact me at my IP + 22000. But my router will accept your packet only *if I have sent bytes to you too*. Which I have done too. But when I switch from sending bytes to server (using port 22000) to sending bytes *to you*, will it still use 22000? If not, you won't be able to reach me on 22000. – Basj Nov 30 '18 at 15:52
  • Until you close the socket, the port assigned to it should remain the same. So, the client should have the same IP and port it used for connecting to the server. – t.m.adam Nov 30 '18 at 16:19
1

The accepted answer gives the solution. Here is some additional information in the case "Client A and Client B are in the same local network". This situation can indeed be detected by the server if it notices that both clients have the same public IP.

Then the server can choose Client A as "local server", and Client B as "local client".

The server will then ask Client A for its "local network IP". Client A can find it with:

import socket
localip = socket.gethostbyname(socket.gethostname())  # example: 192.168.1.21

and then send it back to the server. The server will communicate this "local network IP" to Client B.

Then Client A will then run a "local server":

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)

and Client B will run as a "local client":

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)
Basj
  • 41,386
  • 99
  • 383
  • 673