Search code examples
node.jstcphole-punching

How to establish TCP hole punching with node.js?


I have been trying to establish TCP hole punching with Node.js, and I am not sure if it fails because of my NAT or because the code is erroneous.

The following code intends to:

  • let 2 clients register on a server the 4-tuple (address on client, port on client, address as seen by server, port as seen by server)
  • let the server signal when the 2 clients are mutually ready by sending them each other's 4-tuple (tryConnectToPeer)
  • let each client start a local server (listen) on the local address and port used when communicating with the server (address on client, port on client)
  • when the local server is running, try to establish a connection (connect) with the local port & address of the other client, as well as an external connection with the port & address of the other client, as the server was seeing them (probably the other client's router address and port then)

Client code - I would imagine the mistake is here:

import { createConnection, createServer } from 'net';

const serverPort = 9999;
const serverHost = '192.168.1.19'
const socket = createConnection(serverPort, serverHost);

socket.setEncoding('utf8');
socket.on('data', (data: string) => {
    console.log('data', data);
    let parsedData: any = null;
    try {
        parsedData = JSON.parse(data);
    } catch (e) {
        if (e instanceof Error) {
            console.log(e.message);
        } else {
            throw e;
        }
    }

    if (parsedData?.command === 'tryConnectToPeer') {
        console.log('Will try to connect with peer:', parsedData);
        const server = createServer(c => {
            console.log('client connected');
            c.setEncoding('utf8');
            c.on('data', (data: string) => {
                console.log('received:', data);
                c.write('hi!');
            });
        });

        server.listen(socket.localPort, socket.localAddress, () => {
            console.log('server bound to ', socket.localAddress, socket.localPort);
        });

        server.on('listening', () => {
            console.log('Attempting local connection', parsedData.localAddress,  parsedData.localPort);
            const localSocket = createConnection({ port: parsedData.localPort, host: parsedData.localAddress });
            localSocket.on('error', (e) => {
                console.error('Failed to connect with peer locally');
                console.error(e);
            });

            localSocket.setEncoding('utf8');
            localSocket.on('data', (data: string) => {
                console.log(data);
                localSocket.write('ho! on local')
            })


            console.log('Attempting external connection', parsedData.externalAddress, parsedData.externalPort);
            const externalSocket = createConnection({ port: parsedData.externalPort, host: parsedData.externalAddress});
            externalSocket.on('error', (e) => {
                console.error('Failed to connect with peer externally');
                console.error(e);
            });
            externalSocket.setEncoding('utf8');
            externalSocket.on('data', (data: string) => {
                console.log(data);
                externalSocket.write('ho! on external')
            })

            localSocket.on('connect', () => {
                externalSocket.end();
                localSocket.write('start from localsocket');
                console.log('connected to peer locally!');
            })

            externalSocket.on('connect', () => {
                // localSocket.end();
                externalSocket.write('start from externalSocket');
                console.log('connected to peer externally!');
            })
        })
    }
});

socket.on('connect', () => {
  socket.write(JSON.stringify(
    {
        command: 'register',
        localPort: socket.localPort,
        localAddress: socket.localAddress
    }
  ));
});

Server code - a tad long, but probably not the problematic piece:

import { createServer, Socket } from 'net';

type AddressAndPort = {
  address: string | undefined,
  port: number | undefined
}

class ConnectionDescriptor {
  socket: Socket;
  addressAndPortOnClient: AddressAndPort;
  addressAndPortSeenByServer: AddressAndPort;

  constructor({ socket, addressAndPortOnClient, addressAndPortSeenByServer } : {socket: Socket, addressAndPortOnClient: AddressAndPort, addressAndPortSeenByServer: AddressAndPort}) {
    this.socket = socket;
    this.addressAndPortOnClient = addressAndPortOnClient;
    this.addressAndPortSeenByServer = addressAndPortSeenByServer;
  }

  toString() {
    return JSON.stringify({
      addressAndPortOnClient: this.addressAndPortOnClient,
      addressAndPortSeenByServer: this.addressAndPortSeenByServer
    });
  }
}

class ConnectionDescriptorSet {
  connectionDescriptors: ConnectionDescriptor[]

  constructor() {
    this.connectionDescriptors = [];
  }

  get full() {
    return this.connectionDescriptors.length === 2;
  }

  add(descriptor: ConnectionDescriptor) {
    if (!descriptor.addressAndPortOnClient.address || !descriptor.addressAndPortOnClient.port || !descriptor.addressAndPortSeenByServer.address || !descriptor.addressAndPortSeenByServer.port) {
      throw new Error(`Cannot register incomplete connection descriptor: ${JSON.stringify(descriptor)}`);
    }

    const index = this.connectionDescriptors.findIndex(c => c.addressAndPortSeenByServer.address === descriptor.addressAndPortSeenByServer.address && c.addressAndPortSeenByServer.port === descriptor.addressAndPortSeenByServer.port);
    if (index === -1) {
      console.log('Registering new client:');
      console.log(descriptor.toString());
      if (this.connectionDescriptors.length === 2) {
        throw new Error('Only two clients can be registered at a time!');
      }
      this.connectionDescriptors.push(descriptor)
    } else {
      console.log('Client already registered:');
      console.log(descriptor.toString());
    }
  }

  remove(addressAndPortSeenByServer: AddressAndPort) {
    const index = this.connectionDescriptors.findIndex(c => c.addressAndPortSeenByServer.address === addressAndPortSeenByServer.address && c.addressAndPortSeenByServer.port === addressAndPortSeenByServer.port);
    if (index === -1) {
      console.log('Client with following connectionDescriptors was not found for removal:');
      console.log(JSON.stringify(addressAndPortSeenByServer));
    } else if (index === 0) {
      console.log('Removing client:');
      console.log(this.connectionDescriptors[0].toString());
      this.connectionDescriptors.shift();
    } else if (index === 1) {
      console.log('Removing client:');
      console.log(this.connectionDescriptors[1].toString());
      this.connectionDescriptors.pop();
    } else {
      throw new Error('No more than 2 clients should have been registered.');
    }
  }
}

const connectionDescriptorSet = new ConnectionDescriptorSet();

const server = createServer((c) => {
  console.log('client connected');

  // Optional - useful when logging data
  c.setEncoding('utf8');

  c.on('end', () => {
    connectionDescriptorSet.remove({ address: c.remoteAddress, port: c.remotePort })
    console.log('client disconnected');
  });

  c.on('data', (data: string) => {
    console.log('I received:', data);

    try {
      const parsedData = JSON.parse(data);
      if (parsedData.command === 'register') {
        connectionDescriptorSet.add(new ConnectionDescriptor({
          socket: c,
          addressAndPortOnClient: {
            address: parsedData.localAddress,
            port: parsedData.localPort
          },
          addressAndPortSeenByServer: {
            address: c.remoteAddress,
            port: c.remotePort
          }
        }));

        if (connectionDescriptorSet.full) {
          console.log('connectionDescriptorSet full, broadcasting tryConnectToPeer command');

          connectionDescriptorSet.connectionDescriptors[0].socket.write(
            JSON.stringify({
              command: 'tryConnectToPeer',
              localPort: connectionDescriptorSet.connectionDescriptors[1].addressAndPortOnClient.port,
              localAddress: connectionDescriptorSet.connectionDescriptors[1].addressAndPortOnClient.address,
              externalAddress: connectionDescriptorSet.connectionDescriptors[1].addressAndPortSeenByServer.address,
              externalPort: connectionDescriptorSet.connectionDescriptors[1].addressAndPortSeenByServer.port,
            })
          );

          connectionDescriptorSet.connectionDescriptors[1].socket.write(
            JSON.stringify({
              command: 'tryConnectToPeer',
              localPort: connectionDescriptorSet.connectionDescriptors[0].addressAndPortOnClient.port,
              localAddress: connectionDescriptorSet.connectionDescriptors[0].addressAndPortOnClient.address,
              externalAddress: connectionDescriptorSet.connectionDescriptors[0].addressAndPortSeenByServer.address,
              externalPort: connectionDescriptorSet.connectionDescriptors[0].addressAndPortSeenByServer.port,
            })
          );
        }
      }
    } catch (e) {
      if (e instanceof Error) {
        console.error(e);
        c.write(e.message);
      } else {
        throw e;
      }
    }
  })
});
server.on('error', (err) => {
  throw err;
});
server.listen(9999, () => {
  console.log('server bound');
});

This code works when the two clients are on the same local network, but fails when they are on different networks.


Solution

  • There is in fact no need to run a server on each client once they got each other's credentials. Having a server may increase the chances of EADDRINUSE - unsure.

    A simple connect to each other suffices. First one to try will fail, second may succeed.

    It's important to specify the origination port of the socket making the call to the other peer (both for the private and public calls). I believe this was the issue in the above code.

    Network topology is also tricky. It's best to have the server and each client each behind a different NAT.

    Working proof of concept can be found here:

    https://github.com/qbalin/tcp_hole_punching_node_js/tree/main