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