Building an HTTP Tunnel With WebSocket and Node.JS
Introduce about how I build a HTTP tunnel tool based on WebSocket.
Join the DZone community and get the full member experience.
Join For FreeWhen we develop some apps which integrate with third-party services, we need to make our local development server to be exposed to the Internet. To do that, we need an HTTP tunnel for our local server. How does the HTTP tunnel work? In this article, I will show you how I build an HTTP tunnel tool.
Why Do We Need To Deploy Our Own HTTP Tunnel Service?
There are lots of awesome online services for HTTP tunnels. For example, we can use ngrok
to get paid fixed public domain to connect your local server. It also has a free package. But for the free package, you can’t get a fixed domain. Once you restart the client, you will get a new random domain. It is inconvenient when you need to save the domain in a third-party service.
To get a fixed domain, we can deploy an HTTP tunnel in our own server. ngrok
also provides an open source version for server-side deployment. But it is an old 1.x version and not recommended to deploy at production with some serious reliability issues.
With our own server, it can also keep data secure.
Introduction About Lite HTTP Tunnel
Lite HTTP Tunnel is what I build recently for a self-host HTTP tunnel service. You can deploy it with Heroku
button in the Github repository to get a free fixed Heroku domain quickly.
It is built based on Express.js
and Socket.io
with just a few codes. It uses WebSocket to stream HTTP/HTTPS requests from a public server into your local server.
How I Implement It
Step 1: Build a WebSocket Connection Between Server and Client
Support WebSocket connection at the server side with socket.io
:
const http = require('http');
const express = require('express');
const { Server } = require('socket.io');
const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer);
let connectedSocket = null;
io.on('connection', (socket) => {
console.log('client connected');
connectedSocket = socket;
const onMessage = (message) => {
if (message === 'ping') {
socket.send('pong');
}
}
const onDisconnect = (reason) => {
console.log('client disconnected: ', reason);
connectedSocket = null;
socket.off('message', onMessage);
socket.off('error', onError);
};
const onError = (e) => {
connectedSocket = null;
socket.off('message', onMessage);
socket.off('disconnect', onDisconnect);
};
socket.on('message', onMessage);
socket.once('disconnect', onDisconnect);
socket.once('error', onError);
});
httpServer.listen(process.env.PORT);
Connect WebSocket at the Client side:
const { io } = require('socket.io-client');
let socket = null;
function initClient(options) {
socket = io(options.server, {
transports: ["websocket"],
auth: {
token: options.jwtToken,
},
});
socket.on('connect', () => {
if (socket.connected) {
console.log('client connect to server successfully');
}
});
socket.on('connect_error', (e) => {
console.log('connect error', e && e.message);
});
socket.on('disconnect', () => {
console.log('client disconnected');
});
}
Step 2: Use the Jwt Token To Protect Websocket Connection
On the server side, we use socket.io middleware to reject invalid connections:
const jwt = require('jsonwebtoken');
io.use((socket, next) => {
if (connectedSocket) {
return next(new Error('Connected error'));
}
if (!socket.handshake.auth || !socket.handshake.auth.token){
next(new Error('Authentication error'));
}
jwt.verify(socket.handshake.auth.token, process.env.SECRET_KEY, function(err, decoded) {
if (err) {
return next(new Error('Authentication error'));
}
if (decoded.token !== process.env.VERIFY_TOKEN) {
return next(new Error('Authentication error'));
}
next();
});
});
Step 3: Stream Request From the Server to Client
We implement a writable stream to send request data to tunnel client:
const { Writable } = require('stream');
class SocketRequest extends Writable {
constructor({ socket, requestId, request }) {
super();
this._socket = socket;
this._requestId = requestId;
this._socket.emit('request', requestId, request);
}
_write(chunk, encoding, callback) {
this._socket.emit('request-pipe', this._requestId, chunk);
this._socket.conn.once('drain', () => {
callback();
});
}
_writev(chunks, callback) {
this._socket.emit('request-pipes', this._requestId, chunks);
this._socket.conn.once('drain', () => {
callback();
});
}
_final(callback) {
this._socket.emit('request-pipe-end', this._requestId);
this._socket.conn.once('drain', () => {
callback();
});
}
_destroy(e, callback) {
if (e) {
this._socket.emit('request-pipe-error', this._requestId, e && e.message);
this._socket.conn.once('drain', () => {
callback();
});
return;
}
callback();
}
}
app.use('/', (req, res) => {
if (!connectedSocket) {
res.status(404);
res.send('Not Found');
return;
}
const requestId = uuidV4();
const socketRequest = new SocketRequest({
socket: connectedSocket,
requestId,
request: {
method: req.method,
headers: { ...req.headers },
path: req.url,
},
});
const onReqError = (e) => {
socketRequest.destroy(new Error(e || 'Aborted'));
}
req.once('aborted', onReqError);
req.once('error', onReqError);
req.pipe(socketRequest);
req.once('finish', () => {
req.off('aborted', onReqError);
req.off('error', onReqError);
});
// ...
});
Implement a stream readable to get request data on the client side:
const stream = require('stream');
class SocketRequest extends stream.Readable {
constructor({ socket, requestId }) {
super();
this._socket = socket;
this._requestId = requestId;
const onRequestPipe = (requestId, data) => {
if (this._requestId === requestId) {
this.push(data);
}
};
const onRequestPipes = (requestId, data) => {
if (this._requestId === requestId) {
data.forEach((chunk) => {
this.push(chunk);
});
}
};
const onRequestPipeError = (requestId, error) => {
if (this._requestId === requestId) {
this._socket.off('request-pipe', onRequestPipe);
this._socket.off('request-pipes', onRequestPipes);
this._socket.off('request-pipe-error', onRequestPipeError);
this._socket.off('request-pipe-end', onRequestPipeEnd);
this.destroy(new Error(error));
}
};
const onRequestPipeEnd = (requestId, data) => {
if (this._requestId === requestId) {
this._socket.off('request-pipe', onRequestPipe);
this._socket.off('request-pipes', onRequestPipes);
this._socket.off('request-pipe-error', onRequestPipeError);
this._socket.off('request-pipe-end', onRequestPipeEnd);
if (data) {
this.push(data);
}
this.push(null);
}
};
this._socket.on('request-pipe', onRequestPipe);
this._socket.on('request-pipes', onRequestPipes);
this._socket.on('request-pipe-error', onRequestPipeError);
this._socket.on('request-pipe-end', onRequestPipeEnd);
}
_read() {}
}
socket.on('request', (requestId, request) => {
console.log(`${request.method}: `, request.path);
request.port = options.port;
request.hostname = options.host;
const socketRequest = new SocketRequest({
requestId,
socket: socket,
});
const localReq = http.request(request);
socketRequest.pipe(localReq);
const onSocketRequestError = (e) => {
socketRequest.off('end', onSocketRequestEnd);
localReq.destroy(e);
};
const onSocketRequestEnd = () => {
socketRequest.off('error', onSocketRequestError);
};
socketRequest.once('error', onSocketRequestError);
socketRequest.once('end', onSocketRequestEnd);
// ...
});
Step 4: Stream Response From the Client to Server
Implement a writable stream to send response data to the tunnel server:
const stream = require('stream');
class SocketResponse extends stream.Writable {
constructor({ socket, responseId }) {
super();
this._socket = socket;
this._responseId = responseId;
}
_write(chunk, encoding, callback) {
this._socket.emit('response-pipe', this._responseId, chunk);
this._socket.io.engine.once('drain', () => {
callback();
});
}
_writev(chunks, callback) {
this._socket.emit('response-pipes', this._responseId, chunks);
this._socket.io.engine.once('drain', () => {
callback();
});
}
_final(callback) {
this._socket.emit('response-pipe-end', this._responseId);
this._socket.io.engine.once('drain', () => {
callback();
});
}
_destroy(e, callback) {
if (e) {
this._socket.emit('response-pipe-error', this._responseId, e && e.message);
this._socket.io.engine.once('drain', () => {
callback();
});
return;
}
callback();
}
writeHead(statusCode, statusMessage, headers) {
this._socket.emit('response', this._responseId, {
statusCode,
statusMessage,
headers,
});
}
}
socket.on('request', (requestId, request) => {
// ...stream request and send request to local server...
const onLocalResponse = (localRes) => {
localReq.off('error', onLocalError);
const socketResponse = new SocketResponse({
responseId: requestId,
socket: socket,
});
socketResponse.writeHead(
localRes.statusCode,
localRes.statusMessage,
localRes.headers
);
localRes.pipe(socketResponse);
};
const onLocalError = (error) => {
console.log(error);
localReq.off('response', onLocalResponse);
socket.emit('request-error', requestId, error && error.message);
socketRequest.destroy(error);
};
localReq.once('error', onLocalError);
localReq.once('response', onLocalResponse);
});
Implement a readable stream to get response data in tunnel server:
class SocketResponse extends Readable {
constructor({ socket, responseId }) {
super();
this._socket = socket;
this._responseId = responseId;
const onResponse = (responseId, data) => {
if (this._responseId === responseId) {
this._socket.off('response', onResponse);
this._socket.off('request-error', onRequestError);
this.emit('response', data.statusCode, data.statusMessage, data.headers);
}
}
const onResponsePipe = (responseId, data) => {
if (this._responseId === responseId) {
this.push(data);
}
};
const onResponsePipes = (responseId, data) => {
if (this._responseId === responseId) {
data.forEach((chunk) => {
this.push(chunk);
});
}
};
const onResponsePipeError = (responseId, error) => {
if (this._responseId !== responseId) {
return;
}
this._socket.off('response-pipe', onResponsePipe);
this._socket.off('response-pipes', onResponsePipes);
this._socket.off('response-pipe-error', onResponsePipeError);
this._socket.off('response-pipe-end', onResponsePipeEnd);
this.destroy(new Error(error));
};
const onResponsePipeEnd = (responseId, data) => {
if (this._responseId !== responseId) {
return;
}
if (data) {
this.push(data);
}
this._socket.off('response-pipe', onResponsePipe);
this._socket.off('response-pipes', onResponsePipes);
this._socket.off('response-pipe-error', onResponsePipeError);
this._socket.off('response-pipe-end', onResponsePipeEnd);
this.push(null);
};
const onRequestError = (requestId, error) => {
if (requestId === this._responseId) {
this._socket.off('request-error', onRequestError);
this._socket.off('response', onResponse);
this._socket.off('response-pipe', onResponsePipe);
this._socket.off('response-pipes', onResponsePipes);
this._socket.off('response-pipe-error', onResponsePipeError);
this._socket.off('response-pipe-end', onResponsePipeEnd);
this.emit('requestError', error);
}
};
this._socket.on('response', onResponse);
this._socket.on('response-pipe', onResponsePipe);
this._socket.on('response-pipes', onResponsePipes);
this._socket.on('response-pipe-error', onResponsePipeError);
this._socket.on('response-pipe-end', onResponsePipeEnd);
this._socket.on('request-error', onRequestError);
}
_read(size) {}
}
app.use('/', (req, res) => {
// ... stream request to tunnel client
const onResponse = (statusCode, statusMessage, headers) => {
socketRequest.off('requestError', onRequestError)
res.writeHead(statusCode, statusMessage, headers);
};
socketResponse.once('requestError', onRequestError)
socketResponse.once('response', onResponse);
socketResponse.pipe(res);
const onSocketError = () => {
res.end(500);
};
socketResponse.once('error', onSocketError);
connectedSocket.once('close', onSocketError)
res.once('close', () => {
connectedSocket.off('close', onSocketError);
socketResponse.off('error', onSocketError);
});
});
After all steps, we have supported streaming HTTP requests into the local computer and sending responses from the local server to the original request. It is a lite solution, but it is stable and easy to deploy at any Node.js
environment.
More
If you just want to find an HTTP tunnel service with a free fixed domain, you can try to deploy the Lite HTTP Tunnel project into Heroku
with Heroku deploy button
in the Github README. Hope you can learn something from this article.
Published at DZone with permission of Embbnux Ji. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments