diff --git a/.github/scan-benchmark/scan_bench.py b/.github/scan-benchmark/scan_bench.py new file mode 100644 index 0000000..d0bf73a --- /dev/null +++ b/.github/scan-benchmark/scan_bench.py @@ -0,0 +1,919 @@ +#!/usr/bin/env python3 +"""Compare RustScan builds by scanning listeners on the loopback interface. + +CI-only tooling for ``.github/workflows/scan-benchmark.yml``; it is not part +of the test suite. It port-scans 127.0.0.1, so only run it on a disposable CI +runner, never on a developer machine or a shared host. + +``run`` starts ``serve`` (TCP listeners and UDP responders on 127.0.0.1) in a +separate process, runs every scenario with every build (interleaving the +builds so that drift on the runner affects them all alike), checks that all +builds report the same open ports, and writes the raw measurements as JSON +plus a Markdown summary of the medians. On Linux the workflow also sets up a +host behind an artificial delay (a network namespace on the runner, with its +own ``serve``), so that answers arrive after a round trip, as they do from a +remote host, instead of while the probe is being sent. + +Measured per run: + +* scan time: RustScan's own "Portscan" timer (``RUST_LOG=rustscan=info``), + reported as throughput (sockets per second); +* wall time of the whole process, and the time until the first ``Open`` line + appears on stdout (latency to the first result); +* peak RSS and CPU time (user + system) of the process; +* peak number of open file descriptors (Windows: handles) and of threads, + sampled every few milliseconds while the scan runs. +""" + +from __future__ import annotations + +import argparse +import json +import os +import platform +import re +import selectors +import signal +import socket +import statistics +import subprocess +import sys +import threading +import time +from dataclasses import asdict, dataclass, field + +LOOPBACK = "127.0.0.1" + +# Ports `serve` listens on. They stay below 32768, i.e. below the ephemeral +# port ranges of Linux (32768-60999), macOS and Windows (49152-65535): a scan +# of a port inside the ephemeral range can occasionally connect a socket to +# itself and report a bogus open port. +TCP_PORTS = tuple(range(1024, 32768, 101)) # 315 open TCP ports +UDP_PORTS = tuple(range(1031, 32768, 997)) # 32 open UDP ports +SWEEP = (1, 32767) + +SAMPLE_INTERVAL = 0.005 # seconds between fd/thread samples +RUN_TIMEOUT = 300 # seconds before a run is killed and counted as failed + + +# -------------------------------------------------------------------------- +# Listener process +# -------------------------------------------------------------------------- + + +def serve(addresses: list[str]) -> int: + """Accepts (and closes) TCP connections and answers every UDP datagram.""" + selector = selectors.DefaultSelector() + bound: dict[str, dict[str, list[int]]] = {"tcp": {}, "udp": {}, "busy": {}} + + for address in addresses: + for kind, ports in (("tcp", TCP_PORTS), ("udp", UDP_PORTS)): + sock_type = socket.SOCK_STREAM if kind == "tcp" else socket.SOCK_DGRAM + for port in ports: + sock = socket.socket(socket.AF_INET, sock_type) + if os.name != "nt": + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((address, port)) + except OSError: + sock.close() + bound["busy"].setdefault(f"{kind} {address}", []).append(port) + continue + if kind == "tcp": + sock.listen(1024) + sock.setblocking(False) + selector.register(sock, selectors.EVENT_READ, kind) + bound[kind].setdefault(address, []).append(port) + + print(json.dumps(bound), flush=True) + + while True: + for key, _ in selector.select(timeout=1.0): + sock = key.fileobj + if key.data == "tcp": + while True: + try: + conn, _ = sock.accept() + except OSError: # BlockingIOError once the queue is empty + break + conn.close() + else: + while True: + try: + _, peer = sock.recvfrom(4096) + except ConnectionResetError: + # Windows reports an earlier ICMP error here; skip it. + continue + except OSError: # BlockingIOError once the queue is empty + break + try: + sock.sendto(b"rustscan-benchmark\n", peer) + except OSError: + pass + + +def start_server(addresses: list[str]) -> tuple[subprocess.Popen, dict]: + args = [arg for address in addresses for arg in ("--address", address)] + proc = subprocess.Popen( + [sys.executable, os.path.abspath(__file__), "serve", *args], + stdout=subprocess.PIPE, + stdin=subprocess.DEVNULL, + text=True, + ) + line = proc.stdout.readline() + if not line: + raise SystemExit("the listener process exited before it was ready") + return proc, json.loads(line) + + +# -------------------------------------------------------------------------- +# Scenarios +# -------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Scenario: + name: str + title: str + addresses: tuple[str, ...] + ranges: tuple[tuple[int, int], ...] = () + ports: tuple[int, ...] = () + batch: int = 4500 + timeout_ms: int = 1500 + udp: bool = False + repeat: int = 1 # extra samples for very short scenarios + + @property + def sockets(self) -> int: + span = sum(end - start + 1 for start, end in self.ranges) + return len(self.addresses) * (len(self.ports) + span) + + def covers(self, port: int) -> bool: + return port in self.ports or any(s <= port <= e for s, e in self.ranges) + + def args(self) -> list[str]: + args = ["-a", ",".join(self.addresses)] + if self.ports: + args += ["-p", ",".join(map(str, self.ports))] + else: + args += ["-r", ",".join(f"{s}-{e}" for s, e in self.ranges)] + args += ["-b", str(self.batch), "-t", str(self.timeout_ms)] + if self.udp: + args.append("--udp") + return args + + def expected(self, listeners: dict) -> set[str]: + by_address = listeners["udp" if self.udp else "tcp"] + return { + f"{address}:{port}" + for address in self.addresses + for port in by_address.get(address, []) + if self.covers(port) + } + + def describe(self) -> str: + hosts = f"{len(self.addresses)} addresses" if len(self.addresses) > 1 else self.addresses[0] + return f"{self.title}: `{' '.join(self.args()[2:])}` on {hosts}" + + +def build_scenarios( + system: str, filtered: tuple[int, int] | None, delayed: str | None, delay_ms: int +) -> list[Scenario]: + lo = (LOOPBACK,) + if system == "Windows": + # Windows retries a refused connection for about a second, so every + # closed port there takes as long as a filtered one: sweep fewer. + sweep, small_sweep = ((1, 8191),), ((1, 2047),) + else: + sweep = small_sweep = (SWEEP,) + + def ports(ranges: tuple[tuple[int, int], ...]) -> str: + return f"{sum(e - s + 1 for s, e in ranges):,} ports" + + scenarios = [ + Scenario("tcp-1-port", "TCP, 1 open port", lo, ports=(TCP_PORTS[0],), repeat=3), + Scenario("tcp-sweep", f"TCP, {ports(sweep)}, default batch", lo, sweep), + Scenario( + "tcp-sweep-b500", f"TCP, {ports(small_sweep)}, small batch", lo, small_sweep, batch=500 + ), + Scenario("tcp-sweep-b10000", f"TCP, {ports(sweep)}, large batch", lo, sweep, batch=10000), + ] + if system == "Linux": + # Linux answers on all of 127.0.0.0/8; other systems only on 127.0.0.1. + hosts = tuple(f"127.0.0.{i}" for i in range(1, 9)) + scenarios.append( + Scenario("tcp-8-hosts", "TCP, 8 hosts x 4,096 ports", hosts, ((1, 4096),)) + ) + if delayed: + scenarios.append( + Scenario("tcp-delayed", f"TCP, {ports(sweep)}, {delay_ms} ms away", (delayed,), sweep) + ) + if filtered: + dropped = (filtered,) + scenarios += [ + Scenario( + "tcp-filtered", "TCP, 2,000 filtered ports", lo, dropped, batch=500, timeout_ms=250 + ), + Scenario( + "tcp-mixed", + f"TCP, {ports(sweep)} + 2,000 filtered ports", + lo, + sweep + dropped, + timeout_ms=500, + ), + ] + scenarios.append( + Scenario("udp-sweep", f"UDP, {ports(sweep)}", lo, sweep, timeout_ms=500, udp=True) + ) + if delayed: + scenarios.append( + Scenario( + "udp-delayed", + f"UDP, {ports(sweep)}, {delay_ms} ms away", + (delayed,), + sweep, + timeout_ms=500, + udp=True, + ) + ) + if filtered: + scenarios.append( + Scenario( + "udp-filtered", + "UDP, 2,000 filtered ports", + lo, + (filtered,), + batch=500, + timeout_ms=250, + udp=True, + ) + ) + return scenarios + + +# -------------------------------------------------------------------------- +# Running one scan +# -------------------------------------------------------------------------- + + +@dataclass +class RunResult: + build: str + scenario: str + sockets: int = 0 + ok: bool = True + error: str = "" + returncode: int | None = None + wall_s: float | None = None + scan_s: float | None = None + first_open_s: float | None = None + rss_kb: int | None = None + cpu_s: float | None = None + peak_fds: int | None = None + peak_threads: int | None = None + open: list[str] = field(default_factory=list) + + @property + def rate(self) -> float | None: + return self.sockets / self.scan_s if self.scan_s else None + + +SCAN_TIME = re.compile(r"Portscan\s*\|\s*([0-9.]+)\s*s") +OPEN_LINE = re.compile(r"^Open (\S+)$") +SUMMARY_LINE = re.compile(r"^(\S+) -> \[([0-9,]*)\]$") + + +class Sampler(threading.Thread): + """Samples the open descriptors and the threads of a running process.""" + + def __init__(self, proc: subprocess.Popen): + super().__init__(daemon=True) + self.proc = proc + self.done = threading.Event() + self.peak_fds: int | None = None + self.peak_threads: int | None = None + self.system = platform.system() + self.libproc = darwin_libproc() if self.system == "Darwin" else None + + def sample(self) -> tuple[int | None, int | None]: + pid = self.proc.pid + if self.system == "Linux": + fds = len(os.listdir(f"/proc/{pid}/fd")) + threads = None + with open(f"/proc/{pid}/status", encoding="ascii", errors="replace") as status: + for line in status: + if line.startswith("Threads:"): + threads = int(line.split()[1]) + break + return fds, threads + if self.libproc is not None: + return darwin_fds_and_threads(self.libproc, pid) + if self.system == "Windows": + return windows_handle_count(self.proc), None + return None, None + + def run(self) -> None: + while not self.done.is_set(): + try: + fds, threads = self.sample() + except (OSError, ValueError): + return # the process is gone + if fds is not None: + self.peak_fds = max(self.peak_fds or 0, fds) + if threads is not None: + self.peak_threads = max(self.peak_threads or 0, threads) + self.done.wait(SAMPLE_INTERVAL) + + +def darwin_libproc(): + import ctypes + + try: + libproc = ctypes.CDLL("/usr/lib/libproc.dylib") + except OSError: + return None + libproc.proc_pidinfo.argtypes = [ + ctypes.c_int, + ctypes.c_int, + ctypes.c_uint64, + ctypes.c_void_p, + ctypes.c_int, + ] + libproc.proc_pidinfo.restype = ctypes.c_int + return libproc + + +def darwin_fds_and_threads(libproc, pid: int) -> tuple[int | None, int | None]: + import ctypes + + proc_pidlistfds, proc_pidtaskinfo = 1, 4 + size = libproc.proc_pidinfo(pid, proc_pidlistfds, 0, None, 0) + if size <= 0: + raise OSError("process is gone") + buf = ctypes.create_string_buffer(size) + used = libproc.proc_pidinfo(pid, proc_pidlistfds, 0, ctypes.addressof(buf), size) + if used <= 0: + raise OSError("process is gone") + fds = used // 8 # sizeof(struct proc_fdinfo) + + class TaskInfo(ctypes.Structure): # struct proc_taskinfo + _fields_ = [ + *((name, ctypes.c_uint64) for name in ("vsize", "rsize", "user", "system", "tu", "ts")), + *( + (name, ctypes.c_int32) + for name in ( + "policy", + "faults", + "pageins", + "cow_faults", + "messages_sent", + "messages_received", + "syscalls_mach", + "syscalls_unix", + "csw", + "threadnum", + "numrunning", + "priority", + ) + ), + ] + + info = TaskInfo() + got = libproc.proc_pidinfo( + pid, proc_pidtaskinfo, 0, ctypes.addressof(info), ctypes.sizeof(info) + ) + return fds, (info.threadnum if got == ctypes.sizeof(info) else None) + + +def windows_handle_count(proc: subprocess.Popen) -> int | None: + import ctypes + from ctypes import wintypes + + count = wintypes.DWORD() + handle = wintypes.HANDLE(int(proc._handle)) # Popen keeps the handle open + if not ctypes.windll.kernel32.GetProcessHandleCount(handle, ctypes.byref(count)): + raise OSError("process is gone") + return count.value + + +def windows_memory_and_cpu(proc: subprocess.Popen) -> tuple[int | None, float | None]: + import ctypes + from ctypes import wintypes + + class Counters(ctypes.Structure): # PROCESS_MEMORY_COUNTERS + _fields_ = [ + ("cb", wintypes.DWORD), + ("PageFaultCount", wintypes.DWORD), + ("PeakWorkingSetSize", ctypes.c_size_t), + ("WorkingSetSize", ctypes.c_size_t), + ("QuotaPeakPagedPoolUsage", ctypes.c_size_t), + ("QuotaPagedPoolUsage", ctypes.c_size_t), + ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t), + ("QuotaNonPagedPoolUsage", ctypes.c_size_t), + ("PagefileUsage", ctypes.c_size_t), + ("PeakPagefileUsage", ctypes.c_size_t), + ] + + handle = wintypes.HANDLE(int(proc._handle)) + counters = Counters() + counters.cb = ctypes.sizeof(counters) + rss_kb = None + if ctypes.windll.psapi.GetProcessMemoryInfo(handle, ctypes.byref(counters), counters.cb): + rss_kb = counters.PeakWorkingSetSize // 1024 + + times = [wintypes.FILETIME() for _ in range(4)] # creation, exit, kernel, user + cpu_s = None + if ctypes.windll.kernel32.GetProcessTimes(handle, *(ctypes.byref(t) for t in times)): + ticks = sum((t.dwHighDateTime << 32) | t.dwLowDateTime for t in times[2:]) + cpu_s = ticks / 1e7 # 100 ns units + return rss_kb, cpu_s + + +def run_once(name: str, binary: str, scenario: Scenario) -> RunResult: + result = RunResult(build=name, scenario=scenario.name, sockets=scenario.sockets) + cmd = [ + binary, + *scenario.args(), + "--scripts", + "none", + "--accessible", + "--no-banner", + "--no-config", + ] + env = dict(os.environ, RUST_LOG="rustscan=info") + stdout_lines: list[str] = [] + stderr_chunks: list[bytes] = [] + + start = time.perf_counter() + proc = subprocess.Popen( + cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, env=env + ) + + def read_stdout() -> None: + for raw in proc.stdout: + line = raw.decode("utf-8", "replace").rstrip("\r\n") + if result.first_open_s is None and line.startswith("Open "): + result.first_open_s = time.perf_counter() - start + stdout_lines.append(line) + + readers = [ + threading.Thread(target=read_stdout, daemon=True), + threading.Thread(target=lambda: stderr_chunks.append(proc.stderr.read()), daemon=True), + ] + for reader in readers: + reader.start() + sampler = Sampler(proc) + sampler.start() + # On Unix the child is reaped with os.wait4 below, so the watchdog must + # not go through Popen (which would try to reap it as well). + kill = proc.kill if os.name == "nt" else lambda: os.kill(proc.pid, signal.SIGKILL) + watchdog = threading.Timer(RUN_TIMEOUT, kill) + watchdog.start() + + try: + if hasattr(os, "wait4"): + _, status, usage = os.wait4(proc.pid, 0) + result.wall_s = time.perf_counter() - start + proc.returncode = os.waitstatus_to_exitcode(status) + maxrss = usage.ru_maxrss # KiB on Linux, bytes on macOS + result.rss_kb = maxrss // 1024 if platform.system() == "Darwin" else maxrss + result.cpu_s = usage.ru_utime + usage.ru_stime + else: + proc.wait() + result.wall_s = time.perf_counter() - start + result.rss_kb, result.cpu_s = windows_memory_and_cpu(proc) + finally: + watchdog.cancel() + sampler.done.set() + sampler.join() + for reader in readers: + reader.join() + + result.returncode = proc.returncode + result.peak_fds = sampler.peak_fds + result.peak_threads = sampler.peak_threads + stderr = b"".join(stderr_chunks).decode("utf-8", "replace") + + opened: set[str] = set() + summarised: set[str] = set() + for line in stdout_lines: + if match := OPEN_LINE.match(line): + opened.add(match.group(1)) + elif match := SUMMARY_LINE.match(line): + summarised.update(f"{match.group(1)}:{p}" for p in match.group(2).split(",") if p) + result.open = sorted(opened) + + if scan_time := SCAN_TIME.search(stderr): + result.scan_s = float(scan_time.group(1)) + + problems = [] + if proc.returncode != 0: + problems.append(f"exit code {proc.returncode}") + if result.scan_s is None: + problems.append("no 'Portscan' timer in the log") + if summarised != opened: + problems.append(f"'Open' lines and the summary disagree: {sorted(summarised ^ opened)[:5]}") + if problems: + result.ok = False + tail = "\n".join(stderr.strip().splitlines()[-15:]) + result.error = "; ".join(problems) + (f"\nstderr tail:\n{tail}" if tail else "") + return result + + +# -------------------------------------------------------------------------- +# Environment +# -------------------------------------------------------------------------- + + +def sysctl(*names: str) -> list[str]: + return subprocess.run( + ["sysctl", "-n", *names], capture_output=True, text=True, check=True + ).stdout.split("\n") + + +def ephemeral_range() -> tuple[int, int] | None: + system = platform.system() + try: + if system == "Linux": + with open("/proc/sys/net/ipv4/ip_local_port_range", encoding="ascii") as f: + low, high = map(int, f.read().split()) + return low, high + if system == "Darwin": + first, last = sysctl("net.inet.ip.portrange.first", "net.inet.ip.portrange.last")[:2] + return int(first), int(last) + except (OSError, ValueError, subprocess.CalledProcessError): + return None + return (49152, 65535) if system == "Windows" else None + + +def environment() -> dict[str, object]: + system = platform.system() + info: dict[str, object] = { + "system": system, + "release": platform.release(), + "machine": platform.machine(), + "cpus": os.cpu_count(), + "cpu": platform.processor(), + "python": platform.python_version(), + "ephemeral_ports": ephemeral_range(), + } + try: + if system == "Linux": + with open("/proc/cpuinfo", encoding="ascii", errors="replace") as f: + for line in f: + if line.lower().startswith(("model name", "cpu model")): + info["cpu"] = line.split(":", 1)[1].strip() + break + elif system == "Darwin": + info["cpu"] = sysctl("machdep.cpu.brand_string")[0].strip() + except (OSError, subprocess.CalledProcessError): + pass + try: + import resource + + info["open_file_limit"] = resource.getrlimit(resource.RLIMIT_NOFILE)[0] + except (ImportError, OSError, ValueError): + pass + return info + + +# -------------------------------------------------------------------------- +# Analysis and reporting +# -------------------------------------------------------------------------- + +Results = dict[str, dict[str, list[RunResult]]] + +# key, title, formatter, higher is better +METRICS = ( + ("rate", "Throughput (sockets/s, from RustScan's scan timer)", lambda v: f"{v:,.0f}", True), + ("wall_s", "Wall time of the whole process", lambda v: f"{v * 1000:,.0f} ms", False), + ("first_open_s", "Time to the first open port", lambda v: f"{v * 1000:,.1f} ms", False), + ("rss_kb", "Peak RSS", lambda v: f"{v / 1024:,.1f} MiB", False), + ("cpu_s", "CPU time (user + system)", lambda v: f"{v * 1000:,.0f} ms", False), + ("peak_fds", "Peak open descriptors (sampled; Windows: handles)", lambda v: f"{v:,.0f}", False), + ("peak_threads", "Peak threads (sampled)", lambda v: f"{v:,.0f}", False), +) +FLAG_WORSE = 10.0 # percent + + +def metric_values(runs: list[RunResult], key: str) -> list[float]: + return [float(getattr(r, key)) for r in runs if r.ok and getattr(r, key) is not None] + + +def median(values: list[float]) -> float | None: + return statistics.median(values) if values else None + + +def worse_by(value: float, base: float, higher_is_better: bool) -> float: + """How much worse `value` is than `base`, in percent (negative: better).""" + pct = (value / base - 1.0) * 100.0 + return -pct if higher_is_better else pct + + +def check_open_ports( + results: Results, scenarios: list[Scenario], listeners: dict, baseline: str +) -> tuple[list[str], list[str], list[str]]: + """Returns (failed runs, open-port mismatches, notes). + + No build may miss more listeners than the baseline: none at all if the + baseline found every listener in every run, or barely more if it did not + either (a runner too slow for the scenario's timeout). Ports that are + open beyond the listeners (services already running on the runner) must + match what the baseline found: a build may not miss a port the baseline + found in every run, nor report one the baseline never found. + """ + failures, mismatches, notes = [], [], [] + for sc in scenarios: + expected = sc.expected(listeners) + per_build = results[sc.name] + + def missed(runs: list[RunResult]) -> int: + return sum(len(expected - set(r.open)) for r in runs if r.ok) + + base_missed = missed(per_build[baseline]) + allowed = base_missed + max(3, base_missed // 4) if base_missed else 0 + if base_missed: + notes.append( + f"`{sc.name}`: `{baseline}` itself missed {base_missed} of " + f"{len(expected) * len(per_build[baseline])} listener answers" + ) + + base_extra = [set(r.open) - expected for r in per_build[baseline] if r.ok] + always = set.intersection(*base_extra) if base_extra else set() + ever = set.union(*base_extra) if base_extra else set() + for name, runs in per_build.items(): + for index, res in enumerate(runs, start=1): + if not res.ok: + failures.append(f"`{sc.name}` `{name}` run {index}: {res.error.splitlines()[0]}") + if name == baseline: + continue + if (count := missed(runs)) > allowed: + mismatches.append( + f"`{sc.name}` `{name}`: missed {count} listener answers " + f"(`{baseline}`: {base_missed})" + ) + for index, res in enumerate(runs, start=1): + if not res.ok: + continue + found = set(res.open) + where = f"`{sc.name}` `{name}` run {index}" + if lost := always - found: + mismatches.append(f"{where}: missed ports `{baseline}` always found {sorted(lost)[:5]}") + if bogus := (found - expected) - ever: + mismatches.append(f"{where}: reported ports `{baseline}` never found {sorted(bogus)[:5]}") + return failures, mismatches, notes + + +def gate( + results: Results, + scenarios: list[Scenario], + baseline: str, + candidate: str, + max_slowdown: float, + max_memory: float, +) -> list[str]: + """Scenarios where `candidate` is clearly worse than `baseline`.""" + problems = [] + for sc in scenarios: + checks = [("wall_s", False, max_slowdown, 0.002), ("rss_kb", False, max_memory, 1024)] + if sc.sockets >= 1000: # throughput is meaningless for a handful of sockets + checks.append(("rate", True, max_slowdown, 0)) + for key, higher, limit, floor in checks: + base = median(metric_values(results[sc.name][baseline], key)) + value = median(metric_values(results[sc.name][candidate], key)) + if not base or value is None or abs(value - base) <= floor: + continue + if (worse := worse_by(value, base, higher)) > limit: + problems.append(f"{sc.name}: {key} {worse:+.1f}% worse than {baseline} (limit {limit}%)") + return problems + + +def render_markdown(report: dict, results: Results, scenarios: list[Scenario]) -> str: + builds = report["builds"] + baseline = report["baseline"] + env = report["environment"] + out = [f"### Scan benchmark: {env['system']} {env['machine']}", ""] + out.append( + "; ".join(f"`{b['name']}`: {b['label']}" for b in builds) + + f". {report['runs']} interleaved runs per build and scenario (`tcp-1-port`: x3), medians." + f" Runner: {env.get('cpu') or '?'}, {env['cpus']} CPUs, {env['system']} {env['release']}," + f" open-file limit {env.get('open_file_limit', 'n/a')}." + ) + out.append("") + + problems = report["failures"] + report["mismatches"] + tcp = sum(len(ports) for ports in report["listeners"]["tcp"].values()) + udp = sum(len(ports) for ports in report["listeners"]["udp"].values()) + if not problems: + out.append( + f"✅ Every run of every build exited cleanly; no build missed more of the {tcp} TCP " + f"and {udp} UDP listeners than `{baseline}`, or disagreed with it about other ports." + ) + else: + out.append(f"❌ {len(report['failures'])} failed runs, {len(report['mismatches'])} open-port mismatches:") + out += [f"- {p}" for p in problems[:25]] + if report["notes"]: + out.append("") + out += [f"- ⚠️ {note}" for note in report["notes"]] + out.append("") + out.append("| Scenario | What is scanned |") + out.append("|---|---|") + out += [f"| `{sc.name}` | {sc.describe()} ({sc.sockets:,} sockets) |" for sc in scenarios] + out.append("") + out.append(f"Changes are relative to `{baseline}`; ❗ marks a median more than {FLAG_WORSE:.0f}% worse.") + out.append("") + + for key, title, fmt, higher in METRICS: + rows = [] + for sc in scenarios: + base_value = median(metric_values(results[sc.name][baseline], key)) + cells = [] + for build in builds: + value = median(metric_values(results[sc.name][build["name"]], key)) + if value is None: + cells.append("–") + continue + cell = fmt(value) + if build["name"] != baseline and base_value: + pct = (value / base_value - 1.0) * 100.0 + flag = " ❗" if worse_by(value, base_value, higher) > FLAG_WORSE else "" + cell += f" ({pct:+.1f}%{flag})" + cells.append(cell) + if any(c != "–" for c in cells): + rows.append(f"| `{sc.name}` | " + " | ".join(cells) + " |") + out.append(f"**{title}**") + out.append("") + if rows: + out.append("| Scenario | " + " | ".join(f"`{b['name']}`" for b in builds) + " |") + out.append("|---|" + "---:|" * len(builds)) + out += rows + else: + out.append("_Not measured on this platform._") + out.append("") + return "\n".join(out) + + +# -------------------------------------------------------------------------- +# Main +# -------------------------------------------------------------------------- + + +def parse_pairs(values: list[str], option: str) -> dict[str, str]: + pairs = {} + for value in values: + name, sep, rest = value.partition("=") + if not sep or not name or not rest: + raise SystemExit(f"--{option} expects NAME=VALUE, got {value!r}") + pairs[name] = rest + return pairs + + +def run(args: argparse.Namespace) -> int: + builds = parse_pairs(args.build, "build") + labels = parse_pairs(args.label, "label") + baseline = args.baseline or next(iter(builds)) + if baseline not in builds: + raise SystemExit(f"unknown baseline build {baseline!r}") + for name, path in builds.items(): + if not os.path.isfile(path): + raise SystemExit(f"build {name!r}: {path} does not exist") + if args.gate and args.gate not in builds: + raise SystemExit(f"unknown --gate build {args.gate!r}") + if args.delayed_host and not args.delayed_listeners: + raise SystemExit("--delayed-host needs --delayed-listeners") + + filtered = None + if args.filtered: + low, _, high = args.filtered.partition("-") + filtered = (int(low), int(high)) + + scenarios = build_scenarios(platform.system(), filtered, args.delayed_host, args.delay_ms) + if args.only: + wanted = set(args.only.split(",")) + scenarios = [sc for sc in scenarios if sc.name in wanted] + + if ephemeral := ephemeral_range(): + low, high = ephemeral + for sc in scenarios: + for start, end in sc.ranges: + if start <= high and end >= low: + raise SystemExit( + f"scenario {sc.name} scans {start}-{end}, which overlaps the ephemeral " + f"port range {low}-{high}: a scan could connect a socket to itself" + ) + + server, listeners = start_server([LOOPBACK]) + if args.delayed_host: + # The workflow runs a second `serve` on the delayed host (inside a + # network namespace) and stores its first line in this file. + with open(args.delayed_listeners, encoding="utf-8") as f: + far = json.loads(f.readline()) + for kind in ("tcp", "udp"): + listeners[kind][args.delayed_host] = far[kind].get(args.delayed_host, []) + for kind in ("tcp", "udp"): + counts = {address: len(ports) for address, ports in listeners[kind].items()} + print(f"{kind} listeners: {counts}", flush=True) + if listeners["busy"]: + print(f"already in use: {listeners['busy']}", flush=True) + + names = list(builds) + results: Results = {sc.name: {name: [] for name in names} for sc in scenarios} + try: + warmup = next((sc for sc in scenarios if sc.name == "tcp-sweep"), scenarios[0]) + for name in names: + run_once(name, builds[name], warmup) + + rounds = args.runs * max(sc.repeat for sc in scenarios) + for index in range(rounds): + shift = index % len(names) + order = names[shift:] + names[:shift] + for sc in scenarios: + if index >= args.runs * sc.repeat: + continue + for name in order: + res = run_once(name, builds[name], sc) + results[sc.name][name].append(res) + print( + f"[{index + 1}/{rounds}] {sc.name:<17} {name:<14} ok={res.ok} " + f"scan={res.scan_s} wall={res.wall_s} first={res.first_open_s} " + f"rss={res.rss_kb} cpu={res.cpu_s} fds={res.peak_fds} " + f"threads={res.peak_threads} open={len(res.open)}", + flush=True, + ) + if not res.ok: + print(f" {res.error}", flush=True) + finally: + server.kill() + server.wait() + + failures, mismatches, notes = check_open_ports(results, scenarios, listeners, baseline) + report = { + "environment": environment(), + "baseline": baseline, + "builds": [{"name": n, "label": labels.get(n, n), "path": builds[n]} for n in names], + "runs": args.runs, + "listeners": listeners, + "scenarios": [asdict(sc) | {"sockets": sc.sockets} for sc in scenarios], + "results": { + sc: {n: [asdict(r) | {"rate": r.rate} for r in runs] for n, runs in per.items()} + for sc, per in results.items() + }, + "failures": failures, + "mismatches": mismatches, + "notes": notes, + } + markdown = render_markdown(report, results, scenarios) + if args.json: + with open(args.json, "w", encoding="utf-8") as f: + json.dump(report, f, indent=1) + if args.markdown: + with open(args.markdown, "w", encoding="utf-8") as f: + f.write(markdown + "\n") + print(markdown) + + status = 1 if failures or mismatches else 0 + if args.gate: + for problem in gate(results, scenarios, baseline, args.gate, args.max_slowdown, args.max_memory): + print(f"::error::performance regression: {problem}", flush=True) + status = 1 + return status + + +def main() -> int: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + sub = parser.add_subparsers(dest="command", required=True) + listen = sub.add_parser("serve", help="run the loopback listeners (started by `run`)") + listen.add_argument("--address", action="append", default=[], help="address to listen on") + bench = sub.add_parser("run", help="benchmark the given builds") + bench.add_argument("--build", action="append", required=True, metavar="NAME=PATH") + bench.add_argument("--label", action="append", default=[], metavar="NAME=TEXT") + bench.add_argument("--baseline", help="build the others are compared with (default: the first)") + bench.add_argument("--runs", type=int, default=9, help="interleaved runs per build and scenario") + bench.add_argument( + "--filtered", metavar="START-END", help="ports the runner drops; enables filtered scenarios" + ) + bench.add_argument( + "--delayed-host", + metavar="ADDRESS", + help="address behind an artificial delay, with its own `serve`; enables delayed scenarios", + ) + bench.add_argument( + "--delayed-listeners", metavar="FILE", help="first output line of that host's `serve`" + ) + bench.add_argument("--delay-ms", type=int, default=5, help="that delay, for the summary") + bench.add_argument("--only", metavar="NAMES", help="comma-separated scenarios to run") + bench.add_argument("--json", help="write the raw results to this file") + bench.add_argument("--markdown", help="write the Markdown summary to this file") + bench.add_argument("--gate", metavar="NAME", help="fail if this build regresses against the baseline") + bench.add_argument("--max-slowdown", type=float, default=15.0, help="percent, throughput and wall time") + bench.add_argument("--max-memory", type=float, default=25.0, help="percent, peak RSS") + args = parser.parse_args() + # The summary contains emoji; Windows runners default to a legacy code page. + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + return serve(args.address or [LOOPBACK]) if args.command == "serve" else run(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/scan-benchmark.yml b/.github/workflows/scan-benchmark.yml new file mode 100644 index 0000000..83370c0 --- /dev/null +++ b/.github/workflows/scan-benchmark.yml @@ -0,0 +1,247 @@ +name: Scan benchmark + +# Compares the scanning speed and resource use of a change with its base. +# +# Each run builds both versions in release mode and runs +# .github/scan-benchmark/scan_bench.py, which scans listeners it opens on +# 127.0.0.1 inside the runner (plus closed and, on Linux, filtered ports) and +# reports medians over interleaved runs. Nothing outside the runner is +# scanned. The unit tests deliberately never open sockets; this workflow is +# where scanning behaviour and performance are measured. +# +# It only runs for pull requests that touch the scanner, its runtime or its +# dependencies, or when started by hand, so it does not slow down normal CI. + +on: + pull_request: + branches: [master] + paths: + - "src/scanner/**" + - "src/main.rs" + - "src/lib.rs" + - "Cargo.toml" + - "Cargo.lock" + - ".github/scan-benchmark/**" + - ".github/workflows/scan-benchmark.yml" + workflow_dispatch: + inputs: + base: + description: "Branch, tag or commit to compare against" + default: "master" + runs: + description: "Interleaved runs per build and scenario" + default: "9" + extra: + description: "More builds to compare, as space-separated NAME=REF pairs" + default: "" + +permissions: + contents: read + +concurrency: + group: scan-benchmark-${{ github.ref }} + cancel-in-progress: true + +jobs: + benchmark: + name: Benchmark (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest] + defaults: + run: + shell: bash + env: + # Ports the Linux runners drop, to measure filtered ports (timeouts). + # They lie above Linux's ephemeral port range (32768-60999). + FILTERED_PORTS: "61000-62999" + # The Linux runners' host behind a 5 ms delay (see "Set up a host 5 ms away"). + DELAYED_HOST: "10.200.0.2" + RUNS: ${{ inputs.runs || '9' }} + EXTRA: ${{ inputs.extra }} + steps: + - name: Checkout sources + uses: actions/checkout@v7 + + - name: Setup Python + uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - name: Check the benchmark script + run: python -m py_compile .github/scan-benchmark/scan_bench.py + + - name: Setup Rust + uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0 + with: + toolchain: stable + # build.rs formats the generated payload table with rustfmt. + components: rustfmt + # Caching is handled by Swatinem/rust-cache below. + cache: false + rustflags: "" + + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + + - name: Build the change + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_HEAD: ${{ github.event.pull_request.head.sha }} + run: | + exe="" + if [ "$RUNNER_OS" = Windows ]; then exe=".exe"; fi + echo "EXE=$exe" >> "$GITHUB_ENV" + mkdir -p "$RUNNER_TEMP/bin" + cargo build --release --locked + cp "target/release/rustscan$exe" "$RUNNER_TEMP/bin/head$exe" + if [ -n "$PR_NUMBER" ]; then + label="#$PR_NUMBER at ${PR_HEAD::7}, merged into its base" + else + label="$(git log -1 --format='%h %s' HEAD)" + fi + echo "HEAD_LABEL=$label" >> "$GITHUB_ENV" + + - name: Build the base and any extra builds + env: + EVENT: ${{ github.event_name }} + PR_BASE: ${{ github.event.pull_request.base.sha }} + INPUT_BASE: ${{ inputs.base }} + run: | + build_ref() { + local name="$1" ref="$2" + if [[ ! "$name" =~ ^[A-Za-z0-9._+-]+$ ]]; then + echo "::error::invalid build name '$name'" + exit 1 + fi + git fetch --no-tags --depth=1 origin "$ref" + git worktree add --detach "$RUNNER_TEMP/src-$name" FETCH_HEAD + cargo build --release --locked \ + --manifest-path "$RUNNER_TEMP/src-$name/Cargo.toml" --target-dir target + cp "target/release/rustscan$EXE" "$RUNNER_TEMP/bin/$name$EXE" + echo "$name=$(git log -1 --format='%h %s' FETCH_HEAD)" >> "$RUNNER_TEMP/labels" + } + + if [ "$EVENT" = pull_request ]; then base="$PR_BASE"; else base="${INPUT_BASE:-master}"; fi + build_ref base "$base" + for spec in $EXTRA; do + build_ref "${spec%%=*}" "${spec#*=}" + done + cat "$RUNNER_TEMP/labels" + + - name: Drop traffic to the filtered ports + if: runner.os == 'Linux' + run: | + if ! command -v iptables >/dev/null; then + echo "::warning::iptables is not available; skipping the filtered-port scenarios" + exit 0 + fi + for proto in tcp udp; do + sudo iptables -I INPUT -i lo -p "$proto" --dport "${FILTERED_PORTS/-/:}" -j DROP + done + sudo iptables -S INPUT + echo "FILTERED_OK=1" >> "$GITHUB_ENV" + + - name: Set up a host 5 ms away + if: runner.os == 'Linux' + run: | + # A network namespace on the runner, reached over a veth pair whose + # outbound side delays every packet. Its answers (SYN-ACKs, resets, + # ICMP errors, UDP replies) arrive a round trip after the probe, as + # from a remote host, instead of while the probe is being sent. + skip() { + echo "::warning::$1; skipping the delayed-host scenarios" + exit 0 + } + sudo ip netns add far || skip "cannot create a network namespace" + sudo ip link add rs-near type veth peer name rs-far || skip "cannot create a veth pair" + sudo ip link set rs-far netns far || skip "cannot move the veth end" + sudo ip addr add 10.200.0.1/30 dev rs-near || skip "cannot address the veth pair" + sudo ip link set rs-near up || skip "cannot bring up the veth pair" + sudo ip netns exec far ip addr add "$DELAYED_HOST/30" dev rs-far || skip "cannot address the namespace" + sudo ip netns exec far ip link set rs-far up || skip "cannot bring up the namespace" + sudo ip netns exec far ip link set lo up || true + # Answer every probe to a closed UDP port, like the loopback + # interface does (ICMP errors are rate limited everywhere else). + for setting in icmp_ratelimit=0 icmp_msgs_per_sec=100000000 icmp_msgs_burst=100000000; do + sudo ip netns exec far sysctl -qw "net.ipv4.$setting" || true + done + sudo tc qdisc add dev rs-near root netem delay 5ms limit 1000000 || skip "netem is not available" + tc qdisc show dev rs-near + + sudo ip netns exec far "$(command -v python)" .github/scan-benchmark/scan_bench.py \ + serve --address "$DELAYED_HOST" > "$RUNNER_TEMP/far.json" 2> "$RUNNER_TEMP/far.log" & + for _ in $(seq 100); do + if [ -s "$RUNNER_TEMP/far.json" ]; then break; fi + sleep 0.1 + done + if [ ! -s "$RUNNER_TEMP/far.json" ]; then + cat "$RUNNER_TEMP/far.log" || true + skip "the delayed host's listeners did not start" + fi + head -c 200 "$RUNNER_TEMP/far.json"; echo + echo "DELAYED_OK=1" >> "$GITHUB_ENV" + + - name: Describe the runner + continue-on-error: true + run: | + uname -a + if [ "$RUNNER_OS" = Linux ]; then + nproc + grep -m1 'model name' /proc/cpuinfo || true + sysctl net.ipv4.ip_local_port_range net.ipv4.tcp_tw_reuse net.core.somaxconn || true + ss -Hltnu | sort -k5 | head -n 50 || true + elif [ "$RUNNER_OS" = macOS ]; then + for oid in hw.ncpu machdep.cpu.brand_string kern.maxfilesperproc \ + net.inet.ip.portrange.first net.inet.ip.portrange.last; do + sysctl "$oid" || true + done + fi + + - name: Run the benchmark + run: | + # Allow the largest batch size without RustScan shrinking it to fit + # the descriptor limit (macOS defaults to 256, and caps processes at + # kern.maxfilesperproc, 10240 on its runners). + if [ "$RUNNER_OS" != Windows ]; then + for limit in 65536 24576 10240; do + ulimit -n "$limit" 2>/dev/null && break + done + echo "open-file limit: $(ulimit -n)" + fi + + # Fail on wrong results, or if the change is clearly slower or + # bigger than its base (see --max-slowdown/--max-memory). + args=(--runs "$RUNS" --gate head --json bench-results.json --markdown bench-results.md) + if [ -n "${FILTERED_OK:-}" ]; then args+=(--filtered "$FILTERED_PORTS"); fi + if [ -n "${DELAYED_OK:-}" ]; then + args+=(--delayed-host "$DELAYED_HOST" --delayed-listeners "$RUNNER_TEMP/far.json") + fi + args+=(--build "base=$RUNNER_TEMP/bin/base$EXE") + args+=(--build "head=$RUNNER_TEMP/bin/head$EXE" --label "head=$HEAD_LABEL") + while IFS= read -r line; do + name="${line%%=*}" + args+=(--label "$line") + if [ "$name" != base ]; then + args+=(--build "$name=$RUNNER_TEMP/bin/$name$EXE") + fi + done < "$RUNNER_TEMP/labels" + + python .github/scan-benchmark/scan_bench.py run "${args[@]}" + + - name: Publish the summary + if: always() + run: | + if [ -f bench-results.md ]; then cat bench-results.md >> "$GITHUB_STEP_SUMMARY"; fi + + - name: Upload the results + if: always() + uses: actions/upload-artifact@v7 + with: + name: scan-benchmark-${{ matrix.os }} + path: | + bench-results.json + bench-results.md + if-no-files-found: ignore diff --git a/Cargo.lock b/Cargo.lock index bc98e95..fae4626 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -118,119 +118,6 @@ version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - -[[package]] -name = "async-channel" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89b47800b0be77592da0afd425cc03468052844aff33b84e33cc696f64e77b6a" -dependencies = [ - "concurrent-queue", - "event-listener-strategy", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-executor" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" -dependencies = [ - "async-task", - "concurrent-queue", - "fastrand", - "futures-lite", - "slab", -] - -[[package]] -name = "async-global-executor" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" -dependencies = [ - "async-channel 2.3.1", - "async-executor", - "async-io", - "async-lock", - "blocking", - "futures-lite", - "once_cell", -] - -[[package]] -name = "async-io" -version = "2.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43a2b323ccce0a1d90b449fd71f2a06ca7faa7c54c2751f06c9bd851fc061059" -dependencies = [ - "async-lock", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite", - "parking", - "polling", - "rustix 0.38.44", - "slab", - "tracing", - "windows-sys 0.59.0", -] - -[[package]] -name = "async-lock" -version = "3.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" -dependencies = [ - "event-listener 5.4.0", - "event-listener-strategy", - "pin-project-lite", -] - -[[package]] -name = "async-std" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" -dependencies = [ - "async-channel 1.9.0", - "async-global-executor", - "async-io", - "async-lock", - "crossbeam-utils", - "futures-channel", - "futures-core", - "futures-io", - "futures-lite", - "gloo-timers", - "kv-log-macro", - "log", - "memchr", - "once_cell", - "pin-project-lite", - "pin-utils", - "slab", - "wasm-bindgen-futures", -] - -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" version = "0.1.86" @@ -242,12 +129,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - [[package]] name = "autocfg" version = "1.4.0" @@ -281,19 +162,6 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f68f53c83ab957f72c32642f3868eec03eb974d1fb82e453128456482613d36" -[[package]] -name = "blocking" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" -dependencies = [ - "async-channel 2.3.1", - "async-task", - "futures-io", - "futures-lite", - "piper", -] - [[package]] name = "bumpalo" version = "3.17.0" @@ -450,15 +318,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ffb474a9c3219a8254ead020421ecf1b90427f29b55f6aae9a2471fa62c126ef" -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -629,39 +488,6 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "event-listener" -version = "2.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" - -[[package]] -name = "event-listener" -version = "5.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae" -dependencies = [ - "concurrent-queue", - "parking", - "pin-project-lite", -] - -[[package]] -name = "event-listener-strategy" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3e4e0dd3673c1139bf041f3008816d9cf2946bbfac2945c09e523b8d7b05b2" -dependencies = [ - "event-listener 5.4.0", - "pin-project-lite", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - [[package]] name = "foldhash" version = "0.1.5" @@ -685,7 +511,6 @@ checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", - "futures-executor", "futures-io", "futures-sink", "futures-task", @@ -708,47 +533,12 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - [[package]] name = "futures-io" version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" -[[package]] -name = "futures-lite" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5edaec856126859abb19ed65f39e90fea3a9574b9707f13539acf4abf7eb532" -dependencies = [ - "fastrand", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "futures-sink" version = "0.3.31" @@ -770,7 +560,6 @@ dependencies = [ "futures-channel", "futures-core", "futures-io", - "futures-macro", "futures-sink", "futures-task", "memchr", @@ -816,18 +605,6 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "half" version = "2.4.1" @@ -874,12 +651,6 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" -[[package]] -name = "hermit-abi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbf6a919d6cf397374f7dfeeea91d974c7c0a7221d0d0f4f20d859d329e53fcc" - [[package]] name = "hickory-proto" version = "0.24.4" @@ -1190,15 +961,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "kv-log-macro" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" -dependencies = [ - "log", -] - [[package]] name = "leb128fmt" version = "0.1.0" @@ -1227,12 +989,6 @@ version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" -[[package]] -name = "linux-raw-sys" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" - [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1260,9 +1016,6 @@ name = "log" version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" -dependencies = [ - "value-bag", -] [[package]] name = "lru-cache" @@ -1391,12 +1144,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - [[package]] name = "parking_lot" version = "0.12.3" @@ -1438,17 +1185,6 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" -dependencies = [ - "atomic-waker", - "fastrand", - "futures-io", -] - [[package]] name = "plotters" version = "0.3.7" @@ -1477,21 +1213,6 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "polling" -version = "3.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a604568c3202727d1507653cb121dbd627a58684eb09a820fd746bee38b4442f" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 0.38.44", - "tracing", - "windows-sys 0.59.0", -] - [[package]] name = "portable-atomic" version = "1.11.0" @@ -1711,19 +1432,6 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" -[[package]] -name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - [[package]] name = "rustix" version = "1.1.3" @@ -1733,7 +1441,7 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.60.2", ] @@ -1775,7 +1483,6 @@ dependencies = [ "ansi_term", "anstream", "anyhow", - "async-std", "cidr-utils", "clap", "colored", @@ -1795,6 +1502,7 @@ dependencies = [ "serde", "serde_derive", "text_placeholder", + "tokio", "toml", ] @@ -1974,7 +1682,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" dependencies = [ - "rustix 1.1.3", + "rustix", "windows-sys 0.60.2", ] @@ -2206,12 +1914,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "value-bag" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" - [[package]] name = "version_check" version = "0.9.5" @@ -2278,19 +1980,6 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "wasm-bindgen-macro" version = "0.2.100" diff --git a/Cargo.toml b/Cargo.toml index dffbb1c..947d7c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,8 +19,11 @@ exclude = [ [dependencies] clap = { version = "4.6.1", features = ["derive", "wrap_help"] } colored = "3.1.1" -async-std = "1.13.2" -futures = "0.3" +# Async runtime for the scanner. hickory-resolver already depends on tokio, so +# this adds no crates; only the features the scanner itself uses are listed. +tokio = { version = "1.44.2", features = ["rt", "net", "time"] } +# Only `FuturesUnordered`/`StreamExt` are used; the executor is not needed. +futures = { version = "0.3", default-features = false, features = ["std"] } rlimit = "0.11.0" log = "0.4.32" env_logger = "0.11.10" diff --git a/contributing.md b/contributing.md index f605de4..f88ae53 100644 --- a/contributing.md +++ b/contributing.md @@ -72,3 +72,12 @@ Automated tests and benchmarks must run without network traffic or external scri execution. Keep coverage focused on parsing, configuration, port ordering, socket address iteration, and timing helpers. Do not add tests that run TCP/UDP scans (including localhost), resolve hostnames, or launch Nmap or other scripts. + +Scanning behaviour and speed are measured separately, by the opt-in "Scan benchmark" +GitHub Actions workflow (`.github/workflows/scan-benchmark.yml`). It runs for pull +requests that touch the scanner, its runtime or its dependencies, and can be started +by hand from the Actions tab. It builds the change and its base in release mode, +scans listeners that it opens on 127.0.0.1 inside the runner, checks that both +builds find the same open ports, and adds a comparison table to the job summary. Its +script (`.github/scan-benchmark/scan_bench.py`) port-scans the loopback interface, +so it is meant for disposable CI runners only. diff --git a/src/address.rs b/src/address.rs index 7547233..df07435 100644 --- a/src/address.rs +++ b/src/address.rs @@ -30,6 +30,14 @@ use crate::warning; /// ``` /// /// Finally, any duplicates are removed to avoid excessive scans. +/// +/// # Blocking +/// +/// This function blocks: it reads files and resolves host names, and +/// hickory's synchronous [`Resolver`] runs a Tokio runtime of its own, which +/// Tokio refuses to drop inside an asynchronous context. Call it before you +/// 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 { let mut ips: Vec = Vec::new(); let mut unresolved_addresses: Vec<&str> = Vec::new(); diff --git a/src/lib.rs b/src/lib.rs index ca31a91..5d2e650 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,12 +5,17 @@ //! //! The core scanning behaviour is managed by //! [`Scanner`](crate::scanner::Scanner) which in turn requires a -//! [`PortStrategy`](crate::port_strategy::PortStrategy): +//! [`PortStrategy`](crate::port_strategy::PortStrategy). +//! +//! The scanner is asynchronous and runs on [Tokio](https://docs.rs/tokio): +//! await [`Scanner::run`](crate::scanner::Scanner::run) from a Tokio runtime +//! with the I/O and time drivers enabled. It never spawns tasks, so a +//! current-thread runtime is enough (see [`Scanner`](crate::scanner::Scanner) +//! for details). //! //! This example is compiled but never executed by the test suite. //! //! ```no_run -//! use async_std::task::block_on; //! use std::{net::IpAddr, time::Duration}; //! //! use rustscan::input::{PortRanges, ScanOrder}; @@ -33,7 +38,13 @@ //! false, // is this a UDP scan? //! ); //! -//! let scan_result = block_on(scanner.run()); +//! // Inside an async context (e.g. `#[tokio::main]`) this is simply +//! // `scanner.run().await`. +//! let runtime = tokio::runtime::Builder::new_current_thread() +//! .enable_all() +//! .build() +//! .unwrap(); +//! let scan_result = runtime.block_on(scanner.run()); //! //! println!("{:?}", scan_result); //! } diff --git a/src/main.rs b/src/main.rs index 743de46..d0c8add 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,7 +11,6 @@ use rustscan::tui::println_safe; use rustscan::{detail, funny_opening, output, warning}; use colorful::{Color, Colorful}; -use futures::executor::block_on; use std::collections::HashMap; use std::net::IpAddr; use std::string::ToString; @@ -108,7 +107,13 @@ fn main() { debug!("Scanner finished building: {scanner:?}"); let mut portscan_bench = NamedTimer::start("Portscan"); - let scan_result = block_on(scanner.run_with_status()); + let scan_result = match run_scan(&scanner) { + Ok(scan_result) => scan_result, + Err(e) => { + eprintln!("error: could not start the scan: {e}"); + std::process::exit(1); + } + }; portscan_bench.end(); benchmarks.push(portscan_bench); @@ -236,6 +241,25 @@ fn effective_batch_size(opts: &Opts) -> usize { } } +/// Runs the scan to completion on a current-thread Tokio runtime. +/// +/// The scanner drives every socket from a single future and never spawns +/// tasks, so one thread is all it can use: a current-thread runtime runs the +/// scan, the I/O driver and the timers on the calling thread, without the +/// worker threads and cross-thread wake-ups of a multi-threaded runtime. +/// +/// The runtime only exists for the duration of the scan. Address resolution +/// (blocking DNS lookups and file reads) happens before it is created, so it +/// can never stall the scan, and scripts run after it is gone. +fn run_scan(scanner: &Scanner) -> std::io::Result> { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build()?; + + Ok(runtime.block_on(scanner.run_with_status())) +} + /// Prints the opening title of RustScan #[allow(clippy::items_after_statements, clippy::needless_raw_string_hashes)] fn print_opening(opts: &Opts) { diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 85c27fd..cd0f749 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -7,20 +7,30 @@ use log::debug; mod socket_iterator; use socket_iterator::SocketIterator; -use async_std::net::TcpStream; -use async_std::prelude::*; -use async_std::task::sleep; -use async_std::{io, net::UdpSocket}; use colored::Colorize; -use futures::stream::FuturesUnordered; +use futures::stream::{FuturesUnordered, StreamExt}; use std::collections::BTreeMap; +use std::future::poll_fn; +use std::task::Poll; use std::{ collections::{HashMap, HashSet}, - net::{IpAddr, Shutdown, SocketAddr}, + io, + net::{IpAddr, Ipv4Addr, Ipv6Addr, Shutdown, SocketAddr}, num::NonZeroU8, sync::Arc, time::Duration, }; +use tokio::io::Interest; +use tokio::net::{TcpStream, UdpSocket}; +use tokio::time::{sleep, timeout}; + +/// How many sockets the scan starts or finishes (counted together) between +/// two polls of the runtime's I/O driver; see `Scanner::scan_sockets`. +/// +/// Every round ends with a non-blocking poll of the OS selector, so this +/// keeps the overhead far below 1% while bounding the time between two +/// polls to about a millisecond on Linux. +const WORK_PER_TURN: usize = 128; /// UDP payload lookup: port -> payload bytes /// @@ -50,6 +60,19 @@ pub fn build_udp_payload_lookup(udp_map: &'static BTreeMap, Vec>) - /// batch_size is how many ports at a time should be scanned /// Timeout is the time RustScan should wait before declaring a port closed. As datatype Duration. /// greppable is whether or not RustScan should print things, or wait until the end to print only the ip and open ports. +/// +/// # Runtime +/// +/// The futures returned by [`Scanner::run`] and [`Scanner::run_with_status`] +/// use Tokio sockets and timers, so they must be polled from within a +/// [Tokio](https://docs.rs/tokio) runtime that has both the I/O and the time +/// drivers enabled (for example `#[tokio::main]`, or a runtime built with +/// `enable_all()`). Polling them from another executor panics. +/// +/// The scan never spawns tasks: every socket is driven from the one future +/// you await, so a current-thread runtime is enough (it is what the +/// `rustscan` binary uses), and the future is `Send` if you prefer to spawn +/// it on a multi-threaded runtime. #[cfg(not(tarpaulin_include))] #[derive(Debug)] pub struct Scanner { @@ -157,6 +180,16 @@ impl Scanner { /// definitive answer: open sockets, plus closed sockets when /// [`Self::with_closed_ports`] is enabled. pub async fn run_with_status(&self) -> Vec { + // Every in-flight socket is polled from this single future through a + // `FuturesUnordered`. Under Tokio's cooperative budget the future + // would be forced to yield after ~128 sockets made progress, and the + // `FuturesUnordered` would then re-poll every other ready socket just + // to have it return `Pending` again. Opt out; `scan_sockets` yields to + // the runtime by itself, in rounds of `WORK_PER_TURN` sockets. + tokio::task::unconstrained(self.scan()).await + } + + async fn scan(&self) -> Vec { let ports: Vec = self .port_strategy .order() @@ -207,6 +240,15 @@ impl Scanner { /// Scans every socket yielded by `sockets`, keeping at most `batch_size` /// connection attempts in flight. + /// + /// Starts sockets and handles finished ones in rounds of at most + /// [`WORK_PER_TURN`] of either, and lets the runtime poll for I/O and fire + /// timers between rounds. One poll of the scan future otherwise lasts + /// until no socket is ready, which can take long: starting a whole large + /// batch at once, or a run of UDP probes that all finish straight away. + /// Sockets that finish in the meantime are only noticed once the poll + /// ends, so their results come late, and if the poll outlasts the timeout + /// their timers fire before their answers are seen. async fn scan_sockets( &self, mut sockets: SocketIterator<'_>, @@ -216,26 +258,58 @@ impl Scanner { ) { let mut ftrs = FuturesUnordered::new(); - for _ in 0..self.batch_size { - if let Some(socket) = sockets.next() { - ftrs.push(self.scan_socket(socket, udp_payloads.clone())); + loop { + let mut work = 0; + while work < WORK_PER_TURN { + let mut started = false; + if ftrs.len() < self.batch_size { + if let Some(socket) = sockets.next() { + ftrs.push(self.scan_socket(socket, udp_payloads.clone())); + started = true; + work += 1; + } + } + + // Poll once without waiting; this starts the new socket. + match poll_fn(|cx| Poll::Ready(ftrs.poll_next_unpin(cx))).await { + Poll::Ready(Some(result)) => { + self.record(result, found_sockets, errors); + work += 1; + } + // Nothing in flight and nothing left to start. + Poll::Ready(None) => return, + // Nothing has finished; keep starting sockets while there is room. + Poll::Pending if started => {} + Poll::Pending => break, + } + } + + if work >= WORK_PER_TURN { + tokio::task::yield_now().await; } else { - break; + // The batch is full (or complete) and every socket in it is + // waiting on the network. + match ftrs.next().await { + Some(result) => self.record(result, found_sockets, errors), + None => return, + } } } + } - while let Some(result) = ftrs.next().await { - if let Some(socket) = sockets.next() { - ftrs.push(self.scan_socket(socket, udp_payloads.clone())); - } - - match result { - Ok(status) => found_sockets.push(status), - Err(e) => { - let error_string = e.to_string(); - if errors.len() < self.ips.len() * 1000 { - errors.insert(error_string); - } + /// Records the outcome of one socket. + fn record( + &self, + result: io::Result, + found_sockets: &mut Vec, + errors: &mut HashSet, + ) { + match result { + Ok(status) => found_sockets.push(status), + Err(e) => { + let error_string = e.to_string(); + if errors.len() < self.ips.len() * 1000 { + errors.insert(error_string); } } } @@ -269,7 +343,7 @@ impl Scanner { match self.connect(socket).await { Ok(tcp_stream) => { debug!("Connection was successful, shutting down stream {}", socket); - if let Err(e) = tcp_stream.shutdown(Shutdown::Both) { + if let Err(e) = shutdown_both(tcp_stream) { debug!("Shutdown stream error {}", e); } self.fmt_ports(socket); @@ -339,35 +413,22 @@ impl Scanner { /// ``` /// async fn connect(&self, socket: SocketAddr) -> io::Result { - let stream = io::timeout( - self.timeout, - async move { TcpStream::connect(socket).await }, - ) - .await?; - Ok(stream) + timeout(self.timeout, TcpStream::connect(socket)) + .await + .unwrap_or_else(|_elapsed| Err(timed_out())) } - /// Binds to a UDP socket so we can send and receive packets - /// # Example - /// - /// ```compile_fail - /// # use std::net::{IpAddr, Ipv6Addr, SocketAddr}; - /// let port: u16 = 80; - /// // ip is an IpAddr type - /// let ip = IpAddr::V6(Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 1)); - /// let socket = SocketAddr::new(ip, port); - /// scanner.udp_bind(socket); - /// // returns Result which is either Ok(stream) for port is open, or Err for port is closed. - /// // Timeout occurs after self.timeout seconds - /// ``` - /// - async fn udp_bind(&self, socket: SocketAddr) -> io::Result { + /// Binds a non-blocking UDP socket on the unspecified address of the + /// target's address family, so we can send and receive packets. + fn udp_bind(socket: SocketAddr) -> io::Result { let local_addr = match socket { - SocketAddr::V4(_) => "0.0.0.0:0".parse::().unwrap(), - SocketAddr::V6(_) => "[::]:0".parse::().unwrap(), + SocketAddr::V4(_) => SocketAddr::from((Ipv4Addr::UNSPECIFIED, 0)), + SocketAddr::V6(_) => SocketAddr::from((Ipv6Addr::UNSPECIFIED, 0)), }; - UdpSocket::bind(local_addr).await + let udp_socket = std::net::UdpSocket::bind(local_addr)?; + udp_socket.set_nonblocking(true)?; + Ok(udp_socket) } /// Performs a UDP scan on the specified socket with a payload and wait duration @@ -391,32 +452,59 @@ impl Scanner { payload: &[u8], wait: Duration, ) -> io::Result { - match self.udp_bind(socket).await { - Ok(udp_socket) => { - let mut buf = [0u8; 1024]; - - udp_socket.connect(socket).await?; - udp_socket.send(payload).await?; - - match io::timeout(wait, udp_socket.recv(&mut buf)).await { - Ok(size) => { - debug!("Received {size} bytes"); - self.fmt_ports(socket); - Ok(true) - } - Err(e) => { - if e.kind() == io::ErrorKind::TimedOut { - Ok(false) - } else { - Err(e) - } - } - } - } + let udp_socket = match Self::udp_bind(socket) { + Ok(udp_socket) => udp_socket, Err(e) => { debug!("Error binding UDP socket: {e:?}"); - Err(e) + return Err(e); } + }; + let mut buf = [0u8; 1024]; + + udp_socket.connect(socket)?; + + // Send the probe and try the first receive straight away, as + // async-std did. Tokio's readiness-based I/O would first wait for the + // reactor to report the socket ready, costing every probe extra trips + // through the event loop, while the probe can almost always be sent + // immediately and, on the local host, the answer (often an ICMP "port + // unreachable", seen as a refused connection) is usually already + // there when the send returns. The socket is only registered with + // Tokio when we really have to wait. + let sent = match udp_socket.send(payload) { + Ok(_) => true, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => false, + Err(e) => return Err(e), + }; + let early = if sent { + udp_socket.recv(&mut buf) + } else { + Err(io::ErrorKind::WouldBlock.into()) + }; + + let received = match early { + Err(e) if e.kind() == io::ErrorKind::WouldBlock => { + let udp_socket = UdpSocket::from_std(udp_socket)?; + if !sent { + udp_socket.send(payload).await?; + } + match timeout(wait, recv_or_error(&udp_socket, &mut buf)).await { + Ok(received) => received, + // Nothing came back in time. + Err(_elapsed) => return Ok(false), + } + } + early => early, + }; + + match received { + Ok(size) => { + debug!("Received {size} bytes"); + self.fmt_ports(socket); + Ok(true) + } + Err(e) if e.kind() == io::ErrorKind::TimedOut => Ok(false), + Err(e) => Err(e), } } @@ -443,14 +531,68 @@ impl Scanner { } } +/// Shuts down both halves of a connected stream before it is closed, as the +/// async-std implementation did (`TcpStream::shutdown(Shutdown::Both)`). +/// +/// Tokio only offers an asynchronous write-side shutdown, so take the socket +/// back from the reactor and shut it down synchronously instead. +fn shutdown_both(stream: TcpStream) -> io::Result<()> { + stream.into_std()?.shutdown(Shutdown::Both) +} + +/// Waits for a datagram on a connected UDP socket, or for the error the +/// system queued for it: an ICMP "port unreachable" is reported as a refused +/// connection, which is how closed UDP ports are told apart from filtered +/// ones. +/// +/// `UdpSocket::recv` alone does not do this on Linux: a queued ICMP error +/// only raises `EPOLLERR`, which Tokio does not count as readable, so `recv` +/// would sleep until the timeout. async-io treated `EPOLLERR` as readable. +async fn recv_or_error(socket: &UdpSocket, buf: &mut [u8]) -> io::Result { + loop { + let ready = socket.ready(Interest::READABLE | Interest::ERROR).await?; + + if ready.is_readable() { + match socket.try_recv(buf) { + // A spurious wake-up; wait again (unless the socket is closed + // for reading, which would wake us up forever). + Err(e) if e.kind() == io::ErrorKind::WouldBlock && !ready.is_read_closed() => {} + received => return received, + } + } + + if ready.is_error() { + // Take (and clear) the queued error. `WouldBlock` when there is + // none makes Tokio clear the error readiness, so this cannot spin. + let queued = socket.try_io(Interest::ERROR, || { + socket + .take_error()? + .map_or_else(|| Err(io::ErrorKind::WouldBlock.into()), Ok) + }); + match queued { + Ok(error) => return Err(error), + Err(e) if e.kind() == io::ErrorKind::WouldBlock => {} + Err(e) => return Err(e), + } + } + } +} + +/// The error reported for a connection attempt that hit the timeout; the same +/// kind and message async-std's `io::timeout` used. +fn timed_out() -> io::Error { + io::Error::new(io::ErrorKind::TimedOut, "future timed out") +} + #[cfg(test)] mod tests { use super::*; use crate::input::{PortRanges, ScanOrder}; - // These tests never open sockets: they only build a `Scanner` or inspect - // the payload table generated by build.rs. + // These tests never open sockets: they only build a `Scanner` (and its + // futures), run a scan that has no socket to scan, or inspect the payload + // table generated by build.rs. fn test_scanner() -> Scanner { let addrs = vec!["127.0.0.1".parse::().unwrap()]; @@ -504,6 +646,55 @@ mod tests { assert_eq!(scanner.interval, Duration::from_millis(250)); } + /// Embedders may spawn the scan on a multi-threaded runtime, which needs + /// `Send` futures. The futures are only created here, never polled, so + /// no socket is opened. + #[test] + fn scan_futures_are_send() { + fn assert_send(_: &T) {} + + let scanner = test_scanner(); + assert_send(&scanner.run()); + assert_send(&scanner.run_with_status()); + } + + /// Drives a scan on the same kind of runtime the CLI builds. Every port + /// is excluded, so the scan has no socket to open and returns at once. + #[test] + fn scan_without_sockets_completes_on_a_current_thread_runtime() { + let addrs = vec!["127.0.0.1".parse::().unwrap()]; + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_io() + .enable_time() + .build() + .unwrap(); + + for interval in [Duration::ZERO, Duration::from_millis(10)] { + let scanner = Scanner::new( + &addrs, + 10, + Duration::from_millis(100), + 1, + true, + PortStrategy::Manual(vec![1, 2]), + true, + vec![1, 2], + false, + ) + .with_interval(interval); + + assert!(runtime.block_on(scanner.run_with_status()).is_empty()); + } + } + + #[test] + fn timeouts_are_reported_as_timed_out_errors() { + let error = timed_out(); + + assert_eq!(error.kind(), io::ErrorKind::TimedOut); + assert_eq!(error.to_string(), "future timed out"); + } + /// Regression test for https://github.com/bee-san/RustScan/issues/933: /// the SNMP public-walk probe must be the exact 33-byte BER packet, with /// the literal `public` community string intact. The old hexdigits-only