server.js

'use strict';

const { EventEmitter } = require('events');
const merge = require('merge');
const http = require('http');
const net = require('tls');
const { createLogger } = require('bunyan');
const crypto = require('crypto');
const Proxy = require('./proxy');
const Handshake = require('./handshake');
const randomWord = require('random-word');


/** Manages a collection of proxy tunnels and routing incoming requests */
class Server extends EventEmitter {

  static get HSTS_POLICY_HEADER() {
    return 'max-age=31536000; includeSubDomains';
  }

  static get DEFAULTS() {
    return {
      proxyMaxConnections: 96,
      logger: createLogger({ name: 'diglet' }),
      whitelist: false,
      key: null,
      cert: null,
      subdomainAliasLength: 2,
    };
  }

  /**
   * Represents a tunnel/proxy server
   * @param {Object} options
   * @param {Number} [options.proxyMaxConnections=48] - Max tunnels for proxy
   * @param {Object} [options.logger=console] - Custom logger to use
   */
  constructor(options) {
    super();

    this._opts = this._checkOptions(merge(Server.DEFAULTS, options));
    this._proxies = new Map();
    this._aliases = new Map();
    this._logger = this._opts.logger;
    this._server = net.createServer({
      key: this._opts.key,
      cert: this._opts.cert
    }, sock => this._handleTunnelClient(sock));

    this._getAliasForProxy = this._opts.getAliasById ||
      this._getRandomUnusedAlias;
  }

  /**
   * Listens for tunnel clients on the port
   * @param {number} port
   */
  listen() {
    this._server.listen(...arguments);
  }

  /**
   * Validates options given to constructor
   * @private
   */
  _checkOptions(o) {
    return o;
  }

  /**
   * Establishes a handshake with a tunnel client and creates a proxy
   * @private
   */
  _handleTunnelClient(socket) {
    const challenge = Handshake.challenge();

    socket.once('data', message => {
      this._logger.info('received challenge response from client');

      const handshake = Handshake.from(message);
      const id = crypto.createHash('rmd160').update(
        crypto.createHash('sha256').update(handshake.pubkey || '').digest()
      ).digest('hex');

      if (Buffer.compare(challenge, handshake.challenge) !== 0) {
        this._logger.info('invalid challenge response - bad challenge');
        return socket.destroy();
      }

      if (!handshake.verify()) {
        this._logger.info('invalid challenge response - bad signature');
        return socket.destroy();
      }

      if (this._opts.whitelist && this._opts.whitelist.indexOf(id) === -1) {
        this._logger.info('invalid challenge response - not in whitelist');
        return socket.destroy();
      }

      let proxy = this._proxies.get(id);

      if (!proxy) {
        proxy = this._proxies.get(id) || new Proxy({
          id,
          logger: this._logger,
        });

        this._aliases.set(this._getAliasForProxy(id), id);
        this._proxies.set(id, proxy);
      }

      proxy.push(socket);
    });

    socket.on('error', err => {
      this._logger.warn(err.message);
      socket.destroy();
    });

    this._logger.info('tunnel client opened, issuing challenge');
    socket.write(challenge);
  }

  /**
   * @private
   */
  _respondErrorNoTunnelConnected(proxyId, request, response, next) {
    this._logger.warn('no proxy with id %s exists', proxyId);

    const error = {
      code: 502,
      message: 'Our server moles found your tunnel, ' +
        'but there isn\'t anyone at the other end.'
    };

    next(error);
  }

  /**
   * @private
   */
  _respondErrorNotServicable(proxyId, request, response, next) {
    this._logger.warn('proxy with id %s cannot service request', proxyId);

    const error = {
      code: 504,
      message: 'Our server moles found your tunnel, ' +
        'but couldn\'t get through it. Try again later!'
    };

    next(error);
  }

  /**
   * Routes the incoming HTTP request to it's corresponding proxy
   * @param {String} proxyId - The unique ID for the proxy instance
   * @param {http.IncomingMessage} request
   * @param {http.ServerResponse} response
   */
  routeHttpRequest(proxyId, request, response, next) {
    if (this._aliases.has(proxyId)) {
      proxyId = this._aliases.get(proxyId);
    }

    const proxy = this._proxies.get(proxyId);

    this._logger.info('routing HTTP request to proxy');

    if (!proxy) {
      return this._respondErrorNoTunnelConnected(proxyId, request, response,
        next);
    }

    let responseDidFinish = false;

    const _onFinished = () => {
      this._logger.info('response finished, destroying connection');
      responseDidFinish = true;
      request.connection.destroy();
    };

    response
      .once('finish', _onFinished)
      .once('error', _onFinished)
      .once('close', _onFinished);

    const getSocketHandler = (proxySocket, addSocketBackToPool) => {
      if (responseDidFinish) {
        this._logger.warn('response already finished, aborting');
        return addSocketBackToPool && addSocketBackToPool();
      } else if (!proxySocket) {
        this._logger.warn('no proxied sockets back to client are available');
        return this._respondErrorNotServicable(proxyId, request, response,
          next);
      }

      const clientRequest = http.request({
        path: request.url,
        method: request.method,
        headers: request.headers,
        createConnection: () => proxySocket
      });

      const _forwardResponse = (clientResponse) => {
        this._logger.info('forwarding tunneled response back to requester');
        proxySocket.setTimeout(0);
        response.writeHead(clientResponse.statusCode, {
          'Strict-Transport-Security': Server.HSTS_POLICY_HEADER,
          ...clientResponse.headers
        });
        clientResponse.pipe(response);
      };

      this._logger.info('tunneling request through to client');
      proxySocket.setTimeout(8000);
      proxySocket.on('timeout', () => {
        this._respondErrorNotServicable(proxyId, request, response, next);
        clientRequest.abort();
        proxySocket.destroy();
      });
      response.once('finish', () => {
        addSocketBackToPool();
      });
      clientRequest.on('abort', () => proxy.pop(getSocketHandler));
      clientRequest.on('response', (resp) => _forwardResponse(resp));
      clientRequest.on('error', () => request.connection.destroy());
      request.pipe(clientRequest);
    };

    this._logger.info('getting proxy tunnel socket back to client...');
    proxy.pop(getSocketHandler);
  }

  /**
   * Routes the incoming WebSocket connection to it's corresponding proxy
   * @param {String} proxyId - The unique ID for the proxy instance
   * @param {http.IncomingMessage} request
   * @param {net.Socket} socket
   */
  routeWebSocketConnection(proxyId, request, socket) {
    if (this._aliases.has(proxyId)) {
      proxyId = this._aliases.get(proxyId);
    }

    const proxy = this._proxies.get(proxyId);

    if (!proxy) {
      return socket.destroy();
    }

    let socketDidFinish = false;

    socket.once('end', () => socketDidFinish = true);
    proxy.pop(function(proxySocket) {
      if (socketDidFinish) {
        return;
      } else if (!proxySocket) {
        socket.destroy();
        request.connection.destroy();
        return;
      }

      proxySocket.pipe(socket).pipe(proxySocket);
      proxySocket.write(Server.recreateWebSocketHeaders(request));
    });
  }

  /**
   * Recreates the header information for websocket connections
   * @private
   */
  static recreateWebSocketHeaders(request) {
    var headers = [
      `${request.method} ${request.url} HTTP/${request.httpVersion}`
    ];

    for (let i = 0; i < (request.rawHeaders.length - 1); i += 2) {
      headers.push(`${request.rawHeaders[i]}: ${request.rawHeaders[i + 1]}`);
    }

    headers.push(`Strict-Transport-Security: ${Server.HSTS_POLICY_HEADER}`);
    headers.push('');
    headers.push('');

    return headers.join('\r\n');
  }

  /**
   * Returns some metadata / diagnostic information about a given proxy
   * @param {string} id - Public key hash
   * @returns {object}
   */
  getProxyInfoById(id) {
    if (!this._proxies.has(id)) {
      return null;
    }

    const info = this._proxies.get(id).info;
    const aliasIndex = [...this._aliases.values()].indexOf(id);
    const alias = [...this._aliases.keys()][aliasIndex];

    return { alias, ...info };
  }

  /**
   * Generates a human-readable subdomain to alias a pubkey
   * for this session
   * @param {array} exclude - Recurse until result is not in this list
   * @param {number} [words] - Total words to use
   * @private
   */
  _getRandomUnusedAlias() {
    let alias = '';

    while (alias.split('-').length <= this._opts.subdomainAliasLength) {
      alias += `-${randomWord()}`;
    }

    if (this._aliases.has(alias)) {
      return Server._getRandomUnusedAlias();
    }

    return alias.substr(1);
  }

}

module.exports = Server;