Jesse A. Green / notebook

Live demo: the DocCompiler guide library as a static, read-only snapshot (October 2026). No backend, no accounts, nothing to submit.

RustScan Administrator & Developer Guide

Generated April 2026 from bee-san/RustScan at b033798

AI-generated documentation. The library is checked by sampling (three repositories so far); this guide was not individually reviewed. Verify against the project before relying on it.

This guide is the source-based reference for the architecture, operational posture, security boundaries, configuration surface, and developer internals of RustScan (crate rustscan, version 2.4.1, GPL-3.0-only, Rust edition 2018). It is written for the people who deploy RustScan in environments other than "one analyst on a laptop":

  • Security engineers wiring RustScan into pipelines and SIEMs.
  • Pentest-platform admins who containerize it for a team.
  • Rust developers building on top of the public rustscan library API.
  • Anyone who needs to know exactly what RustScan does to a network before approving it for production use.

Refer to the User Guide for everyday CLI usage; this document does not duplicate that material.


1. Architecture Overview

RustScan is a single-binary CLI that performs three things in sequence:

  1. Resolve the user's target list (IPs, CIDRs, hostnames, files) into a deduplicated Vec<IpAddr>.
  2. Generate a port set from one of three strategies (Manual list, Serial range, Random range), then schedule async TCP connect() (or UDP) probes through a fixed-size pool.
  3. Hand off the open-port set to a downstream "script" — by default, an Nmap invocation; optionally a custom shell command parameterised on the discovered IP/port pairs.

The compiled binary is statically linked, ships with an embedded UDP-payload database (built at compile time from nmap-payloads), and depends only on standard async I/O — no privileged operations are required for the default TCP connect mode.

1.1 Component diagram

                           rustscan (single binary)
                                 │
        ┌────────────────────────┼────────────────────────┐
        │                        │                        │
   ┌────▼────┐            ┌──────▼──────┐          ┌──────▼──────┐
   │  Opts   │            │   Config    │          │  Scripts    │
   │ (CLAP)  │── merge ──▶│ (~/.toml)   │          │  registry   │
   └────┬────┘            └─────────────┘          └──────┬──────┘
        │                                                 │
        │                                                 │
        ▼                                                 │
  ┌─────────────┐    ┌─────────────────────┐              │
  │ address.rs  │───▶│  Vec<IpAddr>        │              │
  │ parse_*     │    │  (deduped, excluded)│              │
  └─────────────┘    └──────────┬──────────┘              │
                                │                         │
                     ┌──────────▼──────────┐              │
                     │  PortStrategy       │              │
                     │  Manual / Serial /  │              │
                     │  Random (LCG)       │              │
                     └──────────┬──────────┘              │
                                │                         │
                     ┌──────────▼──────────┐              │
                     │  SocketIterator     │              │
                     │  IP × Port product  │              │
                     └──────────┬──────────┘              │
                                │                         │
                     ┌──────────▼──────────┐              │
                     │  Scanner            │              │
                     │  FuturesUnordered   │              │
                     │  pool (batch_size)  │              │
                     │  TCP connect / UDP  │              │
                     └──────────┬──────────┘              │
                                │                         │
                     ┌──────────▼──────────┐    ┌─────────▼───────────┐
                     │ Vec<SocketAddr>     │───▶│ Default script      │
                     │ (open sockets)      │    │ → nmap -Pn -vvv -p X│
                     └─────────────────────┘    │ Custom script       │
                                                │ → user TOML defs    │
                                                │ None → just print   │
                                                └─────────────────────┘

Note

The pool is asynchronous but not multithreaded by default. Concurrency is via FuturesUnordered over async-std's single-runtime; the kernel does the heavy lifting through non-blocking sockets. That's why batch size is a function of file descriptors, not CPU cores.

1.2 Component inventory

Component Path Role
Entry point src/main.rs Opts::read() → Config::read() → merge → Scanner::new() → block_on(scanner.run()) → script handoff.
Address parser src/address.rs parse_addresses / parse_address / parse_excluded_networks; DNS via hickory-resolver with Cloudflare-DoT fallback.
Input model src/input.rs Opts (CLAP-derived), Config (TOML-deserialized), ScanOrder, ScriptsRequired, PortRange.
Port strategy src/port_strategy/mod.rs PortStrategy::pick() returns Manual / Serial / Random.
LCG iterator src/port_strategy/range_iterator.rs RangeIterator — Linear Congruential Generator with coprime step.
Scanner core src/scanner/mod.rs Scanner::new, Scanner::run, scan_socket, scan_udp_socket, connect, udp_bind, udp_scan, fmt_ports.
Socket iterator src/scanner/socket_iterator.rs Cartesian product over (ports, ips) with the inner loop on IPs.
Scripts src/scripts/mod.rs init_scripts, Script::build, Script::run, ScriptFile.
TUI macros src/tui.rs warning!, detail!, output!, funny_opening! — all greppable/accessible-aware.
Generated payloads src/generated.rs (built) Compile-time UDP payload BTreeMap<Vec<u16>, Vec<u8>>.
Build script build.rs Parses nmap-payloads and writes src/generated.rs.
Embedded top-1000 config.toml The default [ports] table activated by --top.
Bench harness benches/benchmark_portscan.rs Criterion harness; not shipped with the binary.

1.3 Technology stack

Layer Choice Why
Async runtime async-std 1.13.x, futures 0.3 block_on + FuturesUnordered give a fixed-size, runtime-cheap concurrency pool.
CLI parsing clap 4.6.x (derive + wrap_help) Declarative; the Opts struct is the help screen.
TOML config toml 1.1.x + serde 1.0.x (derive) Strict deserialization — typos abort the run instead of being ignored.
DNS hickory-resolver 0.24.x with dns-over-rustls System-resolver first, Cloudflare-DoT fallback, custom resolver via --resolver.
File-descriptor mgmt rlimit 0.11.x (Unix only) setrlimit(RLIMIT_NOFILE) for --ulimit, soft-limit query for auto-tuning.
RNG rand 0.10.x LCG seeding, port shuffling, funny-quote selection.
Output color colored 3.1.x, colorful 0.3.x, ansi_term 0.12.x, anstream =1.0.0 Cross-platform ANSI, including Windows post enable_ansi_support().
CIDR math cidr-utils 0.6.x CIDR expansion and exclusion.
Iterator product itertools 0.14.x iproduct! macro powers SocketIterator.
GCD gcd 2.0.x LCG coprime step search.
Templating text_placeholder 0.5.x {{ip}}/{{port}} substitution in custom-script call_format.
Logging log 0.4.x + env_logger 0.11.x RUST_LOG=debug toggles per-socket diagnostics.

1.4 Data flow for a single TCP scan

$ rustscan -a 10.0.0.0/24 -p 80,443 -- -A
       │
       │  (1) Opts::read()  → CLAP parses argv
       │  (2) Config::read() → TOML at $XDG_CONFIG_HOME/.rustscan.toml
       │  (3) opts.merge(&config) → CLI wins, missing fields filled from TOML
       ▼
   parse_addresses(&opts)
       ├─▶ each value: IPAddr::from_str → IpInet::from_str → DNS → file fallback
       ├─▶ exclude_addresses: same parsing, kept as Vec<IpCidr>
       └─▶ dedup via BTreeSet, drop excluded
       ▼
   PortStrategy::pick(range, ports, scan_order)
       ├─▶ Manual(ports) if explicit -p
       ├─▶ Serial(SerialRange) if -r and Serial order
       └─▶ Random(RandomRange{LCG}) if -r and Random order
       ▼
   Scanner::new(...)
       ├─▶ batch_size auto-tuned via infer_batch_size(opts, ulimit) on Unix
       └─▶ tries clamped to NonZeroU8 (0 → 1)
       ▼
   block_on(scanner.run())
       ├─▶ ports = strategy.order().filter(|p| !exclude_ports.contains(p))
       ├─▶ SocketIterator::new(&ips, &ports)  (port outer loop, IP inner)
       ├─▶ FuturesUnordered with batch_size in-flight
       ├─▶ each future: connect(socket, timeout=opts.timeout) × tries
       └─▶ panics if "too many open files" hits the assert
       ▼
   Vec<SocketAddr> open_sockets
       │
       │  group by IP → HashMap<IpAddr, Vec<u16>>
       ▼
   For each IP:
       ├─▶ if greppable: println!("{ip} -> [{ports}]") and continue
       ├─▶ if scripts == None: same as greppable
       └─▶ for each ScriptFile in scripts_to_run:
                 Script::build → Script::run
                 (default = nmap -Pn -vvv -p $PORTS [user args])
       ▼
   benchmarks.summary()  printed with RUST_LOG=info

2. System Requirements

2.1 Compile-time

Item Minimum Recommended
Rust toolchain edition 2018 with crates.io access Latest stable (rustup default stable)
Cargo 1.x Pinned via rust-toolchain.toml if you need reproducible builds
nmap-payloads file Present at repo root for build.rs Track upstream Nmap releases
Disk for build ~250 MB target dir 1 GB if you also build benches

2.2 Run-time

Item Minimum Recommended Notes
OS Linux x86_64 / macOS / Windows / BSD Linux x86_64 Windows skips ulimit auto-tuning; uses AVERAGE_BATCH_SIZE = 3000 constant.
Kernel Any with non-blocking sockets Linux 5.x+ epoll is preferred; kqueue works on macOS/BSD.
File descriptors (nofile) ≥ 1024 ≥ 8000 The default batch size is 4500; below nofile=4500, RustScan auto-shrinks the batch.
RAM 50 MB 200 MB Memory grows linearly with batch_size.
Network Outbound TCP to scan targets Sufficient pps headroom Keep an eye on conntrack tables on stateful firewalls.
Companion: Nmap Required only if --scripts default (the default) Latest stable from your distro The .deb package declares it as a dependency.

Note

RustScan does not require root for default TCP connect() scans. Root is needed only if you want --ulimit to actually raise RLIMIT_NOFILE above the soft limit, or if you want Nmap (called by the default script) to do raw-packet operations like -sS.

2.3 Network-policy requirements

Direction Why Notes
Outbound TCP to scan targets Core scanning One full TCP handshake per open port × tries.
Outbound UDP to scan targets --udp mode only Sends payloads from the embedded nmap-payloads map.
Outbound DNS (53/udp, 853/tcp for DoT) Hostname resolution Falls back to Cloudflare DoT if /etc/resolv.conf fails.
No inbound traffic required RustScan is purely client-side Safe to run behind a NAT.

3. Configuration Reference

This is the complete, source-based configuration surface. Anything not in these tables is not a valid setting.

3.1 Files

File Format Default location Purpose
~/.config/.rustscan.toml (or $XDG_CONFIG_HOME/.rustscan.toml) TOML XDG-resolved Default option overrides, deserialized into struct Config.
~/.rustscan.toml TOML $HOME Backwards-compatible legacy path. Used if the XDG path doesn't exist.
--config-path CLI arg TOML user-supplied Highest-precedence config-file location.
~/.rustscan_scripts/*.toml (and .rustscan_scripts.toml) TOML $HOME Custom-script descriptors loaded when --scripts custom.
Build-time config.toml (in repo root) TOML embedded via --top Defines the top-1000 port list activated by --top.

3.2 CLI flags (complete)

Sourced from src/input.rs::Opts (CLAP derive macros). Defaults are the literal default_value strings in the source.

Short Long Type Default Description
-a --addresses Vec<String> (comma-delimited) [] List or file of CIDRs / IPs / hosts.
-p --ports Option<Vec<u16>> (comma-delimited) None Explicit port list.
-r --range Option<PortRange> (start-end) None (effective 1-65535) Inclusive port range. Conflicts with --ports.
-n --no-config bool false Ignore config file completely.
--no-banner bool false Suppress ASCII banner.
-c --config-path Option<PathBuf> XDG default Path to a TOML config file.
-g --greppable bool false Print IP -> [PORTS] only. Skips Nmap.
--accessible bool false Drop ASCII art / color (screen-reader mode).
--resolver Option<String> None Comma-list of DNS-resolver IPs, or path to a file.
-b --batch-size usize 4500 Concurrent socket attempts.
-t --timeout u32 (ms) 1500 Per-port wait.
--tries u8 1 Retries per socket. 0 is corrected to 1.
-u --ulimit Option<usize> None Auto-raise RLIMIT_NOFILE.
--scan-order enum serial / random (case-insensitive) serial Port iteration order.
--scripts enum none / default / custom (case-insensitive) default Script-engine mode.
--top bool false Use the top-1000 ports from your config's ports = [...].
(positional after --) command Vec<String> [] Extra args appended to the script call (e.g., Nmap flags).
-e --exclude-ports Option<Vec<u16>> (comma-delimited) None Ports to exclude.
-x --exclude-addresses Option<Vec<String>> (comma-delimited) None IPs/CIDRs/hosts to exclude.
--udp bool false UDP scan instead of TCP connect.

The version flag is auto-injected by CLAP and prints rustscan 2.4.1 (sourced from CARGO_PKG_VERSION).

3.3 Config-file keys (complete)

Each key is the field name on the Config struct in src/input.rs. All are optional; missing keys leave the CLI default in place.

Key TOML type CLI counterpart Notes
addresses Array<String> --addresses Default targets.
ports Array<u16> --ports Activated by --top even if -r was passed.
range Inline table {start, end} --range
greppable bool --greppable
accessible bool --accessible
batch_size usize --batch-size
timeout u32 --timeout
tries u8 --tries
ulimit usize --ulimit
resolver String --resolver
scan_order String ("Serial"/"Random") --scan-order
command Array<String> trailing -- args
scripts String ("None"/"Default"/"Custom") --scripts
exclude_ports Array<u16> --exclude-ports
exclude_addresses Array<String> --exclude-addresses
udp bool --udp
no_banner bool --no-banner

Important

Any TOML parse error aborts the run with Found {error} in configuration file. Aborting scan. This is intentional: silently dropping a typo'd flag would change scan semantics. Validate config files in CI with a linter such as taplo.

3.4 Merge semantics

Opts::merge(&self, &Config) is split into two phases:

  • merge_required — for addresses, greppable, accessible, batch_size, timeout, tries, scan_order, scripts, command, udp, no_banner: if the config's value is Some, it overrides the CLI value. (The CLAP defaults are applied first, so the CLI baseline is always non-None.)
  • merge_optional — for range, resolver, ulimit, exclude_ports, exclude_addresses: only applied when the CLI didn't set them and the config did. The exception is ports, which is only filled from the config when --top was passed on the CLI.

--no-config short-circuits both phases.

3.5 Environment variables

Variable Read by Purpose
RUST_LOG env_logger Sets log level (trace/debug/info/warn/error). RUST_LOG=info enables the per-phase benchmark summary.
XDG_CONFIG_HOME dirs crate Overrides the default config-file directory.
HOME dirs crate Used for the legacy ~/.rustscan.toml lookup.
CARGO_PKG_VERSION clap macro Compile-time only — embedded into the --version string.

RustScan does not read any other environment variables.


4. Deployment & Scaling

4.1 Deployment patterns

Pattern When to use Trade-offs
One-shot CLI on an analyst laptop Manual recon, ad-hoc verification Easiest; constrained by laptop's nofile.
Docker on a jump-host Shared team environment, CI, hardened distros High default ulimits; per-run isolation; bring-your-own-Nmap not needed.
systemd-run --scope from cron / Ansible Scheduled internal scans Re-uses host's network namespace; honour host policy.
Network-namespace / container per scan Multi-tenant scanning service Strong blast-radius isolation; pair with cgroup CPU/memory caps.
Library mode (rustscan crate) Embedding into your own Rust tooling You own the runtime, output format, error handling.

4.2 Docker reference run

docker run --rm \
  --read-only --tmpfs /tmp \
  --network host \
  rustscan/rustscan:latest \
  -a 10.0.0.0/24 -r 1-1024 --no-banner -- -sV -oX /tmp/result.xml

--network host is needed if you want the container to scan over the host's interfaces and routes (which is usually what you want — RustScan is the scanner, not the target). For multi-tenant deployments, use a dedicated bridge network with deliberate egress rules instead.

Tip

Mount a config volume so analysts share a common policy: -v /etc/rustscan.toml:/root/.config/.rustscan.toml:ro. The container will pick it up automatically.

4.3 Scaling levers

Lever Effect Caveat
--batch-size Concurrency per RustScan process Bounded by nofile.
--ulimit Raises nofile on Unix Requires CAP_SYS_RESOURCE or root.
--timeout Time the scan takes per closed port Doesn't help with rate-limiting upstream.
Sharding by -a chunks Parallel scans across hosts Run each chunk in its own process / container.
Sharding by -r chunks Parallel scans across port subranges Avoids one fat batch saturating one host.
--scan-order random Avoids stripe patterns IDS dashboards use Slightly higher cache misses, negligible cost.

For shard/parallel approaches, RustScan is stateless — there is no shared coordinator. Aggregate output via -g and any log shipper (Vector, Fluent Bit, journald + Loki, etc.).

4.4 Rolling out a new version

  1. Build the image / binary with the new commit.
  2. Smoke-test against scanme.nmap.org from staging (docker run rustscan/rustscan:NEW_VER -a scanme.nmap.org -r 22-100 -g).
  3. Compare output against the previous version on the same target. Diffs in port list = behavioural change; investigate before promoting.
  4. Promote in your registry, update Ansible/Helm references, restart consumer jobs.

There's no daemon and no on-disk state, so rollbacks are just swapping the image tag back.


5. Operations

RustScan is a stateless, short-lived process, so "operations" is mostly about running it predictably and getting useful telemetry out.

5.1 Logging

The binary uses the log crate gated by env_logger. Levels:

Level What you see
error Nothing extra; serious panics are still printed by Rust runtime.
warn Default. CLI warnings ("File limit is lower than default batch size") plus any user-facing warning! lines.
info Adds the per-phase benchmark summary: RustScan, Portscan, Scripts timers.
debug Adds Main() opts dump, scripts initialised list, scanner debug, batch-size / ulimit decisions, per-socket connection errors.
trace Same as debug; no extra trace-level logs are emitted by RustScan itself.
RUST_LOG=info rustscan -a 192.0.2.10 -- -sV
[~] RustScan: 1.243s
[~] Portscan: 0.812s
[~] Scripts:  0.314s

Note

The benchmark summary is the most useful operational signal. If Portscan time grows linearly across runs while ports stay similar, you've likely hit a network-side rate limit; reduce batch size or add a sleep between shards.

5.2 Output capture

Mode Behaviour Best for
Default Banner + colored "Open IP:PORT" lines + Nmap output Interactive analysts.
--accessible Banner-less, plaintext "Open IP:PORT" + Nmap Screen readers / paste-into-tickets.
-g / --greppable Single line per IP: IP -> [PORT,PORT,...], no Nmap Pipelines, dashboards, regex.
-g --no-banner Same as above; banner suppressed for clean piping CI, log shipping.

The greppable format is stable in the sense that the source code structurally produces it ([println!("{} -> [{}]", &ip, ports_str)]) — but RustScan does not commit to it as machine-stable API. If you script around it, pin a version.

5.3 Health checks

There's nothing to liveness-probe — RustScan exits when it's done. For containerized service offerings, build your own health endpoint that runs rustscan --version and confirms the binary is callable.

5.4 Common runbooks

5.4.1 "A scheduled scan went 10x slower yesterday"

  1. Pull RUST_LOG=info summary from yesterday vs. today.
  2. If Portscan time changed dramatically: network/firewall side. Re-run with --tries 1 -t 800 -b 2000 to isolate timeout vs. throughput.
  3. If Scripts time changed: the Nmap call is the variable. Run with --scripts none to confirm.

5.4.2 "RustScan reports zero open ports but I know SSH is open"

  1. Re-run with RUST_LOG=debug to see actual connection errors.
  2. Bisect: rustscan -a TARGET -p 22 --scripts none -t 5000 (single port, generous timeout).
  3. If it still fails: routing / ACL issue, not RustScan.
  4. If it succeeds: lower batch size in the original scan.

5.4.3 "We need to onboard a new team to RustScan"

  1. Stand up a Docker image in your registry from a known commit.
  2. Drop a shared config TOML into a config volume (addresses left blank, opinionated batch_size, tries, timeout).
  3. Document the legal-authorization process before granting access (see Section 7).

Tip

Pre-populate command = ["-Pn", "-vvv", "--script", "default"] in the shared TOML so analysts can't accidentally escalate to noisy NSE scripts. Anything they pass after -- extends, doesn't replace.

5.4.4 "Trace a single port-scan to root cause"

RUST_LOG=debug rustscan -a TARGET -p 22 --scripts none -b 1 2>&1 \
  | tee scan.log

The -b 1 forces fully serial execution so the debug log is human-readable. The log contains Connection was successful, shutting down stream (success), or the exact io::Error string.


6. Performance

6.1 Where time goes

Per Benchmark::summary() output, a typical scan splits into three phases:

Phase Dominant cost
RustScan (total) Sum of all phases plus startup.
Portscan TCP connect() × IP × port × tries, capped at batch_size concurrency.
Scripts One Nmap invocation per IP, concurrent with the next IP's.

Portscan is almost always the biggest phase. Scripts is unpredictable because it's whatever Nmap does — version detection (-sV) can take longer than the entire scan.

6.2 Batch-size math

The infer_batch_size function (Unix only, in src/main.rs) implements:

let DEFAULT_FILE_DESCRIPTORS_LIMIT = 8000      // Ubuntu-typical
let AVERAGE_BATCH_SIZE             = 3000

if ulimit < batch_size:
    if ulimit < 3000:
        batch_size = ulimit / 2          # very tight; halve to leave headroom
    elif ulimit > 8000:
        batch_size = 3000                # generous ulimit, conservative batch
    else:
        batch_size = ulimit - 100        # ride right under the limit
else if ulimit + 2 > batch_size && opts.ulimit.is_none():
    log "[~] File limit higher than batch size. Can increase speed by '-b {ulimit-100}'."

On non-Unix, batch size is the constant AVERAGE_BATCH_SIZE = 3000.

Tip

The "useful warning" path (ulimit + 2 > batch_size && opts.ulimit.is_none()) is the easiest free speedup: it tells you the exact -b value to use. Capture stderr in CI, grep for '-b and feed the recommendation back into your runbook.

6.3 Memory profile

RustScan's memory grows with three things:

  1. The IP list — Vec<IpAddr> (16 bytes each on 64-bit, plus overhead).
  2. The port set — Vec<u16> (2 bytes each, up to 65,535 elements ~ 128 KB).
  3. The in-flight pool — FuturesUnordered of size batch_size, where each entry is a connect() future plus a BTreeMap<Vec<u16>, Vec<u8>> clone for UDP scans.

A typical full-range scan of a /24 with batch_size=4500 runs in ~30-80 MB RSS depending on platform. Worst case (large CIDR, full range, UDP) is hundreds of MB; size containers accordingly.

6.4 Throughput tuning checklist

  • [ ] nofile is at least batch_size + 200 (kernel keeps a few FDs for itself, stdin/stdout, etc.).
  • [ ] --timeout is realistic — 1500 ms is generous on a LAN, tight on a transcontinental link.
  • [ ] --tries is 1 unless you're on a known-lossy link; each retry is a full timeout.
  • [ ] conntrack_max (Linux) > batch_size × number_of_targets; bumping nf_conntrack_max is sometimes the actual fix.
  • [ ] Network MTU isn't dropping packets — TCP connect needs three packets; UDP probe is one.
  • [ ] You're not double-NAT'ing through a slow appliance.

6.5 Benchmarks

benches/benchmark_portscan.rs uses the Criterion framework to micro-benchmark Scanner::run against 127.0.0.1. Run with:

cargo bench --bench benchmark_portscan

These numbers are useful for regression detection on the scanner core; they are not a real-world throughput estimate (loopback has no network costs).


7. Security

RustScan is a vulnerability-discovery / reconnaissance tool. Operating it correctly means thinking about three threat models:

  1. You vs. the target — scanning is regulated; getting authorization wrong is a crime in most jurisdictions.
  2. You vs. detection — RustScan's defaults are noisy; understand what an IDS will see.
  3. The output vs. attackers — your scan results are an attacker's wish list.

Caution

Port scanning systems you do not own and have not been explicitly authorized to scan can violate the Computer Fraud and Abuse Act (US, 18 USC §1030), the Computer Misuse Act 1990 (UK), Article 202c StGB (Germany), and equivalent laws in most jurisdictions. RustScan ships no authorization framework — that responsibility is entirely yours.

Operational guidance:

  • Maintain a written, signed statement of work (SOW) or Rules of Engagement (RoE) before scanning third-party infrastructure. Scope should specify CIDRs, port ranges, time windows, and contact persons.
  • Internal scanning of your own infrastructure typically falls under a blanket security-team authorization; confirm with legal, especially in cloud (AWS / GCP / Azure all have AUPs about port scanning shared infrastructure).
  • scanme.nmap.org (public) and your own loopback are the only universally safe targets.

7.2 Detection posture

RustScan's default behaviour is a fast, full-range TCP connect scan from a single source IP. Every reasonable IDS will flag it.

Default Why detectable Mitigation if you have authorization
Connect (full handshake) scan Visible at L4 even to non-stateful sensors If you need stealth, use Nmap -sS after RustScan discovery.
Full 1-65535 range Volume-based rules -r to a tight range; document in your SOW.
Single source IP Trivial to baseline Distribute scans across egress IPs.
Serial port order "Stripe" pattern --scan-order random.
Nmap follow-up Second-stage signature --scripts none, run Nmap separately.
Burst at batch_size Sudden FD spike Lower --batch-size and let it run longer.

Warning

"Stealth" scans against systems you're authorized to test are fine. "Stealth" scans against systems you're not authorized to test are still illegal — the legality is independent of the noise level.

7.3 Output handling

Scan output describes attack surface. Treat it the way you'd treat any other recon artifact:

  • Scan logs go to a private bucket with a short retention window (30-90 days unless evidence is needed for an ongoing engagement).
  • Encrypt at rest. AWS S3 server-side encryption / GCS CMEK / Azure SSE — pick one and enforce via bucket policy.
  • Restrict access to the security team only. Audit log every read.
  • Strip / mask before sharing with clients — internal IPs, internal hostnames, and user-agent fingerprints can leak more than you intended.
  • For multi-client engagements, segregate output by client into separate prefixes / projects with hard IAM boundaries.

Important

Greppable output (-g) is structured but not stable across versions. Version-pin RustScan (or the Docker image) for any pipeline that parses its output as data.

7.4 Logging posture

What to log:

  • Operator identity (who ran the scan).
  • Authorization reference (SOW / RoE id).
  • Target scope (-a and -r values).
  • Time window (start, end, duration).
  • Source IP and egress path.
  • Tool version (rustscan --version).

What not to log persistently:

  • Per-port success/failure timing — useful for debug only, not retained.
  • Full Nmap NSE script output where it contains harvested credentials, version banners, or service-specific data — segregate to client-restricted storage.

7.5 Default rate limits and avoiding IDS lockouts

RustScan has no built-in rate limiter — batch_size is the only knob. The kernel's TCP stack provides natural backpressure when nofile is exhausted, and that's it.

To avoid being detected:

  • Stay under whatever flow-rate threshold your target IDS uses (commonly 100 SYN/sec/source). With default -b 4500 -t 1500, RustScan can easily exceed that for a /24.
  • Throttle by lowering -b and shard by host: scan one /28 at a time with -b 500.
  • Combine with --scan-order random so the bursts aren't temporally clustered on adjacent ports.
  • For long-running engagements, distribute scans across multiple egress points (different VPC / different VPN endpoint).

7.6 Supply-chain considerations

  • Cargo.lock is committed; lto = true and panic = 'abort' in [profile.release] keep the binary small and the panic behaviour deterministic.
  • Compile with --locked (cargo install --locked rustscan) to refuse silent dependency drift.
  • The Docker image bundles Nmap; track Nmap CVEs, not just RustScan ones.
  • The project carries a .travis.yml and CI history but the canonical CI configuration in newer revisions may live in .github/workflows/; verify against the source commit you're deploying.

7.7 Code-level safety properties

  • The crate compiles under #![deny(clippy::all)] and #![warn(clippy::pedantic)]. New code that adds unsafe blocks or unhandled panics will fail CI.
  • The "too many open files" assertion in scan_socket is intentional: rather than silently degrading, RustScan panics so an automated wrapper sees a non-zero exit and can re-tune. Do not "fix" this by removing the assert.
  • Opts::default() initialises every field; tests rely on it. If you add a new option, update both the struct, Default, and Config.

8. Developer Internals

8.1 Repository layout

RustScan/
├── Cargo.toml                       Crate manifest — pinned deps, package metadata
├── Cargo.lock                       Reproducible build (committed)
├── build.rs                         Compile-time generator → src/generated.rs
├── nmap-payloads                    UDP probe database (text)
├── config.toml                      Embedded top-1000 ports for --top
├── Makefile / justfile / release.sh Convenience scripts
├── contributing.Dockerfile          Dev container for contributors
├── src/
│   ├── main.rs                      Entry point, batch-size math, opening banner
│   ├── lib.rs                       Public API (mod declarations + doc example)
│   ├── input.rs                     Opts, Config, ScanOrder, ScriptsRequired, PortRange
│   ├── address.rs                   IP/CIDR/host/file parsing, exclusions, DNS
│   ├── tui.rs                       warning! / detail! / output! / funny_opening!
│   ├── benchmark/mod.rs             NamedTimer + Benchmark struct
│   ├── port_strategy/
│   │   ├── mod.rs                   PortStrategy enum + Serial/Random
│   │   └── range_iterator.rs        LCG RangeIterator
│   ├── scanner/
│   │   ├── mod.rs                   Scanner core, scan_socket, scan_udp_socket
│   │   └── socket_iterator.rs       (port × ip) cartesian product
│   └── scripts/mod.rs               Script registry, builder, runner
├── tests/timelimits.rs              Integration tests with timeouts (ignored by default)
├── benches/benchmark_portscan.rs    Criterion micro-benchmark
└── fixtures/                        Test data (hosts.txt, naughty_strings.txt, etc.)

Note

src/generated.rs is not committed; it's produced by build.rs from nmap-payloads on first build. Check .gitignore to confirm.

8.2 Public library API

The rustscan crate is also a Rust library. The public surface (per src/lib.rs and the modules it re-exports):

use std::{net::IpAddr, time::Duration};
use async_std::task::block_on;

use rustscan::input::{PortRange, ScanOrder};
use rustscan::port_strategy::PortStrategy;
use rustscan::scanner::Scanner;

fn main() {
    let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
    let range = PortRange { start: 1, end: 1_000 };

    let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);

    let scanner = Scanner::new(
        &addrs,
        10,                              // batch_size
        Duration::from_millis(100),      // timeout
        1,                               // tries
        true,                            // greppable
        strategy,
        true,                            // accessible
        vec![9000],                      // exclude_ports
        false,                           // udp
    );

    let scan_result = block_on(scanner.run());
    println!("{:?}", scan_result);
}

Public items (selected) — all from src/:

  • rustscan::input::{Opts, Config, ScanOrder, ScriptsRequired, PortRange, default_config_path, old_default_config_path}
  • rustscan::address::{parse_addresses, parse_address, parse_excluded_networks}
  • rustscan::scanner::Scanner
  • rustscan::port_strategy::PortStrategy
  • rustscan::scripts::{init_scripts, Script, ScriptFile}
  • rustscan::benchmark::{Benchmark, NamedTimer}
  • Macros (re-exported): warning!, detail!, output!, funny_opening!

8.3 The async port-scanning pool

Scanner::run is the heart of RustScan. The pseudocode:

pub async fn run(&self) -> Vec<SocketAddr> {
    let ports = self.port_strategy.order()
        .into_iter()
        .filter(|p| !self.exclude_ports.contains(p))
        .collect::<Vec<u16>>();

    let mut socket_iterator = SocketIterator::new(&self.ips, &ports);
    let mut futures        = FuturesUnordered::new();
    let mut open_sockets   = Vec::new();

    // Prime the pool
    for _ in 0..self.batch_size {
        if let Some(s) = socket_iterator.next() {
            futures.push(self.scan_socket(s, udp_map.clone()));
        } else {
            break;
        }
    }

    // Drain + refill: each completed future is replaced by one fresh socket
    while let Some(result) = futures.next().await {
        if let Some(s) = socket_iterator.next() {
            futures.push(self.scan_socket(s, udp_map.clone()));
        }
        if let Ok(socket) = result {
            open_sockets.push(socket);
        }
    }

    open_sockets
}

Two design choices to know:

  1. Fixed-size pool. Concurrency is at most batch_size in-flight sockets. This is the only knob that controls pressure on the network and the local FD table.
  2. Cartesian iteration order. SocketIterator yields (port, ip) in the form "all IPs for port 22, then all IPs for port 23, ..." — visible in the test in src/scanner/socket_iterator.rs. This distributes load instead of pounding one host at a time.

8.4 Per-socket scan path

scan_socket (TCP):

  1. Try connect(socket) with a self.timeout deadline.
  2. On Ok: shutdown the stream both ways, format and print "Open IP:PORT" (unless greppable), return Ok(socket).
  3. On Err containing "too many open files" (case-insensitive): assert-fail with a remediation message — by design.
  4. On any other error: retry up to self.tries times, then return Err(io::Error::other(error_string)) with the IP appended.

scan_udp_socket (UDP):

  1. Look up the port in the embedded payload BTreeMap.
  2. Bind a local UDP socket, send the payload, recv with timeout.
  3. On a non-empty response: success.
  4. On timeout: not open (continue with next try).
  5. After all tries: Err.

8.5 The Linear Congruential Generator (random scan order)

Random order isn't a Vec::shuffle — it's an LCG that produces a non-repeating permutation of the range without storing the full vector first:

For range [start, end], normalize to [0, end - start]:
    step = pick_random_coprime(end - start + 1)
    first_pick = rng.random_range(0..normalized_end)
    next(N) = (current + step) % normalized_end
    iteration ends when next == first_pick

pick_random_coprime searches up to 10 candidates in the middle half of the range (end/4..end - end/4); if none are coprime, it falls back to end - 1 (always coprime with end).

Why care: the iterator avoids cluster-of-adjacent-ports patterns even compared to a pre-shuffled vector, because a coprime step deliberately makes consecutive picks far apart.

8.6 Address parsing pipeline

parse_addresses (in src/address.rs) handles four input shapes for each token in -a:

  1. IpAddr::from_str — bare IPv4 / IPv6 → single-element Vec<IpAddr>.
  2. IpInet::from_str — CIDR → expand via cidr_utils to all host addresses.
  3. to_socket_addrs("{addr}:80") — system DNS lookup → first resolved IP.
  4. backup_resolver.lookup_ip — fallback DNS-over-TLS resolver if (3) fails.
  5. If still empty, treat the token as a file path: read line-by-line and recurse.

Final dedup is via BTreeSet<IpAddr>; exclusions (from -x) are subtracted with IpCidr::contains.

Note

The DNS fallback uses ResolverConfig::cloudflare_tls() (1.1.1.1 over DoT) when /etc/resolv.conf can't be parsed. If you can't tolerate that egress, pass --resolver with your own list (or 127.0.0.1 to force a local resolver).

8.7 Scripting engine (Rhai-style descriptors)

Note

Scripts in RustScan 2.4.1 are TOML descriptors that parameterize an external shell command — not in-process Rhai/Lua execution. The src/scripts/mod.rs module loads ~/.rustscan_scripts/*.toml and .rustscan_scripts.toml, validates required fields, and produces a ScriptFile per descriptor. At scan time, each ScriptFile is rendered via the text_placeholder crate ({{ip}}, {{port}}) and executed with std::process::Command.

Descriptor fields the project recognizes (per Script::build):

Field Required Purpose
path yes Path to the script binary or interpreter command.
tags yes Tags used to match scripts to ports.
developer recommended Author attribution.
port optional Specific port the script applies to.
ports_separator optional Separator used when joining ports for the call.
call_format yes Templated command line with {{ip}} / {{port}} placeholders.

The default-mode script is the well-known Nmap handoff — its call_format is something like:

nmap -Pn -vvv -p {{port}} {{ip}}

…with command from Opts appended at runtime, which is how user -- -A -sC ends up at the end of the Nmap call.

--scripts none short-circuits the loop: RustScan still resolves and scans, then prints IP -> [PORTS] and exits. --scripts custom loads only descriptors matched against tags from ~/.rustscan_scripts.toml.

8.8 Code patterns and conventions

  • #![deny(clippy::all)] + #![warn(clippy::pedantic)] in src/main.rs enforce a strict lint profile. New code that fails clippy fails CI.
  • #[cfg(not(tarpaulin_include))] marks blocks excluded from coverage measurement (typically functions whose effect is observable only in main).
  • #[cfg(unix)] gates rlimit-based logic; the #[cfg(not(unix))] branch sets a fixed AVERAGE_BATCH_SIZE = 3000.
  • TUI macros in src/tui.rs are dual-form: warning!("msg") is the simple form; warning!("msg", greppable, accessible) honours operator-mode flags. Always prefer the three-arg form in new code so --accessible / -g stay correct.
  • Tests live next to source in #[cfg(test)] mod tests, plus integration tests in tests/timelimits.rs and benches in benches/.
  • Default-implementations are explicit. Opts::default() is hand-written (not #[derive(Default)]) because some defaults differ from the type defaults (e.g., greppable: true in Default, false in CLAP — that's deliberate; the Default is for tests).

8.9 Testing strategy

Layer Where What it covers
Unit src/**/mod.rs::tests Arithmetic on batch size; CIDR / file parsing; LCG range coverage; PortStrategy variants.
Network src/scanner/mod.rs::tests Real connect to localhost / Google DNS / IPv6 loopback; ensures no panic.
Integration tests/timelimits.rs End-to-end timing constraints; ignored by default (-- --ignored).
Bench benches/benchmark_portscan.rs Criterion harness (regression-only, not absolute throughput).
CLI src/input.rs::tests::verify_cli Opts::command().debug_assert() catches malformed CLAP attributes.

Run the full local test sweep with:

ulimit -n 5000 && cargo test && cargo test timelimits:: -- --ignored --test-threads=1 --show-output

(That's the literal command from .travis.yml.)

8.10 Potential improvements (open features in upstream)

Issue Description Where to start
#780 / #835 Combined port range syntax 1-22,8080,8443 Extend parse_range and the ports/range mutually-exclusive constraint in src/input.rs.
#825 Proxy support New --proxy flag → wrap TcpStream::connect in a SOCKS5 dialer.
#829 Per-host TCP/UDP suffix syntax Extend parse_address to recognise /t and /u suffixes; route to TCP or UDP scanning per host.
#683 ARM CI coverage Update CI matrix; verify Docker manifest list publishes ARM tags.
#741 XDG config-path edge cases Already mostly fixed via dirs::config_dir(); remaining work is the legacy fallback strategy.
#800 Version reporting drift Cargo.toml version of record vs. embedded CARGO_PKG_VERSION when releases skip a tag.

8.11 Contributing workflow

  1. Fork → branch from master.
  2. Implement the change. New crates go in Cargo.toml with explicit version pins.
  3. cargo fmt --all (Travis enforces this).
  4. cargo test and cargo clippy --all-targets -- -D warnings.
  5. Update tests next to source; if it touches scanning behaviour, add an integration test in tests/.
  6. PR with a clear before/after — the clippy + format gate catches the rest.

9. Integration Points

RustScan is deliberately small; integration is mostly about the seam at the command-line, the seam at Nmap, and the seam at the public Rust crate.

9.1 With Nmap

The default --scripts default mode invokes Nmap with at minimum -Pn -vvv -p $PORTS $IP, plus anything you appended after --. Practical implications:

  • Nmap is mandatory unless you set --scripts none. The .deb package declares it as a dependency; the Docker image ships it.
  • All Nmap behaviour — version detection, OS fingerprint, NSE — is configured via the trailing --. You don't need a wrapper script to get Nmap features.
  • Nmap output appears on stdout interleaved with RustScan's lines. If your downstream parser is brittle, run with -g --scripts none to get only RustScan's output, then call Nmap separately.

9.2 Shell pipelines

Greppable mode (-g) makes RustScan a first-class citizen in any tool chain that reads IP -> [PORTS]:

rustscan -g --no-banner -a 10.0.0.0/24 -r 1-1024 \
  | tee scans/$(date +%F).txt \
  | awk -F' -> ' '$2 != "[]"' \
  | mail -s "Open ports today" [email removed]

For structured downstream tools, write your own greppable parser:

import re, subprocess
out = subprocess.check_output(["rustscan", "-g", "-a", "10.0.0.0/24"], text=True)
for line in out.splitlines():
    m = re.match(r'(\S+) -> \[(.*)\]', line)
    if not m: continue
    ip, ports = m.group(1), [int(p) for p in m.group(2).split(',') if p]
    yield ip, ports

9.3 As a Rust library

Add to your Cargo.toml:

[dependencies]
rustscan = "2.4"
async-std = "1.13"
futures = "0.3"

Drive the scanner directly from your code (see Section 8.2 for the canonical example). The library doesn't pull in CLAP unless you depend on it through rustscan::input::Opts::parse(), but the main scanning path (Scanner + PortStrategy + parse_addresses) is dependency-light.

9.4 In CI

# GitHub Actions
- uses: docker://rustscan/rustscan:latest
  with:
    args: >-
      -g --no-banner --scripts none
      -a ${{ secrets.SCAN_TARGET }}
      -r 1-1024

The container starts, scans, prints, exits. Pair with actions/upload-artifact to retain results for the security team.

9.5 SIEM ingestion

Greppable output ships well into:

  • Splunk / Elastic: line-based IP -> [PORTS] with timestamp from the log shipper.
  • Loki: same, with job=rustscan label.
  • Datadog Logs: parse with a custom grok pattern (?<ip>%{IP}) -> \[(?<ports>[^\]]*)\].

Don't ingest the full default output (banner + Nmap interleaved) — it's interactive-shaped, not log-shaped.

9.6 Container orchestration

RustScan exits when done; there is no idle process to keep alive. For Kubernetes:

  • Run as a Job (or CronJob for scheduled scans).
  • restartPolicy: OnFailure so a transient DNS hiccup retries.
  • securityContext.capabilities.add: ["SYS_RESOURCE"] if you want --ulimit to actually raise FDs.
  • Bind the config TOML with a ConfigMap volume if your team shares a policy.

9.7 What RustScan does NOT integrate with

To keep the negative-knowledge surface explicit:

  • No daemon mode. RustScan does not run as a long-lived service. There is no socket, no REST endpoint, no metrics scrape target.
  • No webhook / event push. It writes to stdout and exits. Hook your own.
  • No proxy support. Tracked in #825. Use proxychains or namespace routing.
  • No raw SYN scanning. Connect-only. Use Nmap -sS after the fact for that.
  • No IPv4-mapped IPv6 special casing. Whatever the resolver returns is what RustScan scans.
  • No persistence. No database, no on-disk cache. Each run is independent.