control.js

const http = require('axios');
const https = require('https');

const ACCOUNT_ACTIVATE_URL = '/AccountActivate';
const SERVICE_LOOKUP_URL = '/ServiceLookup';
const ACCESS_SECRET_URL = '/AccessSecret';
const SERVICE_REGISTER_URL = '/ServiceRegister';
const SERVICE_REREGISTER_URL = '/ServiceReregister';
const SERVICE_UNREGISTER_URL = '/ServiceUnregister';

/**
 * @class Control
 *
 * @description Establishes a PxGrid Control connection. Generally passed to a PxGrid REST Client session.
 * @constructor
 * @param {Object} options Options for the PxGrid Control instance. See examples for more information.
 * @param {string} options.host The IP or URL of the PxGrid Controller. Deprecated in v1.3.0, please use `hosts` array.
 * @param {Object} options.hosts An array of PxGrid controllers to attempt connecting to. The first successful connection will be used.
 * @param {number} [options.port] The host port to connect to the PxGrid Controller on.
 * @param {string} options.client The desired name of the client for the client.
 * @param {Buffer} options.clientCert A byte stream of the client public key certificate file to use.
 * @param {Buffer} options.clientKey A byte stream of the client private key file to use.
 * @param {Buffer} options.caBundle A byte stream of the CA Bundle used to verify the PxGrid Controller's identity.
 * @param {Boolean} [options.verifySSL=true] If true, verify server's SSL certificate.
 * @param {number} [options.httpTimeout=1000] Value, in milliseconds, to consider a server unavailable.
 * @param {string} [options.clientKeyPassword] The password to unlock the client private key file.
 * @param {string} [options.secret] The secret to help authenticate a newly registered service.
 *
 *
 * @see {@link https://github.com/cisco-pxgrid/pxgrid-rest-ws/wiki/ Cisco PxGrid 2.0 GitHub Wiki} for more information on the Cisco PxGrid 2.0 implementation.
 * @see Client
 * @example
 *
 * const fs = require('fs');
 * certs = [];
 * certs.clientCert = fs.readFileSync('./certs/publiccert.cer');
 * certs.clientKey = fs.readFileSync('./certs/key.pem');
 * certs.caBundle = fs.readFileSync('./certs/caBundle.cer');
 *
 * const Pxgrid = require('pxgrid-node');
 *
 * const pxgridControlOptions = {
 *   host: 'my-ise-server.domain.com',
 *   hosts: ['ise01.domain.com', 'ise02.domain.com']
 *   client: 'my-node-app',
 *   clientCert: certs.clientCert,
 *   clientKey: certs.clientKey,
 *   caBundle: certs.caBundle,
 *   clientKeyPassword: false,
 *   secret: '',
 *   port: '8910',
 *   verifySSL: false,
 *   httpTimeout: 3000
 * }
 *
 * const pxgrid = new Pxgrid.Control(pxgridControlOptions);
 */
class Control {
  constructor({
    host,
    hosts,
    client,
    clientCert,
    clientKey,
    caBundle,
    clientKeyPassword,
    secret,
    port,
    verifySSL,
    httpTimeout
  }) {
    this.config = {
      hostname: host,
      hosts: hosts,
      client,
      port: port || '8910',
      secret: secret || '',
      clientCert,
      clientKey,
      clientKeyPassword: clientKeyPassword || false,
      caBundle,
      verifySSL,
      httpTimeout: httpTimeout || 1000
    };
    this.hosts = [];

    if (
      Array.isArray(this.config.hosts) &&
      typeof this.config.hosts !== 'undefined'
    ) {
      this.hosts = this.config.hosts;
    } else {
      this.hosts = [];
    }

    // Maintaining handling of 'hostname' config attr for backwards compatability after supporting HA.
    if (
      this.config.hostname &&
      typeof this.config.hostname === 'string' &&
      !this.config.hosts
    )
      this.hosts[0] = this.config.hostname;
    // Further backwards compatability, if host/hostname value is given as array, just place in hosts (alternative is throwing error).
    if (
      this.config.hostname &&
      typeof this.config.hostname.length > 0 &&
      !this.config.hosts
    )
      this.hosts = this.config.hostname;

    this.config.verifySSL =
      typeof this.config.verifySSL === 'undefined'
        ? true
        : this.config.verifySSL;
    this.httpOptions = {
      timeout: this.config.httpTimeout
    };
    this.httpsOptions = {
      cert: this.config.clientCert,
      key: this.config.clientKey,
      ca: this.config.caBundle,
      rejectUnauthorized: this.config.verifySSL
    };
    if (this.config.clientKeyPassword)
      this.httpsOptions.passphrase = clientKeyPassword;

    this.registeredServices = [];

    if ((!this.config.hostname && !this.config.hosts) || !this.config.client) {
      throw new Error(
        'Please define hostname and a Pxgrid client name before connecting to the pxGrid server.'
      );
    }
  }

  async _post(url, body, debug = false) {
    for (let i = 0; i < this.hosts.length; i++) {
      const baseUrl = `https://${this.hosts[i]}:${this.config.port}/pxgrid/control`;
      this.basicAuth = Buffer.from(
        `${this.config.client}:${this.config.secret}`
      ).toString('base64');

      const session = http.create({
        baseURL: baseUrl,
        headers: {
          Authorization: `Basic ${this.basicAuth}`,
          'Content-Type': 'application/json',
          Accept: 'application/json'
        },
        httpsAgent: new https.Agent(this.httpsOptions)
      });

      try {
        return await session
          .post(url, body, this.httpOptions)
          .then(response => {
            if (debug) {
              console.debug(`URL: ${url}`);
              console.debug(`DATA: ${JSON.stringify(response.data)}`);
            }
            if (response.status == 200) {
              return response.data;
            } else {
              return response.status;
            }
          })
          .catch(error => {
            const err = new Error();
            err.message = 'Error in POST request from pxGrid client.';
            err.status = error.response.status;
            err.statusText = error.response.statusText;
            throw err;
          });
      } catch (e) {
        if (debug) {
          console.debug(`Connection to ${this.hosts[i]} failed, trying next..`);
        }
        continue;
      }
    }
    throw new Error('None of the provided hosts responded to requests.');
  }

  _delay(time = 5000) {
    return new Promise(function(resolve) {
      setTimeout(() => {
        resolve();
      }, time);
    });
  }

  /**
   * @description
   * Activate your client pxGrid account on the controller.
   *
   * This needs to happen one time before using your new client, as well as anytime its state changes to PENDING or DISABLED on the controller.
   *
   * For simplicity, this is handled *automatically* by Client#connect, but can be done manually, as well.
   *
   * If the client is not activated, you will fail to interact with pxGrid.
   *
   * Sometimes, the account will return with a PENDING status which will induce a backoff timer of 60 seconds before retrying. This is normally because the account needs to be activated on the pxGrid Controller (either manually, or automatically). If configured for auto-approval, the activation should work in the next attempt.
   *
   * @param {string} [accountDesc='pxgrid-node'] - A description for the client you are registering.
   * @param {number} [retryInterval=60000] - Retry interval in milliseconds.
   * @param {number} [maxRetries=10] - Maximum retries that will be attempted.
   * @param {number} [retryAttempt=1] - Which attempt we are on. This is necessary since we use recursion for retries.
   * @return {Promise} True if the PxGrid account has been activated on the upstream PxGrid controller.
   * @see {@link https://github.com/cisco-pxgrid/pxgrid-rest-ws/wiki/pxGrid-Consumer#accountactivate Cisco PxGrid 2.0 GitHub Wiki - AccountActivate}
   * @memberof Control
   */
  activate(
    accountDesc = 'pxgrid-node',
    retryInterval = 60000,
    maxRetries = 10,
    retryAttempt = 1
  ) {
    const payload = { description: accountDesc };
    return (
      this._post(ACCOUNT_ACTIVATE_URL, payload)
        // eslint-disable-next-line consistent-return
        .then(response => {
          const state = response.accountState;
          if (state === 'ENABLED') {
            return true;
            // eslint-disable-next-line no-else-return
          } else if (state === 'PENDING') {
            if (retryAttempt > maxRetries) {
              throw new Error(
                `Account state is PENDING (likely requires approval). Hit max number of retries (${maxRetries}).`
              );
            } else {
              console.log(
                `Account state is PENDING. Retrying in ${retryInterval /
                  1000} seconds (attempt: ${retryAttempt}/${maxRetries}). The account may need to be approved on the pxGrid controller.`
              );
              return this._delay(retryInterval).then(() =>
                this.activate(
                  accountDesc,
                  retryInterval,
                  maxRetries,
                  retryAttempt + 1
                )
              );
            }
          } else if (state === 'DISABLED') {
            throw new Error(
              'Client failed to activate because the account state is DISABLED! Please enable the account on the pxGrid controller and try again.'
            );
          }
        })
    );
  }

  /**
   * @description
   * Looks up any nodes publishing the serviceName.
   *
   * If no nodes are publishing, response is empty. Therefore, if subscribing, your subscription will fail until a publisher is registered for the service/topic.
   *
   * Will retry every `retryInterval` if no publishers are registered and activated.
   * @memberof Control
   * @param {string} serviceName - Name of the service to lookup.
   * @param {number} [retryInterval=30000] - Retry interval in milliseconds.
   * @param {number} [maxRetries=10] - Maximum retries that will be attempted.
   * @param {number} [retryAttempt=1] - Which attempt we are on. This is necessary since we use recursion for retries.
   * @return {Promise} Returns a list of nodes providing the specified service, as well as their properties. Empty if no publishers registered.
   */
  serviceLookup(
    serviceName,
    retryInterval = 30000,
    maxRetries = 10,
    retryAttempt = 1
  ) {
    const payload = { name: serviceName };
    return this._post(SERVICE_LOOKUP_URL, payload)
      .then(response => {
        if (!response.services[0]) {
          // If no publishers found, retry every 30 seconds.
          if (retryAttempt > maxRetries) {
            throw new Error(
              `No registered publisher(s) for service/topic. Hit max number of retries (${maxRetries}).`
            );
          } else {
            console.log(
              `No publishing nodes registered for service ${serviceName}, retrying in ${retryInterval /
                1000} seconds (attempt: ${retryAttempt})...`
            );
            return this._delay(retryInterval).then(() =>
              this.serviceLookup(
                serviceName,
                retryInterval,
                maxRetries,
                retryAttempt + 1
              )
            );
          }
        } else {
          return response.services[0];
        }
      })
      .catch(error => {
        let message;
        switch (error.status) {
          case 401:
            // 401 Unauthorized
            message =
              'pxGrid client is not registered to the controller. ' +
              'Please ensure that Control#activate() is run at least once ' +
              'before using your client.';
            break;
          case 403:
            // 403 Forbidden
            message =
              'pxGrid client is forbidden from performing task. ' +
              'This is likely because the client registered, but still in a PENDING status. ' +
              'Please approve the client on the pxGrid controller.';
            break;
          default:
            message = `Response status: ${error.status} ${error.statusText}`;
        }
        throw new Error(message);
      });
  }

  /**
   * @description Register as a publisher to a service. This could be a new service, or an existing service.
   * @memberof Control
   * @param  {string} serviceName - Name of the service to register for.
   * @param  {Object} properties - Properties of the service you are registering.
   * @return {Promise} The id and reregisterTimeMillis for the newly registered service.
   */
  serviceRegister(serviceName, properties) {
    const payload = { name: serviceName, properties };
    return this._post(SERVICE_REGISTER_URL, payload)
      .then(response => {
        this.registeredServices[serviceName] = response.id;
        return response;
      })
      .catch(error => console.log(error));
  }

  /**
   * @description Reregister your node for a service. Services must reregister within the reregisterTimeMillis interval provided when initially registering.
   * @memberof Control
   * @see Control#serviceRegister
   * @param  {string} serviceId - The ID of the service to reregister.
   * @return {Promise} Empty response from controller, if successful.
   */
  serviceReregister(serviceId) {
    return this._post(SERVICE_REREGISTER_URL, { id: serviceId });
  }

  /**
   * @description Automatically reregister a service at a given interval. Reregistration will occur 5 seconds prior to provided interval to prevent inadvertent timeouts.
   * @memberof Control
   * @see Control#serviceReregister
   * @param {string} serviceId - The ID of the service to reregister.
   * @param {number} interval - Interval to reregister (milliseconds).
   */
  autoServiceReregister(serviceId, interval) {
    setInterval(() => this.serviceReregister(serviceId), interval - 5000);
  }

  /**
   * @description Unregisters a service.
   * @memberof Control
   * @param {string} serviceId - The ID of the service to unregister.
   * @return {Promise} Empty response from controller, if successful.
   */
  serviceUnregister(serviceId) {
    return this._post(SERVICE_UNREGISTER_URL, { id: serviceId });
  }

  /**
   * @description Unregisters all services that have been registered by the instance.
   * @memberof Control
   */
  serviceUnregisterAll() {
    this.registeredServices.forEach(service => {
      this.serviceUnregister(service.id).then(response =>
        console.log(response)
      );
    });
  }

  /**
   * @description Gets the Access Secret for a given node. AccessSecret is a unique secret between a Consumer and Provider pair.
   * @memberof Control
   * @param  {string} nodeName - The node name to get the secret for.
   * @return {Promise} Access Secret for node.
   */
  getAccessSecret(nodeName) {
    const payload = { peerNodeName: nodeName };
    return this._post(ACCESS_SECRET_URL, payload);
  }

  /**
   * @description Returns the control configuration.
   * @memberof Control
   * @return {Object} Control configuration.
   */
  getConfig() {
    return this.config;
  }
}

module.exports = Control;