~/2026/02/11/node-js-discovery-scanning-a-subnet-for-open-rdp-and-ssh.md

Node.js: Discovery – Scanning a Subnet for Open RDP and SSH

---
author: 
date: 
read: 3 min
in:   [scripts, engineering]
tags: [nodejs, discovery, network, ssh]
---

$ grep -n '^#' post.md

Before I trust a network diagram, I like to check it against reality, and the fastest reality check I know is "what's actually listening." Nmap is the right tool for a serious scan, but when I just want a quick answer to "which hosts on this /24 have RDP or SSH open" from inside a script or a CI job without pulling in a system dependency, a plain Node.js TCP connect scan does the job with nothing beyond the standard library.

It's the routed-subnet counterpart to the Python ARP fingerprinting script (ARP only sees your own VLAN; a TCP connect works across routers) and one of the collectors in the discovery-first approach to documenting an undocumented network.

Requirements

  • A supported Node.js LTS release (22 or later). Only the built-in net and fs modules are used; no npm install.
  • Network reachability from wherever the script runs to the target subnet. No root needed: a TCP connect scan uses the operating system's normal connect() call, the same thing nmap falls back to (-sT) when it lacks raw-packet privileges.
  • Written authorization to scan the target range. It's a plain connect scan, not a stealth technique, but scanning a network you don't own or manage without permission is not okay regardless of how the scan is implemented.

Usage

Scan a /24 for the default ports (22 and 3389):

bash
node scan-subnet.js 10.0.10.0/24

Scan a smaller range for a custom port list (adding WinRM on 5985) with a longer per-connection timeout for a slow WAN link, and write every result, not just open ones, to CSV:

bash
node scan-subnet.js 10.0.10.0/28 --ports 22,3389,5985 --timeout 1500 --concurrency 50 --all --csv results.csv

Sample output from the default run:

text
Scanning 10.0.10.0/24 (254 host(s)) for ports [22, 3389]
Concurrency: 100, timeout: 500ms per attempt

10.0.10.12     22/tcp open     (ssh)  SSH-2.0-OpenSSH_9.6
10.0.10.45     3389/tcp open     (rdp)
10.0.10.201    22/tcp open     (ssh)  SSH-2.0-OpenSSH_8.7
10.0.10.201    3389/tcp open     (rdp)

Scan complete in 4.8s. 3 host(s) with at least one open port out of 254 scanned.
States: open 4, closed 61, filtered 443

The last line is the useful part for an exposure check. Closed means the host answered with a TCP reset (it's alive, nothing listening). Filtered means no answer before the timeout (no host there, or a firewall silently dropping the connection) or any other connection error, such as host or network unreachable.

Script

javascript
/**
 * scan-subnet.js
 *
 * Dependency-free TCP connect scanner for a small set of ports (SSH/RDP by default)
 * across every usable host in an IPv4 CIDR range. Classifies each attempt as open,
 * closed (ECONNREFUSED) or filtered (timeout / unreachable), and records the SSH
 * identification string where the server sends one.
 *
 * Reads: command-line arguments only.
 * Writes: stdout, and a CSV file when --csv is given.
 *
 * Usage:
 *   node scan-subnet.js <cidr> [--ports 22,3389] [--timeout 500] [--concurrency 100] [--all] [--csv out.csv]
 *
 * Only run this against networks you are authorized to scan.
 */

'use strict';

const fs = require('fs');
const net = require('net');

const SERVICE_NAMES = { 22: 'ssh', 3389: 'rdp', 5985: 'winrm-http', 5986: 'winrm-https' };
const BANNER_PORTS = new Set([22]);

function parseArgs(argv) {
    const args = { cidr: null, ports: [22, 3389], timeout: 500, concurrency: 100, all: false, csv: null };

    for (let i = 0; i < argv.length; i++) {
        const arg = argv[i];
        if (arg === '--ports') {
            args.ports = argv[++i].split(',').map((p) => parseInt(p.trim(), 10));
        } else if (arg === '--timeout') {
            args.timeout = parseInt(argv[++i], 10);
        } else if (arg === '--concurrency') {
            args.concurrency = parseInt(argv[++i], 10);
        } else if (arg === '--csv') {
            args.csv = argv[++i];
        } else if (arg === '--all') {
            args.all = true;
        } else if (!args.cidr) {
            args.cidr = arg;
        }
    }

    if (!args.cidr) {
        throw new Error('Usage: node scan-subnet.js <cidr> [--ports 22,3389] [--timeout 500] [--concurrency 100] [--all] [--csv out.csv]');
    }
    if (args.ports.some((p) => !Number.isInteger(p) || p < 1 || p > 65535)) {
        throw new Error('Ports must be integers between 1 and 65535.');
    }
    if (!Number.isInteger(args.timeout) || args.timeout < 1 || !Number.isInteger(args.concurrency) || args.concurrency < 1) {
        throw new Error('--timeout and --concurrency must be positive integers.');
    }
    return args;
}

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

function intToIp(int) {
    return [24, 16, 8, 0].map((shift) => (int >>> shift) & 255).join('.');
}

function expandCidr(cidr) {
    const [base, prefixStr] = cidr.split('/');
    const prefix = parseInt(prefixStr ?? '32', 10);

    if (!net.isIPv4(base)) {
        throw new Error(`Not an IPv4 address: ${base}`);
    }
    if (!Number.isInteger(prefix) || prefix < 16 || prefix > 32) {
        throw new Error(`Prefix /${prefixStr} out of range: use /16 to /32.`);
    }

    const size = 2 ** (32 - prefix);
    const network = (ipToInt(base) & ((0xffffffff << (32 - prefix)) >>> 0)) >>> 0;

    // /31 and /32 have no network/broadcast addresses to skip (RFC 3021 for /31).
    const first = prefix >= 31 ? 0 : 1;
    const last = prefix >= 31 ? size - 1 : size - 2;

    const ips = [];
    for (let i = first; i <= last; i++) {
        ips.push(intToIp((network + i) >>> 0));
    }
    return ips;
}

function checkPort(host, port, timeoutMs) {
    return new Promise((resolve) => {
        const socket = new net.Socket();
        let state = null;
        let banner = '';

        const finish = (result) => {
            if (state) {
                return;
            }
            state = result;
            // A 'timeout' event does not close the socket by itself; destroy() does.
            socket.destroy();
            resolve({ host, port, state, banner });
        };

        socket.setTimeout(timeoutMs);
        socket.once('connect', () => {
            if (!BANNER_PORTS.has(port)) {
                finish('open');
                return;
            }
            // Both sides send an identification string (RFC 4253 section 4.2); servers
            // normally send theirs without waiting. Buffer until the SSH- line is complete,
            // since it can be split across chunks or follow other lines.
            let received = '';
            socket.on('data', (chunk) => {
                received += chunk.toString('utf8');
                const lines = received.split('\n');
                const line = lines.slice(0, -1).find((l) => l.startsWith('SSH-'));
                if (line || received.length > 8192) {
                    banner = line ? line.trim() : '';
                    finish('open');
                }
            });
            // The server may accept and then hang up without sending anything.
            socket.once('close', () => finish('open'));
        });
        socket.once('timeout', () => finish(socket.connecting ? 'filtered' : 'open'));
        socket.once('error', (error) => finish(error.code === 'ECONNREFUSED' ? 'closed' : 'filtered'));

        socket.connect(port, host);
    });
}

async function asyncPool(limit, items, iteratorFn) {
    const results = [];
    const executing = new Set();

    for (const item of items) {
        const promise = Promise.resolve().then(() => iteratorFn(item));
        results.push(promise);
        executing.add(promise);
        const cleanup = () => executing.delete(promise);
        promise.then(cleanup, cleanup);
        if (executing.size >= limit) {
            await Promise.race(executing);
        }
    }
    return Promise.all(results);
}

function csvEscape(value) {
    const text = String(value ?? '');
    return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}

async function main() {
    const args = parseArgs(process.argv.slice(2));
    const hosts = expandCidr(args.cidr);

    console.log(`Scanning ${args.cidr} (${hosts.length} host(s)) for ports [${args.ports.join(', ')}]`);
    console.log(`Concurrency: ${args.concurrency}, timeout: ${args.timeout}ms per attempt`);
    console.log('');

    const startTime = Date.now();
    const targets = hosts.flatMap((host) => args.ports.map((port) => ({ host, port })));

    const results = await asyncPool(args.concurrency, targets, async ({ host, port }) => {
        const result = await checkPort(host, port, args.timeout);
        if (result.state === 'open' || args.all) {
            const service = SERVICE_NAMES[port] ? `(${SERVICE_NAMES[port]})` : '';
            console.log(`${host.padEnd(15)}${`${port}/tcp`} ${result.state.padEnd(8)} ${service}  ${result.banner}`.trimEnd());
        }
        return result;
    });

    const counts = { open: 0, closed: 0, filtered: 0 };
    const openHosts = new Set();
    for (const result of results) {
        counts[result.state]++;
        if (result.state === 'open') {
            openHosts.add(result.host);
        }
    }

    if (args.csv) {
        const rows = (args.all ? results : results.filter((r) => r.state === 'open'))
            .map((r) => [r.host, r.port, SERVICE_NAMES[r.port] ?? '', r.state, r.banner].map(csvEscape).join(','));
        fs.writeFileSync(args.csv, ['ip_address,port,service,state,banner', ...rows].join('\n') + '\n', 'utf8');
    }

    const elapsedSeconds = ((Date.now() - startTime) / 1000).toFixed(1);
    console.log('');
    console.log(`Scan complete in ${elapsedSeconds}s. ${openHosts.size} host(s) with at least one open port out of ${hosts.length} scanned.`);
    console.log(`States: open ${counts.open}, closed ${counts.closed}, filtered ${counts.filtered}`);
}

main().catch((error) => {
    console.error(error.message);
    process.exitCode = 1;
});

Notes

  • What changed from the first version. The earlier draft's sample output listed closed lines the script never printed, it scanned the network and broadcast addresses, it accepted any prefix down to /0 (four billion targets), and it lumped refused and timed-out connections together. It now skips network/broadcast on /30 and larger, refuses anything bigger than a /16, and reports closed and filtered separately.
  • Timeouts need an explicit destroy. Node's socket.setTimeout() only emits a 'timeout' event when the socket goes idle; it does not sever the connection. The finish() helper calls socket.destroy() on every path so pending sockets don't pile up at high concurrency.
  • Filtered is ambiguous by design. A timeout could be an empty address, a host firewall, or a network ACL. If you need to tell those apart, compare against an ARP-based sweep of the same VLAN or DHCP leases.
  • SSH banners come for free because servers normally send their identification string without waiting for the client (RFC 4253 has both sides send one), so the script collects it passively, best effort; they identify the implementation and version (SSH-2.0-OpenSSH_9.6), which is enough to spot an appliance running an old build. The spec allows a server to send other lines before the SSH- line, so the script buffers input until a complete line starting with SSH- arrives rather than taking the first line. RDP doesn't volunteer anything without a protocol handshake; use nmap -sV -p 3389 when you need the service confirmed.
  • Tune for the path. Concurrency of 100 against a local /24 finishes in a few seconds. Across a WAN link or a firewall that rate-limits new connections, raise --timeout and lower --concurrency, or you'll get false "filtered" results from connections still pending when the timer fired.
  • Detectable on purpose. This completes the TCP handshake and closes it. It will show up in firewall and IDS logs, which is what you want for a sanctioned inventory sweep.
  • To track changes over time, run nmap with -oX on a schedule and compare runs with the scan diff script.

References