so5/ssh-client-wrapper

View on GitHub
lib/util.js

Summary

Maintainability
C
1 day
Test Coverage
C
70%
"use strict";
const { EOL } = require("os");
const fs = require("fs");
const debug = require("debug")("sshClientWrapper:debug:util");
const { setTimeout: setTimeoutPromise } = require("timers/promises");

const sshCmd = "ssh";
const rsyncCmd = "rsync";
const acceptableRsyncRetrunCodes = [10, 11, 12, 13, 14];

const rePwPrompt = /password:/;
const rePhPrompt = /Enter passphrase for key/;
const reNewHostPrompt = /Are you sure you want to continue connecting/;

const Ajv = require("ajv");

const ajv = new Ajv({
  allErrors: true,
  coerceTypes: true,
  useDefaults: "empty",
  logger: {
    log: debug,
    warn: debug,
    error: debug
  }
});
require("ajv-keywords")(ajv, "transform");

// never validate password and passphrase value to avoid security insident
const hostInfoSchema = {
  type: "object",
  properties: {
    host: { type: "string", pattern: "\\S+", transform: ["trim"] },
    user: { type: "string", pattern: "\\S+", transform: ["trim"] },
    port: { type: "number", minimum: 0, maximum: 65535 },
    keyFile: { type: "string", pattern: "\\S+", transform: ["trim"] },
    noStrictHostkeyChecking: { type: "boolean" },
    ControlPersist: { type: "number", minimum: 0, default: 180 },
    ConnectTimeout: { type: "number", minimum: 0 },
    ControlPersistDir: { type: "string", pattern: "\\S+", transform: ["trim"] },
    maxRetry: { type: "number", minimum: 0, default: 3 },
    retryDuration: { type: "number", minimum: 0, default: 1000 },
    sshOpt: { type: "array", minItems: 1, items: { type: "string", pattern: "\\S+", transform: ["trim"] } }
  },
  required: ["host"]
};

const stringOptions = [
  "user",
  "keyFile",
  "ControlPersistDir"
];
const numberOptions = [
  "ControlPersist",
  "ConnectTimeout",
  "maxRetry",
  "retryDuration"
];

const validate = ajv.compile(hostInfoSchema);

/**
 * bridge function to functions which need string
 * @param {Function} func - function
 * @param {data} data - binary data which can be converted to string
 */
const buff2String = (func, data) => {
  func(data.toString().replace(/\r\n/g, EOL));
};

/**
 * check if given hostInfo object is OK
 * @param { Object } hostInfo - host information object defined in index.js
 */
const sanityCheck = (hostInfo) => {
  debug("sanityCheck called", hostInfo);

  // keep password, passphrase, and masterPty before validate.
  // these properties can have a iellegal value as JSON data
  // so we keep them before ajv validation and put them back

  const { password, passphrase, masterPty } = hostInfo;
  validate(hostInfo);

  if (validate !== null && Array.isArray(validate.errors)) {
    for (const e of validate.errors) {
      debug("validation error:", e);
      const prop = e.instancePath.replace(/^\//, "");
      if (e.keyword === "required" && e.params.missingProperty === "host") {
        throw new Error("host is required");
      }
      if (prop === "host") {
        if (hostInfo.host === "") {
          throw new Error("empty host is not allowed");
        }
        const err = new Error("invalid host specified");
        err.hostInfo = hostInfo;
        err.validationError = e;
        throw err;
      }
      if (prop.startsWith("sshOpt")) {
        debug("remove empty member of sshOpt");
        hostInfo.sshOpt = hostInfo.sshOpt.filter((opt) => {
          return opt !== "";
        });
        continue;
      } else if (stringOptions.includes(prop) && hostInfo[prop] === "") {
        debug("remove empty string option", prop, hostInfo[prop]);
        delete hostInfo[prop];
        continue;
      } else if (numberOptions.includes(prop) && ["minimum", "maximum"].includes(e.keyword)) {
        debug("remove out of range number option", prop, hostInfo[prop]);
        delete hostInfo[prop];
        continue;
      } else {
        const err = new Error(`invalid ${prop} specified ${hostInfo[prop]}`);
        err.hostInfo = hostInfo;
        err.validationError = e;
        throw err;
      }
    }
  }
  if (["string", "function"].includes(typeof password)) {
    hostInfo.password = password;
  }
  if (["string", "function"].includes(typeof passphrase)) {
    hostInfo.passphrase = passphrase;
  }
  if (typeof masterPty !== "undefined") {
    hostInfo.masterPty = masterPty;
  }
  return hostInfo;
};

function getControlPersistDir (hostInfo) {
  for (const candidate of [hostInfo.ControlPersistDir, process.env.SSH_CONTROL_PERSIST_DIR]) {
    if (typeof candidate === "string") {
      const stat = fs.statSync(candidate, { throwIfNoEntry: false });
      if (stat.isDirectory()) {
        return candidate;
      }
    }
  }
  return "~/.ssh";
}

/**
 * return ssh option with some hard corded options
 * @param { Object} hostInfo - host information object defined in index.js
 * @param {boolean} withoutDestination - return except for hostname
 */
const getSshOption = (hostInfo, withoutDestination) => {
  debug("getSshOption called");
  const args = [];

  if (!withoutDestination) {
    args.push(hostInfo.host);
  }
  if (typeof hostInfo.sshopt === "string") {
    args.push(hostInfo.sshopt);
  }
  if (typeof hostInfo.user === "string") {
    args.push("-l");
    args.push(hostInfo.user);
  } else if (typeof hostInfo.username === "string") {
    args.push("-l");
    args.push(hostInfo.username);
  } else if (typeof hostInfo.loginname === "string") {
    args.push("-l");
    args.push(hostInfo.loginname);
  }
  if (typeof hostInfo.port === "string" || Number.isInteger(hostInfo.port)) {
    args.push("-p");
    args.push(hostInfo.port);
  }
  if (typeof hostInfo.keyFile === "string") {
    try {
      const stats = fs.statSync(hostInfo.keyFile);
      if (stats.isFile()) {
        args.push("-i");
        args.push(hostInfo.keyFile);
      } else {
        debug(`specified keyFile(${hostInfo.keyFile}) is not file. so it is ignored`);
      }
    } catch (e) {
      if (e.code !== "ENOENT") {
        throw e;
      }
      debug(`specified keyFile(${hostInfo.keyFile}) not found and ignored`);
    }
  }

  if (hostInfo.noStrictHostkeyChecking) {
    args.push("-oStrictHostKeyChecking=no");
  }
  const controlPersist = Number.isInteger(hostInfo.ControlPersist) && hostInfo.ControlPersist >= 0 ? hostInfo.ControlPersist : "180";
  const controlPersistDir = getControlPersistDir(hostInfo);
  args.push("-oControlMaster=auto");
  args.push(`-oControlPath=${controlPersistDir}/ssh-client-wrapper-%r@%h:%p`);
  args.push(`-oControlPersist=${controlPersist}`);

  if (Number.isInteger(hostInfo.ConnectTimeout) && hostInfo.ConnectTimeout > 0) {
    args.push(`-oConnectTimeout=${hostInfo.ConnectTimeout}`);
  }
  if (Array.isArray(hostInfo.sshOpt)) {
    args.push(...hostInfo.sshOpt.filter((e) => {
      return typeof e === "string";
    }));
  }
  return args;
};

const isArrayOfString = (target) => {
  if (!Array.isArray(target)) {
    return false;
  }
  return !target.some((e) => {
    // target has non string member
    return typeof e !== "string";
  });
};

/**
 * log and send message to pty
 * @param {Object} pty - pty object which message will be send to
 * @param {string} message - message
 * @param {Function} logger - logging function
 * @param {string} label - where this function is called
 */
const sendPty = async (pty, message, logger, label) => {
  logger(`${label}: ${message}`);
  pty.write(message);
};

/**
 * set watch dog timer
 * @param {number} timeout - timeout (in sec.)
 * @param {string} label - used in both timeout and aborted message
 * @param {Function} cb - call back function which will be called after timeout
 * @returns {AbortController} - ac to stop watch dog timer
 */
const watchDogTimer = (timeout, label, cb) => {
  const ac = new AbortController();
  const signal = ac.signal;
  setTimeoutPromise(timeout * 1000, "neverUsedValue", { signal })
    .then(() => {
      debug(`can not finish ${label} within ${timeout} sec`);
      return typeof cb === "function" ? cb() : true;
    })
    .catch((err) => {
      if (err.name === "AbortError") {
        debug(`timeout was aborted because ${label} has finished or canceled`);
      } else {
        throw err;
      }
    });
  return ac;
};

module.exports = {
  sshCmd,
  rsyncCmd,
  acceptableRsyncRetrunCodes,
  rePwPrompt,
  rePhPrompt,
  reNewHostPrompt,
  buff2String,
  getSshOption,
  sanityCheck,
  isArrayOfString,
  sendPty,
  watchDogTimer
};