diff --git a/docs/lazy-dns-benchmark-results.json b/docs/lazy-dns-benchmark-results.json new file mode 100644 index 00000000..80059f09 --- /dev/null +++ b/docs/lazy-dns-benchmark-results.json @@ -0,0 +1,151 @@ +{ + "metadata": { + "base": "67c08a33100362c54b56fde784b0fd234f70fa11", + "candidate": "a05592a8a88b77ad85d961137943dfadfe98b612", + "environment": "Linux-7.2.6-1-cachyos-x86_64-with-glibc2.44", + "rust": "rustc 1.98.1 (48a229cea 2026-09-01) (Arch Linux rust 1:1.98.1-1.1)", + "hosts_bytes": 2760051, + "profile": "release with LTO", + "network_namespace": "unshare --user --map-root-user --net", + "common_args": [ + "--no-config", + "--config-path", + "/dev/null", + "--no-banner", + "--scripts", + "none", + "--greppable", + "--ports", + "80", + "--exclude-ports", + "80" + ], + "workloads": { + "literal-ipv4": [ + "-a", + "127.0.0.1" + ], + "mixed-cidrs": [ + "-a", + "127.0.0.1,192.0.2.0/30,2001:db8::/126", + "--exclude-addresses", + "192.0.2.1,2001:db8::1" + ] + }, + "warmups_each": 2, + "samples_each": 20, + "binary_cpus": "0-3", + "harness_cpus": "8-9", + "binary_sha256": { + "base": "4d6b090218ff36249ed95f14ec1a82626e8ea33854f3823f19a6de247b1524b8", + "dns": "a9d6bf949f56c53d18cc85bc9b53b21bd85a63bcdd04ad78992d392c675c77ac" + } + }, + "summary": { + "literal-ipv4": { + "base_ms": 168.23068849999999, + "dns_ms": 2.248762, + "samples_each": 20 + }, + "mixed-cidrs": { + "base_ms": 178.0855925, + "dns_ms": 2.4539600000000004, + "samples_each": 20 + } + }, + "samples": { + "literal-ipv4": { + "base": [ + 143.391226, + 146.562804, + 149.615525, + 151.188597, + 152.269193, + 159.499601, + 164.926958, + 164.347529, + 167.208931, + 165.433184, + 172.246939, + 169.252446, + 172.777899, + 170.729249, + 170.092681, + 171.863255, + 169.612842, + 170.857396, + 173.117403, + 175.206345 + ], + "dns": [ + 2.241793, + 1.817341, + 2.39956, + 2.048986, + 3.123401, + 1.820017, + 3.615094, + 2.165312, + 2.675535, + 2.103261, + 2.531392, + 2.215141, + 2.572939, + 2.063803, + 2.671431, + 2.059846, + 2.598858, + 2.230916, + 2.678259, + 2.255731 + ] + }, + "mixed-cidrs": { + "base": [ + 175.504767, + 178.797842, + 186.183728, + 176.885958, + 177.989816, + 180.028568, + 177.66299, + 174.757221, + 175.468485, + 175.328023, + 177.639639, + 175.637593, + 177.922435, + 185.394396, + 182.587204, + 180.58311, + 178.961137, + 178.293186, + 180.482419, + 178.181369 + ], + "dns": [ + 3.275193, + 2.114074, + 2.700771, + 2.183317, + 2.606538, + 2.368046, + 2.599702, + 2.238105, + 2.683892, + 2.292249, + 2.528366, + 2.114478, + 2.668921, + 2.103271, + 3.275514, + 2.27974, + 3.103311, + 2.265298, + 2.580743, + 2.379554 + ] + } + }, + "invocations": 88 +} diff --git a/docs/lazy-dns-benchmark.md b/docs/lazy-dns-benchmark.md new file mode 100644 index 00000000..ed5e4e5b --- /dev/null +++ b/docs/lazy-dns-benchmark.md @@ -0,0 +1,48 @@ +# Lazy DNS initialization benchmark + +Compared current Tokio master `67c08a33100362c54b56fde784b0fd234f70fa11` with the lazy-resolver implementation +at `a05592a8a88b77ad85d961137943dfadfe98b612` on 2026-10-02. + +## Method + +Both binaries are release builds with LTO, using Rust 1.98.1 on an Intel Core +Ultra 7 165U running Linux 7.2.6-1-cachyos. Each workload used twenty measured +runs and two warmups per binary, alternating A/B and B/A order. Binaries used +CPUs 0-3; the harness used CPUs 8-9. No builds ran during measurement. + +The harness ran in a private network namespace. Every invocation excluded +its only requested port, so no scan traffic was sent. Configuration and +scripts were disabled. Timing spans process launch through exit, including +the same `taskset` affinity launcher for both builds. + +Common arguments: + +```sh +--no-config --config-path /dev/null --no-banner --scripts none --greppable \ + --ports 80 --exclude-ports 80 +``` + +The literal case adds `-a 127.0.0.1`. The mixed case adds +`-a 127.0.0.1,192.0.2.0/30,2001:db8::/126` and +`--exclude-addresses 192.0.2.1,2001:db8::1`. Both use `RUST_LOG=rustscan=info`. + +## Results + +| Workload | Master median | Lazy DNS median | Time saved | +| --- | ---: | ---: | ---: | +| Literal IPv4 | 168.23 ms | 2.25 ms | 165.98 ms (98.66%) | +| IPv4/IPv6 CIDRs and exclusions | 178.09 ms | 2.45 ms | 175.63 ms (98.62%) | + +All 88 invocations exited successfully with the expected empty scan output. +A separate syscall trace of the optimized literal-IP invocation recorded no +reads of `/etc/hosts` or `/etc/resolv.conf` and no network connects or sends. + +This host has a 2,760,051-byte hosts file. Eager Hickory initialization parses +that file even for numeric targets. Systems with smaller hosts files will +save less; this measures CLI startup, not socket throughput. Hostnames that +need fallback DNS still pay resolver initialization once, and parsing order, +file handling, deduplication and exclusions keep their existing behavior. + +[Raw samples, setup and binary hashes](lazy-dns-benchmark-results.json) are +included. Network-free regression tests verify that literal IPv4/IPv6 targets, +CIDRs, exclusions, duplicates and empty inputs do not construct the resolver. diff --git a/src/address.rs b/src/address.rs index df074355..8a43a7f1 100644 --- a/src/address.rs +++ b/src/address.rs @@ -1,4 +1,5 @@ //! Provides functions to parse input IP addresses, CIDRs or files. +use std::cell::LazyCell; use std::collections::BTreeSet; use std::fs::{self, File}; use std::io::{prelude::*, BufReader}; @@ -39,12 +40,23 @@ use crate::warning; /// start your runtime (as the `rustscan` binary does) or from /// `tokio::task::spawn_blocking`, not directly from async code. pub fn parse_addresses(input: &Opts) -> Vec { + parse_addresses_with_resolver(input, || get_resolver(&input.resolver)) +} + +fn parse_addresses_with_resolver( + input: &Opts, + create_resolver: impl FnOnce() -> Resolver, +) -> Vec { let mut ips: Vec = Vec::new(); let mut unresolved_addresses: Vec<&str> = Vec::new(); - let backup_resolver = get_resolver(&input.resolver); + // Most scans use literal IPs or CIDRs. Defer the resolver's runtime and + // hosts-file loading until a DNS fallback actually needs them, then reuse + // that resolver for the rest of the targets and exclusions. + let backup_resolver = LazyCell::new(create_resolver); + let resolver = || &*backup_resolver; for address in &input.addresses { - let parsed_ips = parse_address(address, &backup_resolver); + let parsed_ips = parse_address_with_resolver(address, &resolver); if !parsed_ips.is_empty() { ips.extend(parsed_ips); } else { @@ -66,7 +78,7 @@ pub fn parse_addresses(input: &Opts) -> Vec { continue; } - if let Ok(x) = read_ips_from_file(file_path, &backup_resolver) { + if let Ok(x) = read_ips_from_file(file_path, &resolver) { ips.extend(x); } else { warning!( @@ -77,7 +89,7 @@ pub fn parse_addresses(input: &Opts) -> Vec { } } - let excluded_cidrs = parse_excluded_networks(&input.exclude_addresses, &backup_resolver); + let excluded_cidrs = parse_excluded_networks_with_resolver(&input.exclude_addresses, &resolver); // Remove duplicated/excluded IPs. let mut seen = BTreeSet::new(); @@ -100,6 +112,13 @@ pub fn parse_addresses(input: &Opts) -> Vec { /// let ips = parse_address("127.0.0.1", &Resolver::default().unwrap()); /// ``` pub fn parse_address(address: &str, resolver: &Resolver) -> Vec { + parse_address_with_resolver(address, &|| resolver) +} + +fn parse_address_with_resolver<'a>( + address: &str, + resolver: &impl Fn() -> &'a Resolver, +) -> Vec { if let Ok(addr) = IpAddr::from_str(address) { // `address` is an IP string vec![addr] @@ -118,14 +137,17 @@ pub fn parse_address(address: &str, resolver: &Resolver) -> Vec { } /// Uses DNS to get the IPS associated with host -fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec { +fn resolve_ips_from_host<'a>( + source: &str, + backup_resolver: &impl Fn() -> &'a Resolver, +) -> Vec { let mut ips: Vec = Vec::new(); if let Ok(addrs) = source.to_socket_addrs() { for ip in addrs { ips.push(ip.ip()); } - } else if let Ok(addrs) = backup_resolver.lookup_ip(source) { + } else if let Ok(addrs) = backup_resolver().lookup_ip(source) { ips.extend(addrs.iter()); } @@ -148,6 +170,13 @@ fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec>, resolver: &Resolver, +) -> Vec { + parse_excluded_networks_with_resolver(exclude_addresses, &|| resolver) +} + +fn parse_excluded_networks_with_resolver<'a>( + exclude_addresses: &Option>, + resolver: &impl Fn() -> &'a Resolver, ) -> Vec { exclude_addresses .iter() @@ -157,7 +186,10 @@ pub fn parse_excluded_networks( } /// Parses a single address into an IpCidr, handling CIDR notation, IP addresses, and hostnames. -fn parse_single_excluded_address(addr: &str, resolver: &Resolver) -> Vec { +fn parse_single_excluded_address<'a>( + addr: &str, + resolver: &impl Fn() -> &'a Resolver, +) -> Vec { if let Ok(cidr) = IpCidr::from_str(addr) { return vec![cidr]; } @@ -261,9 +293,9 @@ fn read_resolver_from_file(path: &str) -> Result, std::io::Error> { #[cfg(not(tarpaulin_include))] /// Parses an input file of IPs and uses those -fn read_ips_from_file( +fn read_ips_from_file<'a>( ips: &std::path::Path, - backup_resolver: &Resolver, + backup_resolver: &impl Fn() -> &'a Resolver, ) -> Result, std::io::Error> { let file = File::open(ips)?; let reader = BufReader::new(file); @@ -272,7 +304,7 @@ fn read_ips_from_file( for address_line in reader.lines() { if let Ok(address) = address_line { - ips.extend(parse_address(&address, backup_resolver)); + ips.extend(parse_address_with_resolver(&address, backup_resolver)); } else { debug!("Line in file is not valid"); } @@ -283,9 +315,48 @@ fn read_ips_from_file( #[cfg(test)] mod tests { - use super::{parse_addresses, Opts}; + use super::{parse_addresses, parse_addresses_with_resolver, Opts}; use std::net::{IpAddr, Ipv4Addr}; + #[test] + fn literal_targets_and_exclusions_do_not_initialize_a_resolver() { + let opts = Opts { + addresses: vec![ + "192.0.2.0/30".to_owned(), + "192.0.2.2".to_owned(), + "2001:db8::/126".to_owned(), + "2001:db8::3".to_owned(), + ], + exclude_addresses: Some(vec![ + "192.0.2.0/31".to_owned(), + "192.0.2.3".to_owned(), + "2001:db8::/127".to_owned(), + "2001:db8::3".to_owned(), + ]), + ..Default::default() + }; + + let ips = parse_addresses_with_resolver(&opts, || { + panic!("literal targets and exclusions must not initialize DNS") + }); + + assert_eq!( + ips, + [ + "192.0.2.2".parse::().unwrap(), + "2001:db8::2".parse::().unwrap(), + ] + ); + } + + #[test] + fn empty_targets_do_not_initialize_a_resolver() { + assert!(parse_addresses_with_resolver(&Opts::default(), || { + panic!("empty targets must not initialize DNS") + }) + .is_empty()); + } + #[test] fn parse_correct_addresses() { let opts = Opts {