使用Node.js编写的命令行端口扫描器,功能完整,附带英文注解和使用说明

发布时间 2023-03-25 14:38:25作者: 项希盛

这是我花了一整天的时间写的node.js命令行扫描器,500多行代码,功能完整,附带英文注解和使用说明,可以拿来直接用。

const validOptions = [
  {
    "name": "ports",
    "cmd": ["-p", "--ports"],
    "value": "21-23,25,80,443,445,1433,3306,3389",
    "min": 1,
    "max": 65535,
    "type": "range",
    "description": "The port or range of ports to scan (e.g. '1-1024,1433,3389').",
    "description_zh": "要扫描的端口或端口范围(例如:'1-1024,1433,3389')"
  },
  {
    "name": "timeout",
    "cmd": ["-t", "--timeout"],
    "value": 1000,
    "type": "number",
    "description": "The timeout for each port scan, in milliseconds.",
    "description_zh": "每个端口扫描的超时时间(毫秒)"
  },
  {
    "name": "maxQueueSize",
    "cmd": ["-q", "--max-queue-size"],
    "value": 100,
    "type": "number",
    "description": "The maximum size of the scan queue.",
    "description_zh": "扫描队列的最大大小"
  },
  {
    "name": "showAll",
    "cmd": ["-a", "--show-all"],
    "value": false,
    "type": "bool",
    "description": "Show all ports, including closed and timed out ports.",
    "description_zh": "显示全部端口,包含关闭和超时的端口。"
  },
  {
    "name": "help",
    "cmd": ["-h", "--help"],
    "value": false,
    "type": "bool",
    "description": "Show usage information.",
    "description_zh": "显示使用说明。"
  }
];

/**
 * This function takes an option object and an argument and returns the value of the option based on its type and constraints.
 * @param {Object} option - An option object with properties "name", "type", "min", and "max".
 * @param {string} arg - The argument to be parsed and validated.
 * @returns {*} - The parsed and validated value of the option based on its type and constraints.
 */
function getOptionValue(option, arg) {
  if (option.type === "bool") {
    return arg;
  }

  if (option.type === "number") {
    const value = parseInt(arg, 10);
    if (isNaN(value)) {
      throw new Error(`Invalid value for option '${option.name}': '${arg}'`);
    }
    if (option.min && value < option.min) {
      throw new Error(`Value for option '${option.name}' must be greater than or equal to ${option.min}`);
    }
    if (option.max && value > option.max) {
      throw new Error(`Value for option '${option.name}' must be less than or equal to ${option.max}`);
    }
    return value;
  }

  if (option.type === "range") {
    const result = [];
    const ranges = arg.split(",");
    for (let range of ranges) {
      range = range.trim();
      const parts = range.split("-");
      if (parts.length === 1) {
        // Single port
        const value = parseInt(parts[0], 10);
        if (isNaN(value)) {
          throw new Error(`Invalid value for option '${option.name}': '${range}'`);
        }
        if (option.min && value < option.min) {
          throw new Error(`Value for option '${option.name}' must be greater than or equal to ${option.min}`);
        }
        if (option.max && value > option.max) {
          throw new Error(`Value for option '${option.name}' must be less than or equal to ${option.max}`);
        }
        result.push(value);
      } else if (parts.length === 2) {
        // Port range
        const start = parseInt(parts[0], 10);
        const end = parseInt(parts[1], 10);
        if (isNaN(start) || isNaN(end)) {
          throw new Error(`Invalid value for option '${option.name}': '${range}'`);
        }
        if (option.min && start < option.min) {
          throw new Error(`Value for option '${option.name}' must be greater than or equal to ${option.min}`);
        }
        if (option.max && end > option.max) {
          throw new Error(`Value for option '${option.name}' must be less than or equal to ${option.max}`);
        }
        if (start > end) {
          throw new Error(`Invalid range for option '${option.name}': '${range}'`);
        }
        for (let i = start; i <= end; i++) {
          result.push(i);
        }
      } else {
        throw new Error(`Invalid value for option '${option.name}': '${range}'`);
      }
    }
    return result;
  }
}

/**
 * Async function that gets a list of IP addresses from a given host name, domain name or CIDR subnet.
 * @param {string} host - The host to be checked, which can be an IPv4 address, host name, domain name or CIDR subnet.
 * @returns {Promise<array>} - returns an array of IP addresses. Otherwise, throws an error.
 * @throws {Error} - Throws an error if the input is invalid or DNS resolution fails.
 */
async function getIpList(host) {
  const dns = require('dns');

  /**
   * Check if the provided string is a valid host name.
   * @param {string} host - The host name to be checked.
   * @returns {boolean} Returns true if the string is a valid host name, otherwise false.
   */
  function isHostName(host) {
    const p = /^([a-z0-9\-]+)[\.]?$/i;
    return p.test(host);
  }

  /**
   * Check if the given string is a valid domain name format.
   * @param {string} host The domain name string to be checked.
   * @returns {boolean} Whether the given string is a valid domain name format.
   */
  function isDomainName(host) {
    const p = /^([a-z0-9\-]+\.)+([a-z]{2,})+[\.]?$/i;
    return p.test(host);
  }

  /**
   * Check if the given string is a valid IPv4 address
   * @param {string} host - the string to be checked
   * @returns {boolean} - true if the string is a valid IPv4 address, false otherwise
   */
  function isIPv4Addr(host) {
    const p = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
    return p.test(host);
  }

  /**
   * Check if the given host is a valid CIDR notation of an IPv4 address.
   * @param {string} host - The host to check in CIDR notation (e.g. '192.168.0.1/24').
   * @returns {false|array} - If valid, returns an array with two elements: the IPv4 address and the CIDR number. Otherwise, returns false.
   * @throws {Error} - Throws an error if the CIDR mask is less than 10.
  */
  function isCIDR(host) {
    const aHost = host.split('/');
    const cidr = Number(aHost[1]);
    if (isNaN(cidr)) {
      return false;
    }
    if (cidr > 32 || cidr < 0) {
      return false;
    }
    if (!isIPv4Addr(aHost[0])) {
      return false;
    }
    if (cidr < 10) {
      throw new Error("The CIDR mask cannot be less than 10");
    }
    return [aHost[0], cidr];
  }

  /**
   * Generate a list of IPs within a CIDR range.
   * @param {String} ip The IP address.
   * @param {Number} cidr The CIDR prefix.
   * @returns {Array} The list of IP addresses within the CIDR range.
   */
  function generateIpRangeByCIDR(ip, cidr) {
    const mask = (0xFFFFFFFF << (32 - cidr)) >>> 0;
    const start = ip & mask;
    const end = start + (1 << (32 - cidr));
    const ips = [];
    for (let i = start; i < end; i++) {
      ips.push(intToIP(i));
    }
    return ips;
  }

  function ipToInt(ip) {
    return ip.split(".").reduce((acc, cur) => (acc << 8) + parseInt(cur), 0) >>> 0;
  }

  function intToIP(n) {
    return [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff].join(".");
  }

  // step 1: If the host is an IPv4 address, return it directly
  if (isIPv4Addr(host)) {
    return [host];
  }

  // step 2: If it's a name name, return it directly
  if (isHostName(host)) {
    return [host];
  }

  // step 3: If it's a domain name, resolve DNS
  try {
    if (isDomainName(host)) {
      const ipList = await dns.promises.resolve4(host);
      return ipList;
    }
  } catch (err) {
    err.message = `DNS resolution failed: ${err.message}`;
    throw err;
  }

  // step 4: If it's a CIDR, convert it to IP range.
  const cidr = isCIDR(host);
  if (cidr) {
    return generateIpRangeByCIDR(ipToInt(cidr[0]), cidr[1]);
  }

  // step 5: for else, throw error.
  throw new Error("Invalid host name. Please make sure you enter a valid IPv4 address, host name, domain name, or CIDR subnet.");
  return [host];
}

/**
 * Get command-line arguments.
 * @param {Array} validOptions Array of valid options.
 * @returns {Object} An object containing the specified options.
 */
async function getCommandLineArgs(validOptions) {
  // Get parameters from command line
  const args = process.argv.slice(2);

  // Build a map to facilitate finding command-line options
  const cmdOptionMap = {};
  for (const option of validOptions) {
    for (const cmd of option.cmd) {
      cmdOptionMap[cmd] = option;
    }
  }

  const param = {};
  const args2 = [];
  for (const arg of args) {
    const argi = arg.indexOf('=');
    var arg0 = arg;
    var arg1 = true;
    if (argi > 0) {
      arg0 = arg.substr(0, argi);
      arg1 = arg.substr(argi + 1);
    }
    if (!cmdOptionMap.hasOwnProperty(arg0)) {
      args2.push(arg);
      continue;
    }
    const mOption = cmdOptionMap[arg0];
    if (argi === -1 && mOption.type !== 'bool') {
      throw new Error(`The argument ${arg} must be provided with a value in the format of ${arg}=value, for example ${arg}=${mOption.value}`);
    }
    param[arg0] = arg1;
  }

  // Host name is a required field
  const host = args2.shift();
  for (const arg of args2) {
    throw new Error(`The parameter ${arg} does not exist.`);
  }
  if (!host) {
    throw new Error("Please provide a IPv4 address, host name, domain name, or CIDR subnet.");
  }
  const result = {};
  result.ips = await getIpList(host);
  for (const option of validOptions) {
    var value = option.value;
    for (const arg0 of option.cmd) {
      // Find the matching option cmd
      if (param.hasOwnProperty(arg0)) {
        value = param[arg0];
        break;
      }
    }
    result[option.name] = getOptionValue(option, value);
  }
  return result;
}

/**
 * Generate a help text based on the valid options.
 *
 * @param {Array} validOptions - An array of valid options, each option being an object
 * with properties: name, cmd, value and description.
 * @returns {string} A formatted help text with valid options and descriptions.
 */
function generateHelpText(validOptions) {
  const helpText = [];
  let maxCmdLength = 0;
  validOptions.forEach(option => {
    // Find the longest command string length for formatting
    const cmdLength = option.cmd.join(', ').length;
    if (cmdLength > maxCmdLength) {
      maxCmdLength = cmdLength;
    }

    // Add the option to the help text
    helpText.push({
      name: option.name,
      cmd: option.cmd.join(', '),
      value: option.value,
      description: option.description
    });
  });

  // Generate the formatted help text
  const formattedText = [];
  helpText.forEach(option => {
    const cmdString = option.cmd.padEnd(maxCmdLength);
    const description = option.description.replace(/\n/g, '\n' + ' '.repeat(maxCmdLength + 3));
    formattedText.push(`  ${cmdString} ${description} Default: ${option.value}.\n`);
  });

  // Return the complete help text
  return `\nUsage: node tcp-scanner.js <host> [options]\n\nOptions:\n${formattedText.join('')}`;
}

/**
 * This function generates an array of objects, where each object contains an IP address and a port number to scan.
 * The function calculates the required scanning time based on the input parameters, and prompts the user for confirmation if the scanning time exceeds one minute.
 * @param {object} config - An object containing the following properties:
 *   ips: An array of IP addresses to scan.
 *   ports: An array of port numbers to scan.
 *   timeout: The timeout value for each scan in milliseconds.
 *   maxQueueSize: The maximum number of simultaneous scans.
 * @returns {Promise<Array>} - An array of objects containing an IP address and a port number to scan.
 */
async function generateScanList(config) {
  const { ips, ports, timeout, maxQueueSize } = config;
  const totalTargets = ips.length * ports.length;
  console.log(`Scanning ${ips.length} hosts and ${ports.length} ports (Total targets: ${totalTargets})`);
  var scanTime = totalTargets * timeout / maxQueueSize;
  scanTime += totalTargets * 5;
  if (scanTime > 1000) {
    console.log(`May take up to ${formatDuration(scanTime)}.`);
  }
  const msg = `Are you sure Continue (y/N)? `;
  const scanTimeInMinutes = scanTime / 1000 / 60;
  if (scanTimeInMinutes > 1 && !(await promptForContinuation(msg))) {
    return [];
  }
  const scanList = [];
  for (const host of ips) {
    for (const port of ports) {
      scanList.push({ host, port });
    }
  }
  return scanList;
}

/**
 * This function formats a duration in milliseconds to a human-readable string.
 * @param {number} duration - The duration in milliseconds.
 * @returns {string} - The formatted duration.
 */
function formatDuration(duration) {
  const seconds = Math.floor(duration / 1000);
  const minutes = Math.floor(seconds / 60);
  const hours = Math.floor(minutes / 60);
  const days = Math.floor(hours / 24);
  const parts = [];
  if (days > 0) {
    parts.push(`${days} day${days === 1 ? '' : 's'}`);
  }
  if (hours % 24 > 0) {
    parts.push(`${hours % 24} hour${hours % 24 === 1 ? '' : 's'}`);
  }
  if (minutes % 60 > 0) {
    parts.push(`${minutes % 60} minute${minutes % 60 === 1 ? '' : 's'}`);
  }
  if (seconds % 60 > 0) {
    parts.push(`${seconds % 60} second${seconds % 60 === 1 ? '' : 's'}`);
  }
  return parts.join(' ');
}

/**
 * Asks the user if they want to continue scanning.
 * @param {String} message - The message to prompt the user.
 * @returns {Boolean} Whether or not the user wants to continue scanning.
 */
async function promptForContinuation(message) {
  while (true) {
    const confirmation = await getUserConfirmation(message);
    if (!confirmation) {
      continue;
    }
    return confirmation.ret;
  }
}

/**
 * This function prompts the user for confirmation and returns a boolean value indicating whether the user confirmed or not.
 * @param {string} message - The confirmation message to display to the user.
 * @returns {Promise<boolean>} - A boolean value indicating whether the user confirmed or not.
 */
function getUserConfirmation(message) {
  return new Promise((resolve) => {
    const readline = require('readline');
    const rl = readline.createInterface({
      input: process.stdin,
      output: process.stdout
    });
    rl.question(message, (answer) => {
      rl.close();
      const a = answer.toLowerCase();
      if (a === '') {
        return resolve({ret: false});
      }
      if (a === 'y') {
        return resolve({ret: true});
      }
      if (a === 'n') {
        return resolve({ret: false});
      }
      return resolve();
    });
  });
}

/**
 * Scan for open ports on the given targets.
 *
 * @param {Object} options - The scan options.
 * @param {Array} options.targets - An array of targets to scan.
 * @param {number} options.timeout - The timeout for each connection attempt, in milliseconds.
 * @param {number} options.maxQueueSize - The maximum number of targets that can be in the scan queue at once.
 * @param {boolean} options.showAll - Whether to show all errors, including timeouts and connection errors.
 * @param {Function} onResult - A callback function to handle scan results.
 */
function scanPorts({ targets, timeout, maxQueueSize, showAll }, onResult = () => {}) {
  const net = require('net');
  const queue = [...targets]; // Make a copy of the targets array as the scan queue.
  const openPorts = {}; // Record open ports.
  const startTime = Date.now(); // Record start time
  /**
   * Returns a promise that resolves when the connection is successful or rejects when it times out or encounters an error.
   *
   * @param {Object} target - The target to connect to.
   * @param {string} target.host - The target's hostname or IP address.
   * @param {number} target.port - The target's port number.
   * @returns {Promise<Object>} A promise that resolves to the target object if the connection is successful, or rejects with an error message if it times out or encounters an error.
   */
  function connect({ host, port }) {
    return new Promise((resolve, reject) => {
      const socket = net.createConnection({ host, port }, () => {
        // Close the socket after a successful connection.
        socket.end();
        // Resolve with the target object.
        resolve({ host, port });
      });
      socket.setTimeout(timeout, () => {
        // Destroy the socket after a timeout.
        socket.destroy();
        // Reject with a timeout error message.
        reject(`[timeout] ${host}:${port}`);
      });
      socket.on('error', (err) => {
        // Reject with a connection error message.
        reject(`[${err.code}] ${host}:${port}`);
      });
    });
  }

  let queueLength = 0;

  /**
   * Dequeues a target from the scan queue and attempts to connect to it.
   */
  function dequeue() {
    if (queueLength === 0 && queue.length === 0) {
      // If the scan queue is empty, output the scan results.
      const endTime = Date.now();
      // Calculate scan time in seconds
      const scanTime = (endTime - startTime) / 1000;
      onResult(`Scan complete! Open ports:`);
      onResult(JSON.stringify(openPorts));
      onResult(`Scan time: ${scanTime} seconds.`);
      return;
    }
    if (queueLength >= maxQueueSize) {
      // If the scan queue is full, wait and try again.
      setTimeout(dequeue, 100);
      return;
    }
    // Dequeue a target.
    const target = queue.shift();
    if (!target) {
      // If there are no targets left, wait and try again.
      setTimeout(dequeue, 1000);
      return;
    }
    // Schedule the next dequeue operation.
    setTimeout(dequeue, 1);
    queueLength++;
    connect(target)
      .then((mTarget) => {
        queueLength--;
        if (!openPorts.hasOwnProperty(mTarget.host)) {
          openPorts[mTarget.host] = [];
        }
        openPorts[mTarget.host].push(mTarget.port);
        onResult(`[open] ${mTarget.host}:${mTarget.port}`);
      })
      .catch((err) => {
        queueLength--;
        if (showAll) {
          onResult(`${err}`);
        }
      });
  }

  dequeue();
}

async function main() {
  var config;
  try {
    config = await getCommandLineArgs(validOptions);
  } catch(e) {
    console.log(e.toString());
    console.log(generateHelpText(validOptions));
    return;
  }
  config.targets = await generateScanList(config);
  scanPorts(config, console.log);
}
main();

保存成tcp-scanner.js即可使用,工具内置使用帮助和指令说明。