Building an HTTP Tunnel With WebSocket and Node.JS

When 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 is 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 a tunnel client:

{ 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); }); // … });” data-lang=”text/javascript”>

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("https://dzone.com/", (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:

{ // … 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); }); }); ” data-lang=”text/javascript”>

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("https://dzone.com/", (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.

.

Leave a Comment