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.
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
Related
What I'm trying to do is to emit a event based on the progress of my jobs. It's working in the Gateway(proxy) side, the logs appear, etc, but when I'm consuming the event on the front-end, sometimes it works, sometimes not, and it throws 'ping timeout' error in the console. If I restart the nodejs service a few times it works.
I'm open to ideas of alternative ways to implement this feature.
SocketController
export default class SocketController {
socket: any;
interval: any;
instance: any;
queue: any;
constructor(server) {
// Creating Websocket connection
this.instance = new Server(server, {
cors: { origin: process.env.FRONTEND_URL },
path: "/socket.io/"
});
this.socket = null;
this.queue = null;
this.instance.on("connection", (socket) => {
let connectedUsersCount =
Object.keys(this.instance.sockets.sockets).length + 1;
let oneUserLeft = connectedUsersCount - 1;
console.log("New client connected ", connectedUsersCount);
// Assign socket to the class
this.socket = this.socket == null ? socket : this.socket;
/*
if (this.interval) {
clearInterval(this.interval);
}
*/
// initialize Queue
this.queue = this.queue === null ? new QueueService(socket) : this.queue;
socket.on("disconnect", () => {
console.log("Client disconnected ", oneUserLeft);
// clearInterval(this.interval);
});
});
}
QueueService
export default class QueueService {
channels: any;
socket: any;
constructor(socket: any) {
this.channels = ["integrationProgress", "news"];
this.socket = socket;
integrationQueueEvents.on("progress", (job: any) => {
console.log("Job Progressing", job);
this.socket.emit("integrationProgress", { status: true, data: job.data })
});
integrationQueueEvents.on("active", ({ jobId }) => {
console.log(`Job ${jobId} is now active`);
});
integrationQueueEvents.on("completed", ({ jobId, returnvalue }) => {
console.log(`${jobId} has completed and returned ${returnvalue}`);
this.socket.emit("integrationComplete", {
status: true,
message: returnvalue
});
});
integrationQueueEvents.on("failed", ({ jobId, failedReason }) => {
console.log(`${jobId} has failed with reason ${failedReason}`);
this.socket.emit("integrationProgress", {
status: false,
message: failedReason
});
});
}
}
Front-End
const socket = io(process.env.GATEWAY_URL, {
path: "/socket.io/"
});
socket.on("connect_error", (err) => {
console.log(`connect_error due to ${err.message}`);
socket.connect();
});
socket.on("disconnect", (socket) => {
console.log(socket);
console.log("Client disconnected ");
});
socket.on("connect", (socket) => {
console.log("Client Connected ");
console.log(socket);
});
socket.on("integrationProgress", async (socket) => {
try {
console.log(`Progress: ${socket.data}`);
updateJob(socket.data);
} catch (err) {
console.log(err);
}
});
I'm using socket io in two places in the app:
emiting offers on the main page that everyone can see
emiting chat messages only between two users based on order_id
I was able to set up first use case but not the second. When creating a new message, response status is 500 after hitting the socket part in the controller.
index.js
const serverIO = server.listen(
port,
console.log(`Listening on Port ${port}`)
);
const io = require("./socket").init(serverIO);
io.on("connection", (socket) => {
socket.join("some room");
console.log("cient connected");
});
socket.js
let io;
module.exports = {
init: (httpServer) => {
io = require("socket.io")(httpServer);
return io;
},
getIO: (socket) => {
if (!io) {
throw new Error("Socket.io not initialized!");
}
console.log("socket", socket());
return io;
},
};
chatController.js
const io = require("../socket");
const chatModel = require("./chatModel.js");
exports.createChat = async (req, res) => {
try {
const savedMessage = await chatModel.saveMessage(req.body);
if (!savedMessage) {
return res.status(400).json({
errorMessage: "Something went wrong with your chat request",
});
}
io.getIO().socket.to(req.body.order_id).emit("newMessage", { action: "create", message: savedMessage });
return res.status(200).json(savedMessage);
} catch (error) {
return res.status(500).json({
errorMessage: error,
});
}
};
on the client, I'm listening like this:
Chat.js
useEffect(() => {
const socket = openSocket(baseURL);
socket.on("newMessage", ({ room, data }) => {
console.log("room", room); //not being reached
if (data.action === "create") {
dispatch(addMessage(...data.message));
}
});
}, []);
I tried adding the boilerplate code from documentation but that didn't seem to work.
io.on('connection', socket => {
socket.join('some room');
});
How can I join rooms based on orderId and listen to said room on the client?
Was able to reach a working solution (chat messages are being broadcast only to the intended recipients)but don't know if it's optimal or efficient.
added socket.join in my index.js file
io.on("connection", (socket) => {
socket.on("joinRoom", (room) => {
console.log("joined room");
socket.join(room);
});
console.log("cient connected");
});
modified my controller
io.getIO().to(req.body.order_id).emit("newMessage", {
action: "create",
message: savedMessage,
});
And on the front end, on mount, I'm joining a room and listening for newMessage from server.
useEffect(() => {
const socket = openSocket(baseURL);
socket.emit("joinRoom", orderId);
socket.on("newMessage", (data) => {
console.log("data", data);
if (data.action === "create") {
dispatch(addMessage(...data.message));
}
});
}, []);
My node daemon connect to the same TLS server multiple times. I want to save the TLS connection time.
// repeat every 5 seconds
setInterval(() => {
const socket = tls.connect(443, 'test.com')
socket.on('connect', () => {
socket.write(someUniqueData)
})
}, 5000)
Is it possible to reuse the last TLS connection?
You can save the socket easily enough, here's an example:
const tls = require('tls');
const someUniqueData = 'someUniqueData';
var savedSocket = null;
function getSocket() {
return new Promise((resolve, reject) => {
if (savedSocket) {
console.log('getSocket: Reusing saved socket..');
resolve(savedSocket);
return;
}
console.log('getSocket: Creating new socket..');
const socket = tls.connect(443, 'test.com');
socket.on('connect', () => {
console.log('Connected to host..');
savedSocket = socket;
resolve(savedSocket);
});
socket.on('error', (err) => {
console.log('Error connecting to host..');
reject(err);
});
});
}
async function connectAndSend() {
try {
let socket = await getSocket();
console.log('connectAndSend: Sending data..');
socket.write(someUniqueData)
} catch (err) {
console.error('connectAndSend: Error occurred: ', err);
}
}
// repeat every 5 seconds
setInterval(() => {
connectAndSend();
}, 5000)
After some research on staskoverflow, Google and official Node SSH2 repository, I still can not create a Node SSH2 server which work with remote port forwarding...
Actually I can do what I want with standard SSH daemon on distant server and this command on client side :
ssh -R 8100:localsite.tld:80 sub.distantserver.com
All traffic from sub.distantserver.com:8100 is redirect to localsite.tld:80. (port 22, traditional SSH daemon).
My only goal is to achieve this with a Node SSH2 Server on port 21 :
ssh -R 8100:localsite.tld:80 foo#sub.distantserver.com -p 21
password: bar
When it'll work, I can do some check on the user and start some other process ;)
Basing on official Github issues example I try something like this, just like a POC to catch stream but it fail on forwardIn that does not exists.
var fs = require('fs');
var crypto = require('crypto');
var inspect = require('util').inspect;
var buffersEqual = require('buffer-equal-constant-time');
var ssh2 = require('ssh2');
var utils = ssh2.utils;
new ssh2.Server({
hostKeys: [fs.readFileSync('/etc/ssh/ssh_host_rsa_key')]
}, function(client) {
console.log('Client connected!');
client.on('authentication', function(ctx) {
if (ctx.method === 'password'
&& ctx.username === 'foo'
&& ctx.password === 'bar')
ctx.accept();
else
ctx.reject();
}).on('ready', function() {
console.log('Client authenticated!');
client.on('session', function(accept, reject) {
var session = accept();
session.once('exec', function(accept, reject, info) {
console.log('Client wants to execute: ' + inspect(info.command));
var stream = accept();
stream.stderr.write('Oh no, the dreaded errors!\n');
stream.write('Just kidding about the errors!\n');
stream.exit(0);
stream.end();
});
});
client.on('request', function(accept, reject, name, info) {
console.log(info);
if (name === 'tcpip-forward') {
accept();
setTimeout(function() {
console.log('Sending incoming tcpip forward');
client.forwardIn(info.bindAddr,
info.bindPort,
function(err, stream) {
if (err)
return;
stream.end('hello world\n');
});
}, 1000);
} else {
reject();
}
});
});
}).listen(21, '0.0.0.0', function() {
console.log('Listening on port ' + this.address().port);
});
Does anybody know how to achieve a simple conventional SSH forward server side ?
Thanks !
Found a solution with author's help :
Official Github solution on issue
let fs = require('fs'),
inspect = require('util').inspect,
ssh2 = require('ssh2'),
net = require('net');
new ssh2.Server({
hostKeys: [fs.readFileSync('/etc/ssh/ssh_host_rsa_key')]
}, client => {
console.log('Client connected!');
client
.on('authentication', ctx => {
if (
ctx.method === 'password'
&& ctx.username === 'foo'
&& ctx.password === 'bar'
) {
ctx.accept();
} else {
ctx.reject();
}
})
.on('ready', () => {
console.log('Client authenticated!');
client
.on('session', (accept, reject) => {
let session = accept();
session.on('shell', function(accept, reject) {
let stream = accept();
});
})
.on('request', (accept, reject, name, info) => {
if (name === 'tcpip-forward') {
accept();
net.createServer(function(socket) {
socket.setEncoding('utf8');
client.forwardOut(
info.bindAddr, info.bindPort,
socket.remoteAddress, socket.remotePort,
(err, upstream) => {
if (err) {
socket.end();
return console.error('not working: ' + err);
}
upstream.pipe(socket).pipe(upstream);
});
}).listen(info.bindPort);
} else {
reject();
}
});
});
}).listen(21, '0.0.0.0', function() {
console.log('Listening on port ' + server.address().port);
});
I have a client SSH sever written in Java using JSCH lib which is forwarding Port from client to ssh server like ThisJSCH client , Now I want a ssh server which will accept the Port forwarded from client in NODEJS!(I have read documentation on SSH2 and SSH modules but there is nothing regarding server which accepts the port), I am able to create a server(using ssh2 module Nodejs) and client also connecting but not accepting the forwarded Port.Below is the Code for server.
var webSocketPort=20;
var fs = require('fs'),
crypto = require('crypto'),
inspect = require('util').inspect;
var buffersEqual = require('buffer-equal-constant-time'),
ssh2 = require('ssh2'),
utils = ssh2.utils;
var pubKey = utils.genPublicKey(utils.parseKey(fs.readFileSync('C:\\Program Files\\OpenSSH\\etc\\ssh_host_rsa_key.pub')));
new ssh2.Server({
hostKeys: [fs.readFileSync('C:\\Program Files\\OpenSSH\\etc\\ssh_host_rsa_key')]
}, function(client) {
console.log('Client connected!',client);
client.on('authentication', function(ctx) {
if (ctx.method === 'password'
|| ctx.username === '418374'
|| ctx.password === 'hiandroid8#3') {
ctx.accept();
console.log("inside userpwd")
}
else if (ctx.method === 'publickey'
&& ctx.key.algo === pubKey.fulltype
&& buffersEqual(ctx.key.data, pubKey.public)) {
console.log("inside publicKey")
if (ctx.signature) {
console.log("inside signature")
var verifier = crypto.createVerify(ctx.sigAlgo);
verifier.update(ctx.blob);
if (verifier.verify(pubKey.publicOrig, ctx.signature))
ctx.accept();
else
ctx.reject();
} else {
console.log("inside nthing")
// if no signature present, that means the client is just checking
// the validity of the given public key
ctx.accept();
}
} else
ctx.reject();
}).on('ready', function() {
console.log('Client authenticated!');
client.on('session', function(accept, reject) {
console.log('Client Sssio!');
var session = accept();
session.once('exec', function(accept, reject, info) {
console.log('Client wants to execute: ' + inspect(info.command));
var stream = accept();
stream.stderr.write('Oh no, the dreaded errors!\n');
stream.write('Just kidding about the errors!\n');
stream.exit(0);
stream.end();
});
});
client.on('request', function(accept, reject, name,info,a) {
console.log('accept',accept)
console.log('reject',reject)
console.log('info',info)
console.log('name',name)
if(name==="tcpip-forward"){
//info.bindAddr='localhost';
}
console.log('infoafgter',info)
var session = accept();
console.log('tcpIp');
})
function reExec(i) {
if (i === 3)
return;
client.forwardOut('0.0.0.0', 3000, 'localhost', 8080, function(err, stream) {
if (err)
console.log(err);
else
stream.end();
reExec(++i);
});
}
reExec(0);
}).on('error',function(e){
console.log("error occcured",e)
}).on('end', function() {
console.log('Client disconnected');
});
}).listen(webSocketPort, '0.0.0.0', function() {
console.log('Listening on port ' + webSocketPort);
});
Answer here :
Create a Node SSH2 Server with ability to treat Remote Forwarding
let fs = require('fs'),
inspect = require('util').inspect,
ssh2 = require('ssh2'),
net = require('net');
new ssh2.Server({
hostKeys: [fs.readFileSync('/etc/ssh/ssh_host_rsa_key')]
}, client => {
console.log('Client connected!');
client
.on('authentication', ctx => {
if (
ctx.method === 'password'
&& ctx.username === 'foo'
&& ctx.password === 'bar'
) {
ctx.accept();
} else {
ctx.reject();
}
})
.on('ready', () => {
console.log('Client authenticated!');
client
.on('session', (accept, reject) => {
let session = accept();
session.on('shell', function(accept, reject) {
let stream = accept();
});
})
.on('request', (accept, reject, name, info) => {
if (name === 'tcpip-forward') {
accept();
net.createServer(function(socket) {
socket.setEncoding('utf8');
client.forwardOut(
info.bindAddr, info.bindPort,
socket.remoteAddress, socket.remotePort,
(err, upstream) => {
if (err) {
socket.end();
return console.error('not working: ' + err);
}
upstream.pipe(socket).pipe(upstream);
});
}).listen(info.bindPort);
} else {
reject();
}
});
});
}).listen(21, '0.0.0.0', function() {
console.log('Listening on port ' + this.address().port);
});