plugin-traverse.js

/**
 * @module kadence/traverse
 */

'use strict';

const { createLogger } = require('bunyan');
const ip = require('ip');
const merge = require('merge');
const async = require('async');
const { get_gateway_ip: getGatewayIp } = require('network');
const natpmp = require('nat-pmp');
const natupnp = require('nat-upnp');
const url = require('node:url');
const diglet = require('@tacticalchihuahua/diglet');


/**
 * Establishes a series of NAT traversal strategies to execute before
 * {@link AbstractNode#listen}
 */
class TraversePlugin {

  static get TEST_INTERVAL() {
    return 600000;
  }

  /**
   * @constructor
   * @param {KademliaNode} node
   * @param {module:kadence/traverse~TraverseStrategy[]} strategies
   */
  constructor(node, strategies) {
    this.node = node;
    this.strategies = strategies;
    this._originalContact = merge({}, node.contact);

    this._wrapNodeListen();
  }

  /**
   * @private
   * @param {function} callback
   */
  _execTraversalStrategies(callback) {
    async.detectSeries(this.strategies, (strategy, test) => {
      this.node.logger.info(
        `attempting nat traversal strategy ${strategy.constructor.name}`
      );
      this.node.contact = this._originalContact;
      strategy.exec(this.node, (err) => {
        if (err) {
          this.node.logger.warn(err.message);
          test(null, false);
        } else {
          this._testIfReachable(test);
        }
      });
    }, callback);
  }

  /**
   * @private
   */
  _startTestInterval() {
    clearInterval(this._testInterval);

    this._testInterval = setInterval(() => {
      this._testIfReachable((err, isReachable) => {
        /* istanbul ignore else */
        if (!isReachable) {
          this.node.logger.warn('no longer reachable, retrying traversal');
          this._execTraversalStrategies(() => null);
        }
      });
    }, TraversePlugin.TEST_INTERVAL);
  }

  /**
   * @private
   */
  _testIfReachable(callback) {
    try {
      if (!ip.isPublic(this.node.contact.hostname)) {
        this.node.logger.warn('traversal strategy failed, not reachable');
        return callback(null, false);
      }
    } catch (err) {
      this.node.logger.warn(err.message);
      return callback(null, false);
    }

    callback(null, true);
  }

  /**
   * @private
   */
  _wrapNodeListen() {
    const self = this;
    const listen = this.node.listen.bind(this.node);

    this.node.listen = function() {
      let args = [...arguments];
      let listenCallback = () => null;

      if (typeof args[args.length - 1] === 'function') {
        listenCallback = args.pop();
      }

      listen(...args, () => {
        self._execTraversalStrategies((err, strategy) => {
          if (err) {
            self.node.logger.error('traversal errored %s', err.message);
          } else if (!strategy) {
            self.node.logger.warn('traversal failed - may not be reachable');
          } else {
            self.node.logger.info('traversal succeeded - you are reachable');
          }

          self._startTestInterval();
          listenCallback();
        });
      });
    };
  }

}

/**
 * Uses NAT-PMP to attempt port forward on gateway device
 * @extends {module:kadence/traverse~TraverseStrategy}
 */
class NATPMPStrategy {

  static get DEFAULTS() {
    return {
      publicPort: 0,
      mappingTtl: 0,
      timeout: 10000
    };
  }

  /**
   * @constructor
   * @param {object} [options]
   * @param {number} [options.publicPort=contact.port] - Port number to map
   * @param {number} [options.mappingTtl=0] - TTL for port mapping on router
   */
  constructor(options) {
    this.options = merge(NATPMPStrategy.DEFAULTS, options);
  }

  /**
   * @param {KademliaNode} node
   * @param {function} callback
   */
  exec(node, callback) {
    async.waterfall([
      (next) => getGatewayIp(next),
      (gateway, next) => {
        const timeout = setTimeout(() => {
          next(new Error('NAT-PMP traversal timed out'));
        }, this.options.timeout);
        this.client = natpmp.connect(gateway);
        this.client.portMapping({
          public: this.options.publicPort || node.contact.port,
          private: node.contact.port,
          ttl: this.options.mappingTtl
        }, err => {
          clearTimeout(timeout);
          next(err);
        });
      },
      (next) => this.client.externalIp(next)
    ], (err, info) => {
      if (err) {
        return callback(err);
      }

      node.contact.port = this.options.publicPort;
      node.contact.hostname = info.ip.join('.');

      callback(null);
    });
  }

}

/**
 * Uses UPnP to attempt port forward on gateway device
 * @extends {module:kadence/traverse~TraverseStrategy}
 */
class UPNPStrategy {

  static get DEFAULTS() {
    return {
      publicPort: 0,
      mappingTtl: 0
    };
  }

  /**
   * @constructor
   * @param {object} [options]
   * @param {number} [options.publicPort=contact.port] - Port number to map
   * @param {number} [options.mappingTtl=0] - TTL for mapping on router
   */
  constructor(options) {
    this.client = natupnp.createClient();
    this.options = merge(UPNPStrategy.DEFAULTS, options);
  }

  /**
   * @param {KademliaNode} node
   * @param {function} callback
   */
  exec(node, callback) {
    async.waterfall([
      (next) => {
        this.client.portMapping({
          public: this.options.publicPort || node.contact.port,
          private: node.contact.port,
          ttl: this.options.mappingTtl
        }, err => next(err));
      },
      (next) => this.client.externalIp(next)
    ], (err, ip) => {
      if (err) {
        return callback(err);
      }

      node.contact.port = this.options.publicPort;
      node.contact.hostname = ip;

      callback(null);
    });
  }

}

/**
 * Uses a secure reverse HTTPS tunnel via the Diglet package to traverse NAT.
 * This requires a running Diglet server on the internet. By default, this
 * plugin will use a test server operated by bookchin, but this may not be
 * reliable or available. It is highly recommended to deploy your own Diglet
 * server and configure your nodes to use them instead.
 * There is {@link https://gitlab.com/bookchin/diglet detailed documentation}
 * on deploying a Diglet server at the project page.
 * @extends {module:kadence/traverse~TraverseStrategy}
 */
class ReverseTunnelStrategy {

  static get DEFAULTS() {
    return {
      remoteAddress: 'tun.tacticalchihuahua.lol',
      remotePort: 8443,
      secureLocalConnection: false,
      verboseLogging: false
    };
  }

  /**
   * @constructor
   * @param {object} [options]
   * @param {string} [options.remoteAddress=tunnel.bookch.in] - Diglet server address
   * @param {number} [options.remotePort=8443] - Diglet server port
   * @param {buffer} [options.privateKey] - SECP256K1 private key if using spartacus
   * @param {boolean} [options.secureLocalConnection=false] - Set to true if using {@link HTTPSTransport}
   * @param {boolean} [options.verboseLogging=false] - Useful for debugging
   */
  constructor(options) {
    this.options = merge(ReverseTunnelStrategy.DEFAULTS, options);
  }

  /**
   * @param {KademliaNode} node
   * @param {function} callback
   */
  exec(node, callback) {
    const opts = {
      localAddress: '127.0.0.1',
      localPort: node.contact.port,
      remoteAddress: this.options.remoteAddress,
      remotePort: this.options.remotePort,
      logger: this.options.verboseLogging
        ? node.logger
        : createLogger({ name: 'kadence', level: 'warn' }),
      secureLocalConnection: this.options.secureLocalConnection
    };

    if (this.options.privateKey) {
      opts.privateKey = this.options.privateKey;
    }

    this.tunnel = new diglet.Tunnel(opts);

    this.tunnel.once('connected', () => {
      node.contact.hostname = url.parse(this.tunnel.url).hostname;
      node.contact.port = 443;
      node.contact.protocol = 'https:';

      this.tunnel.removeListener('disconnected', callback);
      callback()
    });

    this.tunnel.once('disconnected', callback);
    this.tunnel.open();
  }

}

/**
 * @class
 */
class TraverseStrategy {

  constructor() {}

  /**
   * @param {KademliaNode} node
   * @param {function} callback - Called on travere complete or failed
   */
  exec(node, callback) {
    callback(new Error('Not implemented'));
  }

}

/**
 * Registers a {@link module:kadence/traverse~TraversePlugin} with an
 * {@link AbstractNode}. Strategies are attempted in the order they are
 * defined.
 * @param {module:kadence/traverse~TraverseStrategy[]} strategies
 * @example <caption>Proper Configuration</caption>
 * const node = new kadence.KademliaNode(node_options);
 * const keys = node.plugin(kadence.spartacus(key_options));
 *
 * node.plugin(kadence.traverse([
 *   new kadence.traverse.UPNPStrategy({
 *     publicPort: 8080,
 *     mappingTtl: 0
 *   }),
 *   new kadence.traverse.NATPMPStrategy({
 *     publicPort: 8080,
 *     mappingTtl: 0
 *   }),
 *   new kadence.traverse.ReverseTunnelStrategy({
 *     remoteAddress: 'my.diglet.server',
 *     remotePort: 8443,
 *     privateKey: keys.privateKey,
 *     secureLocalConnection: false,
 *     verboseLogging: false
 *   })
 * ]));
 *
 * node.listen(node.contact.port);
 */
module.exports = function(strategies) {
  return function(node) {
    return new module.exports.TraversePlugin(node, strategies);
  };
};

module.exports.ReverseTunnelStrategy = ReverseTunnelStrategy;
module.exports.UPNPStrategy = UPNPStrategy;
module.exports.NATPMPStrategy = NATPMPStrategy;
module.exports.TraverseStrategy = TraverseStrategy;
module.exports.TraversePlugin = TraversePlugin;