mirror of
https://github.com/bee-san/RustScan.git
synced 2026-11-07 09:46:04 -10:00
Compare commits
218
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d568d3d04 | ||
|
|
937903340a | ||
|
|
f33752812c | ||
|
|
9b48f03ecd | ||
|
|
32d7d2cb17 | ||
|
|
67c08a3310 | ||
|
|
b7119f7f3e | ||
|
|
cfb8645901 | ||
|
|
d7cdad29c6 | ||
|
|
ea9e6f0736 | ||
|
|
2c854eb551 | ||
|
|
1c3fd8c378 | ||
|
|
4e64b5f00a | ||
|
|
faa21b7d14 | ||
|
|
bf74950995 | ||
|
|
c027ebc7da | ||
|
|
183eafd22e | ||
|
|
e497adf530 | ||
|
|
9ef667caed | ||
|
|
002f736073 | ||
|
|
1c2a982320 | ||
|
|
9644b3a2cd | ||
|
|
a21bbb31bb | ||
|
|
8b143f8ad7 | ||
|
|
111316f446 | ||
|
|
8be97b841d | ||
|
|
7446a42e56 | ||
|
|
e9ba3fa547 | ||
|
|
9a45d26e99 | ||
|
|
6cb99747e1 | ||
|
|
bc80fbbad4 | ||
|
|
3168580b86 | ||
|
|
2e78c22644 | ||
|
|
996f818b5e | ||
|
|
90c5c1766b | ||
|
|
f570d9ff8e | ||
|
|
42a3c87c83 | ||
|
|
1e93f44d8b | ||
|
|
bf290d7c7f | ||
|
|
160dc7a3a2 | ||
|
|
2d52f239ca | ||
|
|
5900974fda | ||
|
|
d0d388c813 | ||
|
|
5492dd729c | ||
|
|
b84f1fa046 | ||
|
|
3d4db2b6a1 | ||
|
|
1967598d96 | ||
|
|
94be5711d9 | ||
|
|
55c9f2a1c8 | ||
|
|
ce880ebe8c | ||
|
|
64f60a97ed | ||
|
|
a084c1b3b9 | ||
|
|
c668636a61 | ||
|
|
730f709fd6 | ||
|
|
e9dadb4a30 | ||
|
|
036297fbb9 | ||
|
|
e7db81a478 | ||
|
|
7c7018530b | ||
|
|
3abd1abe68 | ||
|
|
a4099a4ec2 | ||
|
|
827acd9cb0 | ||
|
|
e724d1953c | ||
|
|
f9bc5abf21 | ||
|
|
82c1a9373a | ||
|
|
45ece51c63 | ||
|
|
e234127874 | ||
|
|
053a3eb0eb | ||
|
|
b03379890e | ||
|
|
52ba449b70 | ||
|
|
79768931e3 | ||
|
|
3c3a69b06b | ||
|
|
487acbe382 | ||
|
|
8115b2d62e | ||
|
|
99c3c1a650 | ||
|
|
f366ce741d | ||
|
|
6f388ae152 | ||
|
|
a11b624644 | ||
|
|
85c080ee56 | ||
|
|
b367fbe48e | ||
|
|
50aed26d29 | ||
|
|
1ceb758acf | ||
|
|
07aa04a94d | ||
|
|
42d2f6128e | ||
|
|
f71ed28ff7 | ||
|
|
bb8aa7fa80 | ||
|
|
cd9852ae9d | ||
|
|
6cd10727ee | ||
|
|
034cbccf7e | ||
|
|
6bae2a1aa2 | ||
|
|
36e303e6ad | ||
|
|
3c2c869ba9 | ||
|
|
7f2fc87aa3 | ||
|
|
6440aed8a7 | ||
|
|
51efbca7aa | ||
|
|
c78975fa87 | ||
|
|
3a7230127f | ||
|
|
6dbf9f51cb | ||
|
|
c7a265e270 | ||
|
|
e3d31b3957 | ||
|
|
b0965dc946 | ||
|
|
2c66b4203a | ||
|
|
c8596c7679 | ||
|
|
aeef44c6e0 | ||
|
|
b228abfaa9 | ||
|
|
7fbd96be7c | ||
|
|
4a7ca7c00b | ||
|
|
60a5ae0102 | ||
|
|
1b74455a35 | ||
|
|
00d1003afa | ||
|
|
4c056a9039 | ||
|
|
a40c4ed17f | ||
|
|
59c6f10fad | ||
|
|
ec2d714e39 | ||
|
|
c19726c74f | ||
|
|
2600036621 | ||
|
|
9596712a19 | ||
|
|
fe98a3b6ef | ||
|
|
f4411e551a | ||
|
|
0640d94f53 | ||
|
|
d3508f8192 | ||
|
|
aacde90cf1 | ||
|
|
81a367e40b | ||
|
|
cc418f08b4 | ||
|
|
6781108db1 | ||
|
|
ea8015747f | ||
|
|
c09fb7bcfd | ||
|
|
6b96e84878 | ||
|
|
6f8b7f42f4 | ||
|
|
24ff49622c | ||
|
|
87399a1583 | ||
|
|
578457f6bd | ||
|
|
e315f82d03 | ||
|
|
63c9fdc05a | ||
|
|
6a1dd069b1 | ||
|
|
c89fad6c58 | ||
|
|
ac83fe7da0 | ||
|
|
3a06c22b43 | ||
|
|
9524b3503d | ||
|
|
eb11342a66 | ||
|
|
7e8230f762 | ||
|
|
d51fbb7411 | ||
|
|
56a234b41f | ||
|
|
59b645aa0e | ||
|
|
f5cbfef10f | ||
|
|
0772c4948d | ||
|
|
06e37f114e | ||
|
|
18a71f2625 | ||
|
|
48f826e545 | ||
|
|
f1006e4b3c | ||
|
|
a7257acbbb | ||
|
|
23724292e4 | ||
|
|
e1e5e7a020 | ||
|
|
2e85a6e31d | ||
|
|
9b43d1c6ce | ||
|
|
7f7de101dc | ||
|
|
c353cf5fd8 | ||
|
|
94b9c0e30a | ||
|
|
042875afec | ||
|
|
edc90a4db3 | ||
|
|
028554c18f | ||
|
|
f979926275 | ||
|
|
cf1eaf8e5c | ||
|
|
618b8aa9a9 | ||
|
|
0f23a671a5 | ||
|
|
3bdac9c9c7 | ||
|
|
1085a723c4 | ||
|
|
e1c8c254a1 | ||
|
|
05e5c50f49 | ||
|
|
f42f887da3 | ||
|
|
d5b7f4a024 | ||
|
|
c5d54880fc | ||
|
|
179d7fdfb8 | ||
|
|
2176262f22 | ||
|
|
447283cf23 | ||
|
|
8b317cec1f | ||
|
|
08ccf624c1 | ||
|
|
16e45cea2b | ||
|
|
a5c36c3f52 | ||
|
|
1124c46f37 | ||
|
|
8330e4363d | ||
|
|
08ee05ff2d | ||
|
|
34c285af75 | ||
|
|
e4eeeac6ef | ||
|
|
a70bd8b374 | ||
|
|
2884a448be | ||
|
|
6ec01bb154 | ||
|
|
c09baef4f8 | ||
|
|
cf426427ed | ||
|
|
293056ef19 | ||
|
|
2888336657 | ||
|
|
800cb3ba39 | ||
|
|
096ebbf73d | ||
|
|
5e6cc410e7 | ||
|
|
5739c6175e | ||
|
|
c134747788 | ||
|
|
2c2a55723a | ||
|
|
c5fb1b006c | ||
|
|
083a20269f | ||
|
|
815b4b5e05 | ||
|
|
b7734da3a7 | ||
|
|
36d71894b9 | ||
|
|
8a6334dfe8 | ||
|
|
e7c4205ec5 | ||
|
|
1553e58cf7 | ||
|
|
2e5e5fd71e | ||
|
|
ea2e8f6fbe | ||
|
|
d6bc7e7219 | ||
|
|
5d66529231 | ||
|
|
9e3c547615 | ||
|
|
a8c723fc68 | ||
|
|
bbb8237d19 | ||
|
|
5f8e015686 | ||
|
|
9d6bca1119 | ||
|
|
4168bf612e | ||
|
|
6a1b49e7a3 | ||
|
|
c123457734 | ||
|
|
727a64d934 | ||
|
|
b1f008a9c9 |
No files matched your search
@@ -0,0 +1,57 @@
|
||||
"""Provision TCP memory for repeated scans on a disposable macOS CI runner."""
|
||||
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
|
||||
class Statistics(ctypes.Structure):
|
||||
# XNU's bsd/sys/mem_acct_private.h, struct memacct_statistics.
|
||||
_fields_ = [("peak", ctypes.c_uint64), ("allocated", ctypes.c_int64),
|
||||
("softlimit", ctypes.c_uint64), ("hardlimit", ctypes.c_uint64),
|
||||
("name", ctypes.c_char * 16)]
|
||||
|
||||
|
||||
def tcp_memory(new_limit: int | None = None) -> dict:
|
||||
"""Read the TCP accounting object, optionally raising its hard limit."""
|
||||
libc = ctypes.CDLL(None, use_errno=True)
|
||||
libc.sysctlnametomib.argtypes = [ctypes.c_char_p, ctypes.POINTER(ctypes.c_int),
|
||||
ctypes.POINTER(ctypes.c_size_t)]
|
||||
libc.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint,
|
||||
ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t),
|
||||
ctypes.c_void_p, ctypes.c_size_t]
|
||||
mib = (ctypes.c_int * 24)()
|
||||
count = ctypes.c_size_t(22)
|
||||
if libc.sysctlnametomib(b"kern.memacct", mib, ctypes.byref(count)):
|
||||
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
|
||||
|
||||
def query(operation: int, index: int, output, replacement=None):
|
||||
mib[count.value], mib[count.value + 1] = operation, index
|
||||
length = ctypes.c_size_t(ctypes.sizeof(output))
|
||||
if libc.sysctl(mib, count.value + 2, ctypes.byref(output), ctypes.byref(length),
|
||||
ctypes.byref(replacement) if replacement is not None else None,
|
||||
ctypes.sizeof(replacement) if replacement is not None else 0):
|
||||
raise OSError(ctypes.get_errno(), os.strerror(ctypes.get_errno()))
|
||||
return length.value
|
||||
|
||||
names = ctypes.create_string_buffer(8 * 16)
|
||||
length = query(5, 0, names) # MEM_ACCT_SUBSYSTEMS
|
||||
index = next(i for i in range(length // 16)
|
||||
if names.raw[i * 16:(i + 1) * 16].rstrip(b"\0") == b"TCP")
|
||||
stats = Statistics()
|
||||
query(6, index, stats) # MEM_ACCT_ALL_SUBSYSTEM_STATISTICS
|
||||
previous_limit = stats.hardlimit
|
||||
if new_limit is not None and 0 < previous_limit < new_limit:
|
||||
query(3, index, ctypes.c_uint64(), ctypes.c_uint64(new_limit))
|
||||
query(6, index, stats)
|
||||
return {"previous_hardlimit": previous_limit,
|
||||
**{name: getattr(stats, name) for name in ("peak", "allocated", "softlimit", "hardlimit")}}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# The default TCP budget is 1/32 of physical memory. Repeated 10,000-socket
|
||||
# batches hit ENOBUFS on Darwin 25.6 even on master. Give both builds 1/8
|
||||
# before timing; retain every listener check and performance threshold.
|
||||
memory = int(subprocess.check_output(["sysctl", "-n", "hw.memsize"], text=True))
|
||||
print(json.dumps(tcp_memory(memory // 8)))
|
||||
@@ -0,0 +1,984 @@
|
||||
#!/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
|
||||
excluded_ports: tuple[int, ...] = ()
|
||||
|
||||
@property
|
||||
def sockets(self) -> int:
|
||||
span = sum(end - start + 1 for start, end in self.ranges)
|
||||
excluded = sum(self.covers(port) for port in set(self.excluded_ports))
|
||||
return len(self.addresses) * (len(self.ports) + span - excluded)
|
||||
|
||||
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")
|
||||
if self.excluded_ports:
|
||||
args += ["--exclude-ports", ",".join(map(str, self.excluded_ports))]
|
||||
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) and port not in self.excluded_ports
|
||||
}
|
||||
|
||||
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-open", "TCP, open listeners only", lo, ports=TCP_PORTS, repeat=3),
|
||||
Scenario("tcp-sweep", f"TCP, {ports(sweep)}, default batch", lo, sweep),
|
||||
Scenario(
|
||||
"tcp-sweep-excluded",
|
||||
f"TCP, {ports(sweep)}, 1,024 excluded ports",
|
||||
lo,
|
||||
sweep,
|
||||
excluded_ports=tuple(range(1024, 2048)),
|
||||
),
|
||||
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)
|
||||
)
|
||||
scenarios.append(
|
||||
Scenario(
|
||||
"udp-open", "UDP, echo responders only", lo, ports=UDP_PORTS,
|
||||
timeout_ms=500, udp=True, repeat=9,
|
||||
)
|
||||
)
|
||||
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 diagnose_tcp(binary: str, scenario: Scenario, listeners: dict) -> dict:
|
||||
"""Untimed failure capture on a disposable macOS runner, using debug logs."""
|
||||
from macos_tcp_memory import tcp_memory
|
||||
|
||||
def states() -> dict[str, int]:
|
||||
snapshot = subprocess.run(
|
||||
["netstat", "-an", "-p", "tcp"], capture_output=True, text=True, check=True
|
||||
)
|
||||
counts: dict[str, int] = {}
|
||||
for line in snapshot.stdout.splitlines():
|
||||
if line.startswith("tcp"):
|
||||
state = line.split()[-1]
|
||||
counts[state] = counts.get(state, 0) + 1
|
||||
return counts
|
||||
|
||||
before = states()
|
||||
memory_before = tcp_memory()
|
||||
probe = subprocess.run(
|
||||
[binary, *scenario.args(), "--scripts", "none", "--accessible", "--no-banner", "--no-config"],
|
||||
env=dict(os.environ, RUST_LOG="rustscan=info,rustscan::scanner=debug"),
|
||||
capture_output=True, text=True, check=True,
|
||||
)
|
||||
opened = {
|
||||
match.group(1) for line in probe.stdout.splitlines()
|
||||
if (match := OPEN_LINE.match(line))
|
||||
}
|
||||
return {
|
||||
"before": before, "after": states(),
|
||||
"memory_before": memory_before, "memory_after": tcp_memory(),
|
||||
"missing": sorted(scenario.expected(listeners) - opened),
|
||||
"errors": [line for line in probe.stderr.splitlines()
|
||||
if "Typical socket connection errors" in line],
|
||||
}
|
||||
|
||||
|
||||
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}
|
||||
diagnostics = []
|
||||
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)
|
||||
if platform.system() == "Darwin" and not diagnostics and any(
|
||||
sc.expected(listeners) - set(results[sc.name][name][-1].open) for name in names
|
||||
):
|
||||
# A failed pair cannot support a performance claim. Capture
|
||||
# its OS errors immediately, outside both measured scans.
|
||||
for name in names:
|
||||
record = {"build": name, "scenario": sc.name,
|
||||
**diagnose_tcp(builds[name], sc, listeners)}
|
||||
diagnostics.append(record)
|
||||
print("TCP diagnosis: " + json.dumps(record), 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,
|
||||
"diagnostics": diagnostics,
|
||||
}
|
||||
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())
|
||||
+83
-61
@@ -1,13 +1,22 @@
|
||||
name: CD Pipeline
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "master"
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
# Build jobs only need to read the repository. The publish job below is the
|
||||
# only job that gets write access, and it only runs for tag pushes.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-nix:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.ref == 'refs/heads/master'
|
||||
strategy:
|
||||
matrix:
|
||||
type: [ubuntu-x64, ubuntu-x86, armv7, aarch64]
|
||||
@@ -37,40 +46,35 @@ jobs:
|
||||
path: target/aarch64-unknown-linux-gnu/release/rustscan
|
||||
pkg_config_path: /usr/lib/x86_64-linux-gnu/pkgconfig
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: Build binary
|
||||
uses: houseabsolute/actions-rust-cross@v0
|
||||
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
|
||||
with:
|
||||
command: build
|
||||
target: ${{ matrix.target }}
|
||||
args: "--locked --release"
|
||||
strip: true
|
||||
toolchain: stable
|
||||
- name: Build tar.gz for homebrew installs
|
||||
if: matrix.type == 'ubuntu-x64'
|
||||
run: |
|
||||
tar czf ${{ matrix.name }}.tar.gz -C target/x86_64-unknown-linux-musl/release rustscan
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: Package and upload artifacts
|
||||
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: ${{ matrix.path }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: matrix.type == 'ubuntu-x64'
|
||||
with:
|
||||
name: ${{ matrix.name }}.tar.gz
|
||||
path: ${{ matrix.name }}.tar.gz
|
||||
executable-name: rustscan
|
||||
target: ${{ matrix.target }}
|
||||
archive-name: ${{ matrix.name }}
|
||||
changes-file: ""
|
||||
|
||||
build-deb:
|
||||
needs: [build-nix]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Install cargo-deb
|
||||
run: cargo install -f cargo-deb
|
||||
- uses: awalsh128/cache-apt-pkgs-action@v1
|
||||
- uses: awalsh128/cache-apt-pkgs-action@553a35bb8ebd9fcabcb1c9451aa4c98e1b4ca8a9 # v1.6.3
|
||||
with:
|
||||
packages: musl-tools # provides musl-gcc
|
||||
version: 1.0
|
||||
@@ -79,7 +83,7 @@ jobs:
|
||||
- name: Deb Build
|
||||
run: cargo deb --target=x86_64-unknown-linux-musl
|
||||
- name: Upload Deb Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: rustscan.deb
|
||||
path: ./target/x86_64-unknown-linux-musl/debian/*
|
||||
@@ -88,65 +92,54 @@ jobs:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: macos-latest
|
||||
if: github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: Build binary
|
||||
uses: houseabsolute/actions-rust-cross@v0
|
||||
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
|
||||
with:
|
||||
command: build
|
||||
target: x86_64-apple-darwin
|
||||
args: "--locked --release"
|
||||
strip: true
|
||||
toolchain: stable
|
||||
- name: Build tar.gz for homebrew installs
|
||||
run: |
|
||||
tar czf x86_64-macos-rustscan.tar.gz -C target/x86_64-apple-darwin/release rustscan
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: Package and upload artifacts
|
||||
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
|
||||
with:
|
||||
name: x86_64-macos-rustscan
|
||||
path: target/x86_64-apple-darwin/release/rustscan
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: x86_64-macos-rustscan.tar.gz
|
||||
path: x86_64-macos-rustscan.tar.gz
|
||||
|
||||
executable-name: rustscan
|
||||
target: x86_64-apple-darwin
|
||||
archive-name: x86_64-macos-rustscan
|
||||
changes-file: ""
|
||||
|
||||
build-macos-aarch64:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: macos-latest
|
||||
if: github.ref == 'refs/heads/master'
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: Build binary
|
||||
uses: houseabsolute/actions-rust-cross@v0
|
||||
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
|
||||
with:
|
||||
command: build
|
||||
target: aarch64-apple-darwin
|
||||
args: "--locked --release"
|
||||
strip: true
|
||||
toolchain: stable
|
||||
- name: Build tar.gz for homebrew installs
|
||||
run: |
|
||||
tar czf aarch64-macos-rustscan.tar.gz -C target/aarch64-apple-darwin/release rustscan
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: Package and upload artifacts
|
||||
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
|
||||
with:
|
||||
name: aarch64-macos-rustscan
|
||||
path: target/aarch64-apple-darwin/release/rustscan
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: aarch64-macos-rustscan.tar.gz
|
||||
path: aarch64-macos-rustscan.tar.gz
|
||||
executable-name: rustscan
|
||||
target: aarch64-apple-darwin
|
||||
archive-name: aarch64-macos-rustscan
|
||||
changes-file: ""
|
||||
|
||||
build-windows:
|
||||
env:
|
||||
IN_PIPELINE: true
|
||||
runs-on: ${{ matrix.os }}
|
||||
if: github.ref == 'refs/heads/master'
|
||||
strategy:
|
||||
matrix:
|
||||
type: [windows-x64, windows-x86]
|
||||
@@ -154,26 +147,55 @@ jobs:
|
||||
- type: windows-x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
name: x86_64-windows-rustscan.exe
|
||||
path: target\x86_64-pc-windows-msvc\release\rustscan.exe
|
||||
name: x86_64-windows-rustscan
|
||||
- type: windows-x86
|
||||
os: windows-latest
|
||||
target: i686-pc-windows-msvc
|
||||
name: x86-windows-rustscan.exe
|
||||
path: target\i686-pc-windows-msvc\release\rustscan.exe
|
||||
name: x86-windows-rustscan
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v7
|
||||
- name: Cache cargo & target directories
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2
|
||||
- name: Build binary
|
||||
uses: houseabsolute/actions-rust-cross@v0
|
||||
uses: houseabsolute/actions-rust-cross@21b0f18dc621b25bfae556ff2791fca4173121e8 # v1.0.8
|
||||
with:
|
||||
command: build
|
||||
target: ${{ matrix.target }}
|
||||
target: ${{ matrix.target }}
|
||||
args: "--locked --release"
|
||||
strip: true
|
||||
toolchain: stable
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: Package and upload artifacts
|
||||
uses: houseabsolute/actions-rust-release@9e126fd5fc18f4cecf358b3909f3991ad59663dc # v1.0.1
|
||||
with:
|
||||
name: ${{ matrix.name }}
|
||||
path: ${{ matrix.path }}
|
||||
executable-name: rustscan
|
||||
target: ${{ matrix.target }}
|
||||
archive-name: ${{ matrix.name }}
|
||||
changes-file: ""
|
||||
|
||||
# Runs only for tag pushes (tags can only be created by maintainers), and
|
||||
# only after every build job succeeded, so a release never ships partially.
|
||||
publish:
|
||||
if: github.ref_type == 'tag'
|
||||
needs:
|
||||
- build-nix
|
||||
- build-deb
|
||||
- build-macos
|
||||
- build-macos-aarch64
|
||||
- build-windows
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # create the release / upload release assets
|
||||
steps:
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
merge-multiple: true
|
||||
- name: Publish artifacts to GitHub release
|
||||
uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3
|
||||
with:
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
*.deb
|
||||
*.sha256
|
||||
*.tar.gz
|
||||
*.zip
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Dependabot Auto-Merge
|
||||
on: pull_request
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
steps:
|
||||
- name: Enable auto-merge for Dependabot PRs
|
||||
run: |
|
||||
gh pr review --approve "$PR_URL"
|
||||
gh pr merge --auto --merge "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Check Markdown links
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- '.github/**'
|
||||
schedule:
|
||||
# Run everyday at 9:00 AM (See https://pubs.opengroup.org/onlinepubs/9699919799/utilities/crontab.html#tag_20_25_07)
|
||||
- cron: "0 9 * * *"
|
||||
|
||||
jobs:
|
||||
markdown-link-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
- uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
use-quiet-mode: 'yes'
|
||||
config-file: './.github/workflows/mlc_config.json'
|
||||
@@ -0,0 +1,256 @@
|
||||
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: Provision macOS TCP memory for repeated socket batches
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
# XNU's TCP accounting hard limit otherwise makes both builds miss
|
||||
# listeners with ENOBUFS during repeated sweeps. Apply one budget to
|
||||
# both builds before timing; keep all input sizes and gates intact.
|
||||
sudo "$(command -v python)" .github/scan-benchmark/macos_tcp_memory.py
|
||||
|
||||
- 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 \
|
||||
kern.ipc.maxsockbuf kern.ipc.nmbclusters; 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
|
||||
@@ -1,34 +1,45 @@
|
||||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [master]
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test Suite
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
# ubuntu-24.04-arm is the GitHub-hosted arm64 Linux runner;
|
||||
# macos-latest already runs on Apple Silicon (arm64).
|
||||
os: [ubuntu-latest, ubuntu-24.04-arm, macos-latest, windows-latest]
|
||||
rust: [stable]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout sources
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install stable toolchain
|
||||
uses: actions-rs/toolchain@v1
|
||||
- name: Setup Rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@166cdcfd11aee3cb47222f9ddb555ce30ddb9659 # v1.17.0
|
||||
with:
|
||||
profile: minimal
|
||||
toolchain: ${{ matrix.rust }}
|
||||
override: true
|
||||
components: clippy, rustfmt
|
||||
# Caching is handled by Swatinem/rust-cache below.
|
||||
cache: false
|
||||
# Keep RUSTFLAGS unset; `just test` already denies Clippy warnings.
|
||||
rustflags: ""
|
||||
|
||||
- uses: taiki-e/install-action@nextest
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Install Just
|
||||
uses: extractions/setup-just@v2
|
||||
uses: extractions/setup-just@v4
|
||||
|
||||
- name: Run just
|
||||
- name: Run tests
|
||||
run: just test
|
||||
@@ -17,12 +17,7 @@ jobs:
|
||||
script:
|
||||
- cargo build
|
||||
- cargo test
|
||||
- "ulimit -n 5000; cargo test timelimits:: -- --ignored --test-threads=1 --show-output"
|
||||
- cargo fmt -- --check
|
||||
after_failure:
|
||||
- wget https://raw.githubusercontent.com/DiscordHooks/travis-ci-discord-webhook/master/send.sh
|
||||
- chmod +x send.sh
|
||||
- "./send.sh failure $WEBHOOK_URL"
|
||||
after_success: |
|
||||
if [ -n "${GITHUB_TOKEN}" ]; then
|
||||
[ "${TRAVIS_BRANCH} " = master ] &&
|
||||
|
||||
Generated
+672
-726
File diff suppressed because it is too large.
Load diff
+26
-18
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rustscan"
|
||||
version = "2.3.0"
|
||||
version = "2.4.1"
|
||||
authors = ["Autumn <[email protected]>"]
|
||||
edition = "2018"
|
||||
description = "Faster Nmap Scanning with Rust"
|
||||
@@ -17,33 +17,41 @@ exclude = [
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4.5.13", features = ["derive", "wrap_help"] }
|
||||
colored = "3.0.0"
|
||||
async-std = "1.13.0"
|
||||
futures = "0.3"
|
||||
rlimit = "0.10.2"
|
||||
log = "0.4.25"
|
||||
env_logger = "0.11.6"
|
||||
anstream = "=0.6.18"
|
||||
dirs = "6.0.0"
|
||||
clap = { version = "4.6.1", features = ["derive", "wrap_help"] }
|
||||
colored = "3.1.1"
|
||||
# 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"
|
||||
anstream = "=1.0.0"
|
||||
dirs = "7.0.0"
|
||||
gcd = "2.0.1"
|
||||
rand = "0.9.0"
|
||||
rand = "0.10.1"
|
||||
colorful = "0.3.2"
|
||||
ansi_term = "0.12.1"
|
||||
toml = "0.8.20"
|
||||
toml = "1.1.2"
|
||||
serde = "1.0.124"
|
||||
serde_derive = "1.0.116"
|
||||
cidr-utils = "0.6.1"
|
||||
itertools = "0.14.0"
|
||||
cidr-utils = "0.7.1"
|
||||
itertools = "0.15.0"
|
||||
hickory-resolver = { version = "0.24.3", features = ["dns-over-rustls"] }
|
||||
anyhow = "1.0.40"
|
||||
text_placeholder = { version = "0.5", features = ["struct_context"] }
|
||||
once_cell = "1.20.3"
|
||||
once_cell = "1.21.4"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Networking_WinSock"] }
|
||||
|
||||
[dev-dependencies]
|
||||
parameterized = "2.0.0"
|
||||
wait-timeout = "0.2"
|
||||
criterion = { version = "0.5", features = ["html_reports"] }
|
||||
criterion = { version = "0.8", features = ["html_reports"] }
|
||||
|
||||
[package.metadata.deb]
|
||||
depends = "$auto, nmap"
|
||||
@@ -63,5 +71,5 @@ path = "src/main.rs"
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ["cfg(tarpaulin_include)"] }
|
||||
|
||||
[[bench]]
|
||||
name = "benchmark_portscan"
|
||||
name = "benchmark_helpers"
|
||||
harness = false
|
||||
@@ -40,7 +40,7 @@ MacOS:
|
||||
Arch:
|
||||
|
||||
```
|
||||
yay rustscan
|
||||
pacman -S rustscan
|
||||
```
|
||||
|
||||
# ✨ Features
|
||||
@@ -55,12 +55,14 @@ Arch:
|
||||
|
||||
| <!--Installation Guide--> | <!--Documentation--> | <!--Discord--> |
|
||||
| :----------------------------------------: | :------------------------------------: | :-------------------------: |
|
||||
| :book: [Installation Guide][links-table-1] | :books: [Documentation][links-table-2] | :parrot: [Discord][discord] |
|
||||
| :book: [Installation Guide][toc-install] | :books: [Documentation][links-table-2] | :parrot: [Discord][discord] |
|
||||
| | :penguin: [ARM Support](docs/arm-support.md) | |
|
||||
|
||||
## 🙋 Table of Contents
|
||||
|
||||
- 📖 [Installation Guide][toc-install]
|
||||
- 🐋 [Docker Usage][toc-docker-usage]
|
||||
- 🐧 [ARM Support](docs/arm-support.md)
|
||||
- 🦜 [Discord][discord]
|
||||
- 🤸 [Usage][usage-1]
|
||||
|
||||
@@ -209,7 +211,7 @@ This project follows the [all-contributors](https://github.com/all-contributors/
|
||||
[speed-3]: https://github.com/RustScan/RustScan/wiki/Increasing-Speed-&-Accuracy "Increasing Speed & Accuracy"
|
||||
[toc-community]: https://github.com/RustScan/RustScan#-community "Community"
|
||||
[links-table-1]: https://github.com/RustScan/RustScan#-full-installation-guide "Full installation guide"
|
||||
[links-table-2]: https://rustscan.github.io/RustScan/ "Rustscan"
|
||||
[links-table-2]: https://github.com/bee-san/RustScan/wiki "Rustscan"
|
||||
[discord]: http://discord.skerritt.blog "Discord blog"
|
||||
[toc-install]: https://github.com/RustScan/RustScan/wiki/Installation-Guide "Installation Guide Wiki"
|
||||
[toc-docker-usage]: https://github.com/RustScan/RustScan/wiki/Installation-Guide#docker- "Docker Installation Guide Wiki"
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
use criterion::{criterion_group, criterion_main, Criterion};
|
||||
use rustscan::generated::get_parsed_data;
|
||||
use rustscan::input::{Opts, PortRanges, ScanOrder};
|
||||
use rustscan::port_strategy::PortStrategy;
|
||||
use rustscan::scanner::{build_udp_payload_lookup, Scanner};
|
||||
use std::collections::BTreeMap;
|
||||
use std::hint::black_box;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
fn bench_address() {
|
||||
let _addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
|
||||
}
|
||||
|
||||
fn bench_port_strategy() {
|
||||
let range = PortRanges(vec![(1, 1_000)]);
|
||||
let _strategy = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
|
||||
}
|
||||
|
||||
fn bench_address_parsing() {
|
||||
let opts = Opts {
|
||||
addresses: vec![
|
||||
"127.0.0.1".to_owned(),
|
||||
"10.2.0.1".to_owned(),
|
||||
"192.168.0.0/24".to_owned(),
|
||||
],
|
||||
exclude_addresses: Some(vec![
|
||||
"10.0.0.0/8".to_owned(),
|
||||
"172.16.0.0/12".to_owned(),
|
||||
"192.168.0.0/16".to_owned(),
|
||||
"172.16.0.1".to_owned(),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
let _ips = rustscan::address::parse_addresses(&opts);
|
||||
}
|
||||
|
||||
// Replicates the old UDP payload selection behavior:
|
||||
// scan the whole UDP payload map and find the last payload whose port list contains `port`.
|
||||
fn old_payload_for_port(udp_map: &'static BTreeMap<Vec<u16>, Vec<u8>>, port: u16) -> &'static [u8] {
|
||||
let mut payload: &'static [u8] = b"";
|
||||
for (ports, value) in udp_map.iter() {
|
||||
if ports.contains(&port) {
|
||||
payload = value.as_slice();
|
||||
}
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
||||
fn criterion_benchmark(c: &mut Criterion) {
|
||||
// Benching helper functions
|
||||
c.bench_function("parse address", |b| b.iter(bench_address));
|
||||
|
||||
c.bench_function("port strategy", |b| b.iter(bench_port_strategy));
|
||||
|
||||
let mut address_group = c.benchmark_group("address parsing");
|
||||
address_group.measurement_time(Duration::from_secs(10));
|
||||
address_group.bench_function("parse addresses with exclusions", |b| {
|
||||
b.iter(bench_address_parsing)
|
||||
});
|
||||
address_group.finish();
|
||||
|
||||
// Exercise production port preparation without opening a socket. The
|
||||
// scanner has no target addresses, and runtime construction is not timed.
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let mut preparation = c.benchmark_group("port preparation");
|
||||
preparation.sample_size(20);
|
||||
preparation.warm_up_time(Duration::from_millis(500));
|
||||
preparation.measurement_time(Duration::from_secs(2));
|
||||
for (port_count, excluded_count) in [
|
||||
(1, 1),
|
||||
(16, 4),
|
||||
(64, 1),
|
||||
(64, 4096),
|
||||
(4096, 0),
|
||||
(4096, 64),
|
||||
(4096, 4096),
|
||||
(65535, 0),
|
||||
(65535, 4),
|
||||
(65535, 64),
|
||||
(65535, 1024),
|
||||
(65535, 4096),
|
||||
] {
|
||||
let scanner = Scanner::new(
|
||||
&[],
|
||||
500,
|
||||
Duration::from_millis(100),
|
||||
1,
|
||||
true,
|
||||
PortStrategy::Manual((1..=port_count).collect()),
|
||||
true,
|
||||
(0..excluded_count)
|
||||
.map(|i| (i * 13 % 65536) as u16)
|
||||
.collect(),
|
||||
false,
|
||||
);
|
||||
preparation.bench_function(
|
||||
format!("{port_count} ports, {excluded_count} exclusions"),
|
||||
|b| b.iter(|| black_box(runtime.block_on(black_box(&scanner).run_with_status()))),
|
||||
);
|
||||
}
|
||||
preparation.finish();
|
||||
|
||||
// UDP payload lookup micro-benchmark: compares the old linear scan of the
|
||||
// payload map with the precomputed port -> payload lookup. No sockets.
|
||||
let udp_map = get_parsed_data();
|
||||
let lookup = build_udp_payload_lookup(udp_map);
|
||||
let ports: Vec<u16> = (1..=4096).collect();
|
||||
|
||||
c.bench_function("udp payload lookup/old scan map 1..4096", |b| {
|
||||
b.iter(|| {
|
||||
for &p in ports.iter() {
|
||||
let payload = old_payload_for_port(black_box(udp_map), black_box(p));
|
||||
black_box(payload);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
c.bench_function("udp payload lookup/new hashmap 1..4096", |b| {
|
||||
b.iter(|| {
|
||||
for &p in ports.iter() {
|
||||
let payload = lookup.get(&p).copied().unwrap_or(b"");
|
||||
black_box(payload);
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(benches, criterion_benchmark);
|
||||
criterion_main!(benches);
|
||||
@@ -1,80 +0,0 @@
|
||||
use async_std::task::block_on;
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use rustscan::input::{PortRange, ScanOrder};
|
||||
use rustscan::port_strategy::PortStrategy;
|
||||
use rustscan::scanner::Scanner;
|
||||
use std::net::IpAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
fn portscan_tcp(scanner: &Scanner) {
|
||||
let _scan_result = block_on(scanner.run());
|
||||
}
|
||||
|
||||
fn portscan_udp(scanner: &Scanner) {
|
||||
let _scan_result = block_on(scanner.run());
|
||||
}
|
||||
|
||||
fn bench_address() {
|
||||
let _addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
|
||||
}
|
||||
|
||||
fn bench_port_strategy() {
|
||||
let range = PortRange {
|
||||
start: 1,
|
||||
end: 1_000,
|
||||
};
|
||||
let _strategy = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
|
||||
}
|
||||
|
||||
fn criterion_benchmark(c: &mut Criterion) {
|
||||
let addrs = vec!["127.0.0.1".parse::<IpAddr>().unwrap()];
|
||||
let range = PortRange {
|
||||
start: 1,
|
||||
end: 1_000,
|
||||
};
|
||||
let strategy_tcp = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
|
||||
let strategy_udp = PortStrategy::pick(&Some(range.clone()), None, ScanOrder::Serial);
|
||||
|
||||
let scanner_tcp = Scanner::new(
|
||||
&addrs,
|
||||
10,
|
||||
Duration::from_millis(10),
|
||||
1,
|
||||
false,
|
||||
strategy_tcp,
|
||||
true,
|
||||
vec![],
|
||||
false,
|
||||
);
|
||||
|
||||
c.bench_function("portscan tcp", |b| {
|
||||
b.iter(|| portscan_tcp(black_box(&scanner_tcp)))
|
||||
});
|
||||
|
||||
let scanner_udp = Scanner::new(
|
||||
&addrs,
|
||||
10,
|
||||
Duration::from_millis(10),
|
||||
1,
|
||||
false,
|
||||
strategy_udp,
|
||||
true,
|
||||
vec![],
|
||||
true,
|
||||
);
|
||||
|
||||
let mut udp_group = c.benchmark_group("portscan udp");
|
||||
udp_group.measurement_time(Duration::from_secs(20));
|
||||
udp_group.bench_function("portscan udp", |b| {
|
||||
b.iter(|| portscan_udp(black_box(&scanner_udp)))
|
||||
});
|
||||
udp_group.finish();
|
||||
|
||||
// Benching helper functions
|
||||
c.bench_function("parse address", |b| b.iter(bench_address));
|
||||
|
||||
c.bench_function("port strategy", |b| b.iter(bench_port_strategy));
|
||||
}
|
||||
|
||||
criterion_group!(benches, criterion_benchmark);
|
||||
criterion_main!(benches);
|
||||
@@ -25,6 +25,10 @@ pub fn main() {
|
||||
let mut curr = String::new();
|
||||
|
||||
for line in data.trim().split('\n') {
|
||||
// Strip a trailing carriage return so CRLF checkouts parse
|
||||
// identically to LF ones (otherwise `\r` pollutes port tokens and
|
||||
// blank lines stop looking blank).
|
||||
let line = line.strip_suffix('\r').unwrap_or(line);
|
||||
if line.contains('#') || line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
@@ -141,7 +145,7 @@ fn ports_v(fp_map: &BTreeMap<i32, String>) -> BTreeMap<i32, Vec<u16>> {
|
||||
} else if !segment.is_empty() {
|
||||
match segment.parse::<u16>() {
|
||||
Ok(port) => port_list.push(port),
|
||||
Err(_) => println!("Error parsing port: {}", segment),
|
||||
Err(_) => println!("Error parsing port: {segment}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -167,38 +171,43 @@ fn payloads_v(fp_map: &BTreeMap<i32, String>) -> BTreeMap<i32, Vec<u8>> {
|
||||
let mut payb_linenr: BTreeMap<i32, Vec<u8>> = BTreeMap::new();
|
||||
|
||||
for (&line_nr, data) in fp_map {
|
||||
if data.contains('\"') {
|
||||
let start = data.find('\"').expect("payload opening \" not found");
|
||||
let payloads = &data[start + 1..];
|
||||
payb_linenr.insert(line_nr, parser(payloads.trim()));
|
||||
if data.contains('"') {
|
||||
let start = data.find('"').expect("payload opening \" not found");
|
||||
// Pass from the opening quote: the decoder pairs quotes itself
|
||||
// to split multi-line segments.
|
||||
payb_linenr.insert(line_nr, parser(data[start..].trim()));
|
||||
}
|
||||
}
|
||||
|
||||
payb_linenr
|
||||
}
|
||||
|
||||
/// Converts a hexadecimal string to a Vec<u8>
|
||||
/// Converts a quoted Nmap payload block to bytes.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `payload` - A string slice containing the hexadecimal payload
|
||||
/// * `payload` - The raw block text starting at its first opening quote:
|
||||
/// one or more `"..."` segments separated by whitespace
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// A vector of bytes representing the decoded payload
|
||||
/// The decoded probe bytes: segments are decoded with [`decode_segment`]
|
||||
/// and concatenated with no separators, so multi-line probes reassemble
|
||||
/// exactly.
|
||||
fn parser(payload: &str) -> Vec<u8> {
|
||||
let payload = payload.trim_matches('"');
|
||||
let mut tmp_str = String::new();
|
||||
let mut bytes: Vec<u8> = Vec::new();
|
||||
|
||||
for (idx, char) in payload.chars().enumerate() {
|
||||
if char == '\\' && payload.chars().nth(idx + 1) == Some('x') {
|
||||
continue;
|
||||
} else if char.is_ascii_hexdigit() {
|
||||
tmp_str.push(char);
|
||||
if tmp_str.len() == 2 {
|
||||
bytes.push(u8::from_str_radix(&tmp_str, 16).unwrap());
|
||||
tmp_str.clear();
|
||||
let mut rest = payload.trim();
|
||||
while let Some(open) = rest.find('"') {
|
||||
let after_open = &rest[open + 1..];
|
||||
match split_segment(after_open) {
|
||||
Some((segment, remainder)) => {
|
||||
decode_segment(segment, &mut bytes);
|
||||
rest = remainder;
|
||||
}
|
||||
None => {
|
||||
// Unterminated trailing quote: decode what's left literally.
|
||||
decode_segment(after_open, &mut bytes);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,6 +215,69 @@ fn parser(payload: &str) -> Vec<u8> {
|
||||
bytes
|
||||
}
|
||||
|
||||
/// Splits off the first `"..."` segment: `text` must start just after an
|
||||
/// opening quote. Escaped quotes (`\"`) do not terminate the segment.
|
||||
///
|
||||
/// Returns the segment body and the remainder after the closing quote.
|
||||
fn split_segment(text: &str) -> Option<(&str, &str)> {
|
||||
let mut escaped = false;
|
||||
for (i, c) in text.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if c == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if c == '"' {
|
||||
return Some((&text[..i], &text[i + 1..]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Decodes one quoted Nmap payload segment in C-style escape format:
|
||||
/// `\xNN` becomes a byte, common single-char escapes (`\\`, `\"`,
|
||||
/// `\0`, `\n`, `\r`, `\t`) decode to their value, and every other
|
||||
/// character contributes its literal bytes.
|
||||
///
|
||||
/// Preserving literal text matters: several probes embed plain-text
|
||||
/// protocol words (the SNMP `public` community string, NetBIOS names,
|
||||
/// LDAP `objectClass`, SSDP headers). The previous hexdigits-only
|
||||
/// decoding mangled them into wrong bytes, so agents never answered.
|
||||
fn decode_segment(segment: &str, bytes: &mut Vec<u8>) {
|
||||
let mut chars = segment.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
let mut buf = [0; 4];
|
||||
bytes.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('x') => {
|
||||
let hi = chars.next().unwrap_or('0');
|
||||
let lo = chars.next().unwrap_or('0');
|
||||
let hex: String = [hi, lo].iter().collect();
|
||||
bytes.push(u8::from_str_radix(&hex, 16).unwrap_or(0));
|
||||
}
|
||||
Some('0') => bytes.push(0),
|
||||
Some('n') => bytes.push(b'\n'),
|
||||
Some('r') => bytes.push(b'\r'),
|
||||
Some('t') => bytes.push(b'\t'),
|
||||
Some('\\') => bytes.push(b'\\'),
|
||||
Some('"') => bytes.push(b'"'),
|
||||
// Unknown escape: keep the character literally, matching Nmap,
|
||||
// which passes unrecognized escapes through untouched.
|
||||
Some(other) => {
|
||||
let mut buf = [0; 4];
|
||||
bytes.extend_from_slice(other.encode_utf8(&mut buf).as_bytes());
|
||||
}
|
||||
None => bytes.push(b'\\'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Combines the ports BTreeMap and the Payloads BTreeMap
|
||||
///
|
||||
/// # Arguments
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile for RustScan development environment
|
||||
# Provides a containerized setup with Rust and development tools
|
||||
FROM rust
|
||||
# Install nmap first.
|
||||
RUN apt-get update -qy && apt-get install -qy nmap
|
||||
# Then install rustfmt and clippy for cargo.
|
||||
RUN rustup component add rustfmt clippy
|
||||
# Install rustfmt for code formatting and clippy for linting.
|
||||
RUN rustup component add rustfmt clippy
|
||||
+16
-2
@@ -54,10 +54,10 @@ You can now modify RustScan files with your favorite editor, once you want to co
|
||||
root@container:/rustscan# cargo build
|
||||
```
|
||||
|
||||
You are now ready to use RustScan:
|
||||
You can check the command-line interface without starting a scan:
|
||||
|
||||
```bash
|
||||
root@container:/rustscan# cargo run -- -b 2000 -t 5000 -a 127.0.0.1
|
||||
root@container:/rustscan# cargo run -- --help
|
||||
```
|
||||
|
||||
You can also format, lint with `clippy` and test the code with the following commands:
|
||||
@@ -67,3 +67,17 @@ root@container:/rustscan# cargo fmt
|
||||
root@container:/rustscan# cargo clippy
|
||||
root@container:/rustscan# cargo test
|
||||
```
|
||||
|
||||
Automated tests and benchmarks must run without network traffic or external script
|
||||
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.
|
||||
@@ -0,0 +1,38 @@
|
||||
# ARM support
|
||||
|
||||
This page covers which ARM targets RustScan's CI builds and tests, and how to build ARM binaries yourself.
|
||||
|
||||
## What CI covers
|
||||
|
||||
| Target | Built by | Tested by |
|
||||
| --- | --- | --- |
|
||||
| `aarch64-unknown-linux-gnu` (64-bit ARM Linux) | `build-nix` job in [build.yml](../.github/workflows/build.yml) | `Test Suite (ubuntu-24.04-arm)` in [test.yml](../.github/workflows/test.yml) |
|
||||
| `armv7-unknown-linux-gnueabihf` (32-bit ARM Linux) | `build-nix` job in [build.yml](../.github/workflows/build.yml) | not tested in CI |
|
||||
| `aarch64-apple-darwin` (Apple Silicon macOS) | `build-macos-aarch64` job in [build.yml](../.github/workflows/build.yml) | `Test Suite (macos-latest)` in [test.yml](../.github/workflows/test.yml) |
|
||||
|
||||
- **Builds** use [`houseabsolute/actions-rust-cross`](https://github.com/houseabsolute/actions-rust-cross). It cross-compiles the Linux ARM targets with [`cross`](https://github.com/cross-rs/cross) on x86_64 runners.
|
||||
- **Tests** run natively on GitHub-hosted arm64 runners: `ubuntu-24.04-arm` for Linux, and `macos-latest`, which runs on Apple Silicon. They run the same `just test` recipe as every other platform.
|
||||
|
||||
## Building ARM binaries locally
|
||||
|
||||
The simplest option is `cross`, which runs the build in a container that already has the right toolchain and linker. It needs Docker or Podman.
|
||||
|
||||
```sh
|
||||
cargo install cross
|
||||
cross build --locked --release --target aarch64-unknown-linux-gnu
|
||||
# or, for 32-bit ARM:
|
||||
cross build --locked --release --target armv7-unknown-linux-gnueabihf
|
||||
```
|
||||
|
||||
The binary ends up at `target/<target>/release/rustscan`.
|
||||
|
||||
On an ARM machine (for example a Raspberry Pi running a 64-bit OS, or an Apple Silicon Mac) you don't need `cross`; a normal native build works:
|
||||
|
||||
```sh
|
||||
cargo build --locked --release
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`error[E0463]: can't find crate for std`, or linker errors:** you're cross-compiling with plain `cargo`. Either use `cross`, or install the target (`rustup target add <target>`) along with a matching cross linker.
|
||||
- **`cross` can't pull its image:** check that Docker or Podman is running and that your user can reach it.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,114 @@
|
||||
# Port exclusion preparation benchmark
|
||||
|
||||
Compared Tokio master `b7119f7f3eb1601e81126ef3572699ed7b3b7c0b` with
|
||||
implementation `270561d553688acbd99f61c460cdfa7c76c1423e` on 2026-10-02.
|
||||
|
||||
## Method
|
||||
|
||||
Release builds with LTO, Rust 1.98.1, Intel Core Ultra 7 165U, Linux
|
||||
7.2.6-1-cachyos. Both versions use the same lockfile and identical
|
||||
`benches/benchmark_helpers.rs`; copy the final benchmark harness into the
|
||||
baseline worktree before building it. The benchmark runs the production
|
||||
`Scanner::run_with_status` with **zero target addresses**, so it opens no
|
||||
sockets and does no DNS or scripting. It includes generating/cloning the
|
||||
port list, filtering and empty scan setup. Scanner construction and the
|
||||
current-thread Tokio runtime construction are outside the timer.
|
||||
|
||||
Manual ports are `1..=port_count`; exclusions are
|
||||
`(i * 13 % 65536) as u16` for `i` in `0..excluded_count`. Each case has two
|
||||
independent twenty-sample Criterion runs per build, a 500 ms warmup and a
|
||||
2 s target measurement. Cases run in A/B then B/A order on CPU 0. No builds
|
||||
or other local checks ran during timing. CPU frequency was not fixed on
|
||||
this shared laptop, so the table retains both repetitions rather than
|
||||
selecting one result. Values are Criterion's mean point estimates.
|
||||
|
||||
Build command for each worktree:
|
||||
|
||||
```sh
|
||||
cargo bench --locked --bench benchmark_helpers --no-run
|
||||
```
|
||||
|
||||
The saved benchmark executables are then run serially, for each case:
|
||||
|
||||
```sh
|
||||
CARGO_TARGET_DIR=/tmp/preparation-results taskset -c 0 /path/to/benchmark_helpers \
|
||||
--bench 'port preparation/65535 ports, 4096 exclusions' --save-baseline base-a
|
||||
```
|
||||
|
||||
Repeat with the candidate binary as `head-a`, then the candidate as
|
||||
`head-b` and the baseline as `base-b`. To run the group normally without
|
||||
comparing builds:
|
||||
|
||||
```sh
|
||||
cargo bench --locked --bench benchmark_helpers -- 'port preparation'
|
||||
```
|
||||
|
||||
## Results
|
||||
|
||||
Times are microseconds; A and B are separate matched repetitions.
|
||||
|
||||
| Ports | Exclusions | Master µs A / B | Optimized µs A / B | Time saved A / B |
|
||||
| ---: | ---: | ---: | ---: | ---: |
|
||||
| 1 | 1 | 0.289 / 0.286 | 0.261 / 0.271 | 9.7% / 5.2% |
|
||||
| 16 | 4 | 0.260 / 0.268 | 0.232 / 0.237 | 10.6% / 11.6% |
|
||||
| 64 | 1 | 0.433 / 0.437 | 0.237 / 0.239 | 45.2% / 45.4% |
|
||||
| 64 | 4,096 | 6.231 / 10.093 | 2.484 / 3.038 | 60.1% / 69.9% |
|
||||
| 4,096 | 0 | 10.889 / 10.037 | 0.335 / 0.397 | 96.9% / 96.0% |
|
||||
| 4,096 | 64 | 20.680 / 20.363 | 2.930 / 3.064 | 85.8% / 85.0% |
|
||||
| 4,096 | 4,096 | 524.223 / 519.670 | 6.805 / 6.710 | 98.7% / 98.7% |
|
||||
| 65,535 | 0 | 166.113 / 115.686 | 3.655 / 2.333 | 97.8% / 98.0% |
|
||||
| 65,535 | 4 | 175.717 / 181.343 | 29.769 / 30.230 | 83.1% / 83.3% |
|
||||
| 65,535 | 64 | 207.617 / 206.910 | 31.595 / 31.462 | 84.8% / 84.8% |
|
||||
| 65,535 | 1,024 | 2018.463 / 3271.564 | 33.403 / 33.520 | 98.3% / 99.0% |
|
||||
| 65,535 | 4,096 | 8811.234 / 8469.706 | 59.853 / 63.211 | 99.3% / 99.3% |
|
||||
|
||||
All twelve cases improved in both repetitions. This measures **preparation,
|
||||
not end-to-end network scan speed**: many scans are dominated by socket I/O,
|
||||
timeouts and reporting. In particular, the default no-exclusion saving is
|
||||
only a fraction of a millisecond.
|
||||
|
||||
## Implementation and correctness
|
||||
|
||||
With no exclusions, keep the already generated vector. For fewer than 64
|
||||
ports, filter in place with linear membership checks; this avoids bitmap
|
||||
setup and a second allocation. Larger lists use an 8 KiB bitmap covering
|
||||
all 65,536 values representable by `u16`, then retain ports in place. This
|
||||
changes the large-list filtering cost from ports × exclusions to ports +
|
||||
exclusions, while preserving order, duplicate ports and boundary values.
|
||||
The helper is kept out of line: release assembly showed an inlined bitmap
|
||||
reserved 8,424 bytes in every async poll, whereas the final poll frame uses
|
||||
344 bytes and the helper reserves its bitmap only during preparation.
|
||||
|
||||
Exploration found that in-place linear membership slows large cases. A
|
||||
first production candidate also slowed 64 ports with 4,096 exclusions;
|
||||
using the bitmap for that case corrected the regression before the final
|
||||
matched runs. The cutoff is a performance choice, not a limit on input.
|
||||
|
||||
One focused network-free regression test compares the implementation with
|
||||
an independent membership oracle for empty inputs, endpoints 0/65535,
|
||||
duplicate ports/exclusions, all ports, and a frozen random order.
|
||||
`cargo test --locked` passes 74 tests with one pre-existing ignored doctest.
|
||||
All-target Clippy, formatting, documentation and diff checks pass. The
|
||||
CI-only scan harness adds an excluded-port sweep using the existing
|
||||
listeners, checking expected results and using the actual attempted socket
|
||||
count for throughput.
|
||||
|
||||
[Raw Criterion samples, confidence intervals, revisions and binary hashes](port-exclusion-benchmark-results.json)
|
||||
are included. Hosted correctness/performance results are linked in the PR.
|
||||
|
||||
## macOS runner setup
|
||||
|
||||
Repeated hosted sweeps on Darwin 25.6 returned `ENOBUFS` (error 55) on
|
||||
both master and the candidate, missing fixture listeners. Such runs are
|
||||
invalid for performance claims. The workflow provisions the TCP memory
|
||||
budget once before either build is measured, raising it from the default
|
||||
1/32 to 1/8 of physical memory when necessary, and prints the actual limit
|
||||
and allocation counters. It retains every scenario, repeat, listener
|
||||
comparison and performance threshold. An immediate untimed diagnostic
|
||||
scan records errors and kernel counters if a measured pair misses a
|
||||
listener; that failed dataset is not used to claim a speedup.
|
||||
|
||||
This uses XNU's [TCP budget initialization](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/netinet/tcp_subr.c)
|
||||
and [memory accounting interface](https://github.com/apple-oss-distributions/xnu/blob/main/bsd/sys/mem_acct_private.h).
|
||||
It changes the disposable benchmark environment, not RustScan's behavior
|
||||
or the operating system configuration on users' machines.
|
||||
@@ -0,0 +1,4 @@
|
||||
tags = ["core_approved", "example"]
|
||||
ports = ["80", "443", "8080"]
|
||||
developer = ["example", "https://example.org"]
|
||||
directory = "fixtures/.rustscan_scripts"
|
||||
@@ -1,23 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
#tags = ["core_approved", "example",]
|
||||
#developer = [ "example", "https://example.org" ]
|
||||
#ports_separator = ","
|
||||
#call_format = "perl {{script}} {{ip}} {{port}}"
|
||||
|
||||
# Sriptfile parser stops at the first blank line with parsing.
|
||||
# This script will run itself as an argument with the system installed perl interpreter, ports will be concatenated with "," .
|
||||
# Unused field: trigger_port = "80"
|
||||
# get total arg passed to this script
|
||||
my $total = $#ARGV + 1;
|
||||
my $counter = 1;
|
||||
|
||||
# get script name
|
||||
my $scriptname = $0;
|
||||
|
||||
print "Total args passed to $scriptname : $total\n";
|
||||
|
||||
# Use loop to print all args stored in an array called @ARGV
|
||||
foreach my $a(@ARGV) {
|
||||
print "Arg # $counter : $a\n";
|
||||
$counter++;
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/python3
|
||||
#tags = ["core_approved", "example",]
|
||||
#developer = [ "example", "https://example.org" ]
|
||||
#trigger_port = "80"
|
||||
#call_format = "python3 {{script}} {{ip}} {{port}}"
|
||||
|
||||
# Sriptfile parser stops at the first blank line with parsing.
|
||||
# This script will run itself as an argument with the system installed python interpreter, only scanning port 80.
|
||||
# Unused filed: ports_separator = ","
|
||||
|
||||
import sys
|
||||
|
||||
print('Python script ran with arguments', str(sys.argv))
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
#tags = ["core_approved", "example",]
|
||||
#developer = [ "example", "https://example.org" ]
|
||||
#ports_separator = ","
|
||||
#call_format = "bash {{script}} {{ip}} {{port}}"
|
||||
|
||||
# Sriptfile parser stops at the first blank line with parsing.
|
||||
# This script will run itself as an argument with the system installed bash interpreter, scanning all ports concatenated with "," .
|
||||
# Unused filed: trigger_port = "80"
|
||||
|
||||
# print all arguments passed to the script
|
||||
echo $@
|
||||
@@ -1,9 +1,7 @@
|
||||
#!intentional_blank_line
|
||||
Metadata-only test fixture; not executable.
|
||||
#tags = ["core_approved", "example"]
|
||||
#developer = [ "example", "https://example.org" ]
|
||||
#ports_separator = ","
|
||||
#call_format = "nmap -vvv -p {{port}} {{ip}}"
|
||||
#call_format = "fixture {{ip}} {{port}}"
|
||||
|
||||
# Scriptfile parser stops at the first blank line with parsing.
|
||||
# This script will run the system installed nmap, ports will be concatenated with "," .
|
||||
# Unused field: trigger_port = "80"
|
||||
The parser reads only the metadata above. This file contains no script body.
|
||||
@@ -1,7 +1,7 @@
|
||||
#!intentional_blank_line
|
||||
Metadata-only test fixture; not executable.
|
||||
#tags = "core_approved"
|
||||
#developer = [ "example", "https://example.org" ]
|
||||
#ports_separator = ","
|
||||
#call_format = "nmap -vvv -p {{port}} {{ip}}"
|
||||
#call_format = "fixture {{ip}} {{port}}"
|
||||
|
||||
# tags has to be an array, thus this file won't be parsed
|
||||
# tags has to be an array, thus this file won't be parsed
|
||||
Whitespace-only changes.
@@ -1,6 +0,0 @@
|
||||
127.0.0.1
|
||||
google.com
|
||||
example.com
|
||||
66666666666666.666666666666666666.66666666666666666.6666666666
|
||||
....
|
||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.radiatorFixtures
|
||||
@@ -1,5 +1,5 @@
|
||||
test:
|
||||
cargo nextest run
|
||||
cargo test
|
||||
cargo clippy -- --deny warnings
|
||||
cargo clippy --tests -- --deny warnings
|
||||
cargo fmt --check
|
||||
|
||||
+283
-126
@@ -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};
|
||||
@@ -6,7 +7,7 @@ use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
|
||||
use cidr_utils::cidr::IpCidr;
|
||||
use cidr_utils::cidr::{IpCidr, IpInet};
|
||||
use hickory_resolver::{
|
||||
config::{NameServerConfig, Protocol, ResolverConfig, ResolverOpts},
|
||||
Resolver,
|
||||
@@ -30,13 +31,32 @@ 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<IpAddr> {
|
||||
parse_addresses_with_resolver(input, || get_resolver(&input.resolver))
|
||||
}
|
||||
|
||||
fn parse_addresses_with_resolver(
|
||||
input: &Opts,
|
||||
create_resolver: impl FnOnce() -> Resolver,
|
||||
) -> Vec<IpAddr> {
|
||||
let mut ips: Vec<IpAddr> = 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 {
|
||||
@@ -58,7 +78,7 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
|
||||
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!(
|
||||
@@ -69,17 +89,11 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, craft a list of addresses to be excluded from the scan.
|
||||
let mut excluded_ips: BTreeSet<IpAddr> = BTreeSet::new();
|
||||
if let Some(exclude_addresses) = &input.exclude_addresses {
|
||||
for addr in exclude_addresses {
|
||||
excluded_ips.extend(parse_address(addr, &backup_resolver));
|
||||
}
|
||||
}
|
||||
let excluded_cidrs = parse_excluded_networks_with_resolver(&input.exclude_addresses, &resolver);
|
||||
|
||||
// Remove duplicated/excluded IPs.
|
||||
let mut seen = BTreeSet::new();
|
||||
ips.retain(|ip| seen.insert(*ip) && !excluded_ips.contains(ip));
|
||||
ips.retain(|ip| seen.insert(*ip) && !excluded_cidrs.iter().any(|cidr| cidr.contains(ip)));
|
||||
|
||||
ips
|
||||
}
|
||||
@@ -98,33 +112,98 @@ pub fn parse_addresses(input: &Opts) -> Vec<IpAddr> {
|
||||
/// let ips = parse_address("127.0.0.1", &Resolver::default().unwrap());
|
||||
/// ```
|
||||
pub fn parse_address(address: &str, resolver: &Resolver) -> Vec<IpAddr> {
|
||||
IpCidr::from_str(address)
|
||||
.map(|cidr| cidr.iter().map(|c| c.address()).collect())
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
format!("{}:80", &address)
|
||||
.to_socket_addrs()
|
||||
.ok()
|
||||
.map(|mut iter| vec![iter.next().unwrap().ip()])
|
||||
})
|
||||
.unwrap_or_else(|| resolve_ips_from_host(address, resolver))
|
||||
parse_address_with_resolver(address, &|| resolver)
|
||||
}
|
||||
|
||||
fn parse_address_with_resolver<'a>(
|
||||
address: &str,
|
||||
resolver: &impl Fn() -> &'a Resolver,
|
||||
) -> Vec<IpAddr> {
|
||||
if let Ok(addr) = IpAddr::from_str(address) {
|
||||
// `address` is an IP string
|
||||
vec![addr]
|
||||
} else if let Ok(net_addr) = IpInet::from_str(address) {
|
||||
// `address` is a CIDR string
|
||||
net_addr.network().into_iter().addresses().collect()
|
||||
} else {
|
||||
// `address` is a hostname or DNS name
|
||||
// attempt default DNS lookup
|
||||
match format!("{address}:80").to_socket_addrs() {
|
||||
Ok(mut iter) => vec![iter.next().unwrap().ip()],
|
||||
// default lookup didn't work, so try again with the dedicated resolver
|
||||
Err(_) => resolve_ips_from_host(address, resolver),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uses DNS to get the IPS associated with host
|
||||
fn resolve_ips_from_host(source: &str, backup_resolver: &Resolver) -> Vec<IpAddr> {
|
||||
fn resolve_ips_from_host<'a>(
|
||||
source: &str,
|
||||
backup_resolver: &impl Fn() -> &'a Resolver,
|
||||
) -> Vec<IpAddr> {
|
||||
let mut ips: Vec<IpAddr> = 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());
|
||||
}
|
||||
|
||||
ips
|
||||
}
|
||||
|
||||
/// Parses excluded networks from a list of addresses.
|
||||
///
|
||||
/// This function handles three types of inputs:
|
||||
/// 1. CIDR notation (e.g. "192.168.0.0/24")
|
||||
/// 2. Single IP addresses (e.g. "192.168.0.1")
|
||||
/// 3. Hostnames that need to be resolved (e.g. "example.com")
|
||||
///
|
||||
/// ```rust
|
||||
/// # use rustscan::address::parse_excluded_networks;
|
||||
/// # use hickory_resolver::Resolver;
|
||||
/// let resolver = Resolver::default().unwrap();
|
||||
/// let excluded = parse_excluded_networks(&Some(vec!["192.168.0.0/24".to_owned()]), &resolver);
|
||||
/// ```
|
||||
pub fn parse_excluded_networks(
|
||||
exclude_addresses: &Option<Vec<String>>,
|
||||
resolver: &Resolver,
|
||||
) -> Vec<IpCidr> {
|
||||
parse_excluded_networks_with_resolver(exclude_addresses, &|| resolver)
|
||||
}
|
||||
|
||||
fn parse_excluded_networks_with_resolver<'a>(
|
||||
exclude_addresses: &Option<Vec<String>>,
|
||||
resolver: &impl Fn() -> &'a Resolver,
|
||||
) -> Vec<IpCidr> {
|
||||
exclude_addresses
|
||||
.iter()
|
||||
.flatten()
|
||||
.flat_map(|addr| parse_single_excluded_address(addr, resolver))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parses a single address into an IpCidr, handling CIDR notation, IP addresses, and hostnames.
|
||||
fn parse_single_excluded_address<'a>(
|
||||
addr: &str,
|
||||
resolver: &impl Fn() -> &'a Resolver,
|
||||
) -> Vec<IpCidr> {
|
||||
if let Ok(cidr) = IpCidr::from_str(addr) {
|
||||
return vec![cidr];
|
||||
}
|
||||
|
||||
if let Ok(ip) = IpAddr::from_str(addr) {
|
||||
return vec![IpCidr::new_host(ip)];
|
||||
}
|
||||
|
||||
resolve_ips_from_host(addr, resolver)
|
||||
.into_iter()
|
||||
.map(IpCidr::new_host)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Derive a DNS resolver.
|
||||
///
|
||||
/// 1. if the `resolver` parameter has been set:
|
||||
@@ -152,17 +231,56 @@ fn get_resolver(resolver: &Option<String>) -> Resolver {
|
||||
Protocol::Udp,
|
||||
));
|
||||
}
|
||||
Resolver::new(config, ResolverOpts::default()).unwrap()
|
||||
Resolver::new(config, resolver_opts()).unwrap()
|
||||
}
|
||||
None => match Resolver::from_system_conf() {
|
||||
None => match system_resolver() {
|
||||
Ok(resolver) => resolver,
|
||||
Err(_) => {
|
||||
Resolver::new(ResolverConfig::cloudflare_tls(), ResolverOpts::default()).unwrap()
|
||||
}
|
||||
Err(_) => Resolver::new(ResolverConfig::cloudflare_tls(), resolver_opts()).unwrap(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `true` on Windows when the `SystemRoot` environment variable is unset.
|
||||
///
|
||||
/// hickory-resolver locates the hosts file via
|
||||
/// `std::env::var_os("SystemRoot").expect(...)`, which panics when the
|
||||
/// variable is missing. Processes spawned with a minimal environment
|
||||
/// (services, scheduled tasks, WMI) can lack `SystemRoot`, and the panic is
|
||||
/// fatal under this crate's `panic = "abort"` release profile.
|
||||
fn windows_system_root_missing() -> bool {
|
||||
cfg!(windows) && std::env::var_os("SystemRoot").is_none()
|
||||
}
|
||||
|
||||
/// Derives a resolver from the system configuration, e.g. `/etc/resolv.conf`
|
||||
/// on *nix or the registry on Windows.
|
||||
///
|
||||
/// Returns an error without touching the system configuration when doing so
|
||||
/// would panic inside hickory-resolver (see [`windows_system_root_missing`]).
|
||||
fn system_resolver() -> std::io::Result<Resolver> {
|
||||
if windows_system_root_missing() {
|
||||
debug!("SystemRoot is not set; skipping system resolver configuration");
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
"SystemRoot environment variable is not set",
|
||||
));
|
||||
}
|
||||
|
||||
Resolver::from_system_conf()
|
||||
}
|
||||
|
||||
/// Resolver options that are safe to use in the current environment.
|
||||
///
|
||||
/// hickory-resolver eagerly loads the hosts file when `use_hosts_file` is set
|
||||
/// (the default), which hits the same missing-`SystemRoot` panic described in
|
||||
/// [`windows_system_root_missing`]; disable it in that case.
|
||||
fn resolver_opts() -> ResolverOpts {
|
||||
let mut opts = ResolverOpts::default();
|
||||
if windows_system_root_missing() {
|
||||
opts.use_hosts_file = false;
|
||||
}
|
||||
opts
|
||||
}
|
||||
|
||||
/// Parses and input file of IPs for use in DNS resolution.
|
||||
fn read_resolver_from_file(path: &str) -> Result<Vec<IpAddr>, std::io::Error> {
|
||||
let ips = fs::read_to_string(path)?
|
||||
@@ -175,9 +293,9 @@ fn read_resolver_from_file(path: &str) -> Result<Vec<IpAddr>, 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<Vec<IpAddr>, std::io::Error> {
|
||||
let file = File::open(ips)?;
|
||||
let reader = BufReader::new(file);
|
||||
@@ -186,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");
|
||||
}
|
||||
@@ -197,8 +315,47 @@ fn read_ips_from_file(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{get_resolver, parse_addresses, Opts};
|
||||
use std::net::Ipv4Addr;
|
||||
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::<IpAddr>().unwrap(),
|
||||
"2001:db8::2".parse::<IpAddr>().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() {
|
||||
@@ -279,81 +436,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_correct_host_addresses() {
|
||||
let opts = Opts {
|
||||
addresses: vec!["google.com".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ips = parse_addresses(&opts);
|
||||
|
||||
assert_eq!(ips.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_correct_and_incorrect_addresses() {
|
||||
let opts = Opts {
|
||||
addresses: vec!["127.0.0.1".to_owned(), "im_wrong".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ips = parse_addresses(&opts);
|
||||
|
||||
assert_eq!(ips, [Ipv4Addr::new(127, 0, 0, 1),]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_incorrect_addresses() {
|
||||
let opts = Opts {
|
||||
addresses: vec!["im_wrong".to_owned(), "300.10.1.1".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ips = parse_addresses(&opts);
|
||||
|
||||
assert!(ips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_hosts_file_and_incorrect_hosts() {
|
||||
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
|
||||
let opts = Opts {
|
||||
addresses: vec!["fixtures/hosts.txt".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ips = parse_addresses(&opts);
|
||||
|
||||
assert_eq!(ips.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_hosts_file() {
|
||||
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
|
||||
let opts = Opts {
|
||||
addresses: vec!["fixtures/empty_hosts.txt".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ips = parse_addresses(&opts);
|
||||
|
||||
assert_eq!(ips.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_naughty_host_file() {
|
||||
// Host file contains IP, Hosts, incorrect IPs, incorrect hosts
|
||||
let opts = Opts {
|
||||
addresses: vec!["fixtures/naughty_string.txt".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ips = parse_addresses(&opts);
|
||||
|
||||
assert_eq!(ips.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_duplicate_cidrs() {
|
||||
let opts = Opts {
|
||||
@@ -367,27 +449,102 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_default_cloudflare() {
|
||||
let opts = Opts::default();
|
||||
|
||||
let resolver = get_resolver(&opts.resolver);
|
||||
let lookup = resolver.lookup_ip("www.example.com.").unwrap();
|
||||
|
||||
assert!(opts.resolver.is_none());
|
||||
assert!(lookup.iter().next().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolver_args_google_dns() {
|
||||
// https://developers.google.com/speed/public-dns
|
||||
fn parse_overspecific_cidr() {
|
||||
// a canonical CIDR string has 0 in all host bits, but we want to treat any CIDR-like string as CIDR
|
||||
let opts = Opts {
|
||||
resolver: Some("8.8.8.8,8.8.4.4".to_owned()),
|
||||
addresses: vec!["192.128.1.1/24".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let resolver = get_resolver(&opts.resolver);
|
||||
let lookup = resolver.lookup_ip("www.example.com.").unwrap();
|
||||
let ips = parse_addresses(&opts);
|
||||
|
||||
assert!(lookup.iter().next().is_some());
|
||||
assert_eq!(ips.len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_non_canonical_cidr_mid_block() {
|
||||
// 192.168.1.13/29: .13 = 0000 1101, mask clears last 3 bits → .8 = 0000 1000
|
||||
// network is 192.168.1.8/29, spanning .8 through .15
|
||||
let opts = Opts {
|
||||
addresses: vec!["192.168.1.13/29".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
let ips = parse_addresses(&opts);
|
||||
assert_eq!(
|
||||
ips,
|
||||
[
|
||||
Ipv4Addr::new(192, 168, 1, 8),
|
||||
Ipv4Addr::new(192, 168, 1, 9),
|
||||
Ipv4Addr::new(192, 168, 1, 10),
|
||||
Ipv4Addr::new(192, 168, 1, 11),
|
||||
Ipv4Addr::new(192, 168, 1, 12),
|
||||
Ipv4Addr::new(192, 168, 1, 13),
|
||||
Ipv4Addr::new(192, 168, 1, 14),
|
||||
Ipv4Addr::new(192, 168, 1, 15),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_non_canonical_cidr_last_in_block() {
|
||||
// 192.168.1.15/29: last address in the block, should still resolve to same .8–.15 network
|
||||
let opts = Opts {
|
||||
addresses: vec!["192.168.1.15/29".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
let ips = parse_addresses(&opts);
|
||||
assert_eq!(
|
||||
ips,
|
||||
[
|
||||
Ipv4Addr::new(192, 168, 1, 8),
|
||||
Ipv4Addr::new(192, 168, 1, 9),
|
||||
Ipv4Addr::new(192, 168, 1, 10),
|
||||
Ipv4Addr::new(192, 168, 1, 11),
|
||||
Ipv4Addr::new(192, 168, 1, 12),
|
||||
Ipv4Addr::new(192, 168, 1, 13),
|
||||
Ipv4Addr::new(192, 168, 1, 14),
|
||||
Ipv4Addr::new(192, 168, 1, 15),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_non_canonical_cidr_crosses_third_octet() {
|
||||
// 192.168.1.5/23: host bits span into the third octet
|
||||
// .1.5 in 23-bit context → network is 192.168.0.0/23, spanning .0.0 through .1.255 (512 addresses)
|
||||
let opts = Opts {
|
||||
addresses: vec!["192.168.1.5/23".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
let ips = parse_addresses(&opts);
|
||||
assert_eq!(
|
||||
ips.first(),
|
||||
Some(&IpAddr::V4(Ipv4Addr::new(192, 168, 0, 0)))
|
||||
);
|
||||
assert_eq!(
|
||||
ips.last(),
|
||||
Some(&IpAddr::V4(Ipv4Addr::new(192, 168, 1, 255)))
|
||||
);
|
||||
assert_eq!(ips.len(), 512);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_non_canonical_cidr_slash30() {
|
||||
// 10.0.0.7/30: .7 = 0000 0111, mask clears last 2 bits → .4 = 0000 0100
|
||||
// network is 10.0.0.4/30, spanning .4 through .7
|
||||
let opts = Opts {
|
||||
addresses: vec!["10.0.0.7/30".to_owned()],
|
||||
..Default::default()
|
||||
};
|
||||
let ips = parse_addresses(&opts);
|
||||
assert_eq!(
|
||||
ips,
|
||||
[
|
||||
Ipv4Addr::new(10, 0, 0, 4),
|
||||
Ipv4Addr::new(10, 0, 0, 5),
|
||||
Ipv4Addr::new(10, 0, 0, 6),
|
||||
Ipv4Addr::new(10, 0, 0, 7),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -42,13 +42,11 @@ impl Benchmark {
|
||||
let mut summary = String::from("\nRustScan Benchmark Summary");
|
||||
|
||||
for timer in &self.named_timers {
|
||||
if timer.start.is_some() && timer.end.is_some() {
|
||||
let runtime_secs = timer
|
||||
.end
|
||||
.unwrap()
|
||||
.saturating_duration_since(timer.start.unwrap())
|
||||
.as_secs_f32();
|
||||
summary.push_str(&format!("\n{0: <10} | {1: <10}s", timer.name, runtime_secs));
|
||||
if let Some(start) = timer.start {
|
||||
if let Some(end) = timer.end {
|
||||
let runtime_secs = end.saturating_duration_since(start).as_secs_f32();
|
||||
summary.push_str(&format!("\n{0: <10} | {1: <10}s", timer.name, runtime_secs));
|
||||
}
|
||||
}
|
||||
}
|
||||
summary
|
||||
|
||||
+455
-58
@@ -1,7 +1,9 @@
|
||||
//! Provides a means to read, parse and hold configuration options for scans.
|
||||
use clap::{Parser, ValueEnum};
|
||||
use clap::parser::ValueSource;
|
||||
use clap::{CommandFactory, FromArgMatches, Parser, ValueEnum};
|
||||
use serde_derive::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::OsString;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -28,35 +30,94 @@ pub enum ScriptsRequired {
|
||||
Custom,
|
||||
}
|
||||
|
||||
/// Represents the range of ports to be scanned.
|
||||
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PortRange {
|
||||
pub start: u16,
|
||||
pub end: u16,
|
||||
/// The port ranges to scan, as inclusive `(start, end)` pairs.
|
||||
///
|
||||
/// Parsed from `-r/--range` (e.g. `1-500,1000-2500`) and from the `range`
|
||||
/// key of the configuration file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PortRanges(pub Vec<(u16, u16)>);
|
||||
|
||||
/// Accepted spellings of `range` in the configuration file.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum PortRangesConfig {
|
||||
/// `range = { start = 1, end = 1000 }`: the format used before multiple
|
||||
/// ranges were supported, kept so existing configuration files still work.
|
||||
Single { start: u16, end: u16 },
|
||||
/// `range = "1-500,1000-2500"`: the same syntax as `--range`.
|
||||
Text(String),
|
||||
/// `range = [[1, 500], [1000, 2500]]`.
|
||||
Pairs(Vec<(u16, u16)>),
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for PortRanges {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
let pairs = match PortRangesConfig::deserialize(deserializer)? {
|
||||
PortRangesConfig::Single { start, end } => vec![(start, end)],
|
||||
PortRangesConfig::Text(text) => {
|
||||
return parse_ranges(&text).map_err(serde::de::Error::custom)
|
||||
}
|
||||
PortRangesConfig::Pairs(pairs) => pairs,
|
||||
};
|
||||
|
||||
if pairs.is_empty() {
|
||||
return Err(serde::de::Error::custom("expected at least one port range"));
|
||||
}
|
||||
if let Some(&(start, end)) = pairs.iter().find(|&&(start, end)| start > end) {
|
||||
return Err(serde::de::Error::custom(format!(
|
||||
"invalid port range {start}-{end}: start must not be greater than end"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(PortRanges(pairs))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn parse_range(input: &str) -> Result<PortRange, String> {
|
||||
let range = input
|
||||
.split('-')
|
||||
.map(str::parse)
|
||||
.collect::<Result<Vec<u16>, std::num::ParseIntError>>();
|
||||
/// Parse a single `start-end` token (e.g. "100-200") into `(start, end)`.
|
||||
/// Returns `None` when the token is malformed, cannot be parsed as `u16`,
|
||||
/// or `start > end`.
|
||||
fn parse_range(input: &str) -> Option<(u16, u16)> {
|
||||
let mut parts = input.trim().splitn(2, '-').map(str::trim);
|
||||
let a = parts.next()?;
|
||||
let b = parts.next()?;
|
||||
|
||||
if range.is_err() {
|
||||
return Err(String::from(
|
||||
"the range format must be 'start-end'. Example: 1-1000.",
|
||||
));
|
||||
let start = a.parse::<u16>().ok()?;
|
||||
let end = b.parse::<u16>().ok()?;
|
||||
|
||||
if start <= end {
|
||||
Some((start, end))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
/// Parse a comma-separated list of `start-end` ranges into `PortRanges`.
|
||||
///
|
||||
/// Errors with a helpful message identifying the bad token.
|
||||
fn parse_ranges(input: &str) -> Result<PortRanges, String> {
|
||||
let s = input.trim();
|
||||
if s.is_empty() {
|
||||
return Err("empty input: expected one or more comma-separated 'start-end' pairs".into());
|
||||
}
|
||||
|
||||
match range.unwrap().as_slice() {
|
||||
[start, end] => Ok(PortRange {
|
||||
start: *start,
|
||||
end: *end,
|
||||
}),
|
||||
_ => Err(String::from(
|
||||
"the range format must be 'start-end'. Example: 1-1000.",
|
||||
)),
|
||||
}
|
||||
let ranges_res: Result<Vec<(u16, u16)>, String> = s
|
||||
.split(',')
|
||||
.map(|token| {
|
||||
let t = token.trim();
|
||||
parse_range(t).ok_or_else(|| {
|
||||
format!(
|
||||
"invalid range token `{}` — expected `start-end` with 0 <= start <= end <= 65535",
|
||||
t
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
ranges_res.map(PortRanges)
|
||||
}
|
||||
|
||||
#[derive(Parser, Debug, Clone)]
|
||||
@@ -81,9 +142,9 @@ pub struct Opts {
|
||||
#[arg(short, long, value_delimiter = ',')]
|
||||
pub ports: Option<Vec<u16>>,
|
||||
|
||||
/// A range of ports with format start-end. Example: 1-1000.
|
||||
#[arg(short, long, conflicts_with = "ports", value_parser = parse_range)]
|
||||
pub range: Option<PortRange>,
|
||||
/// Ranges of ports as comma-separated start-end pairs. Example: 1-500,1000-2500,4000-7000
|
||||
#[arg(short, long, conflicts_with = "ports", value_parser = parse_ranges)]
|
||||
pub range: Option<PortRanges>,
|
||||
|
||||
/// Whether to ignore the configuration file or not.
|
||||
#[arg(short, long)]
|
||||
@@ -114,7 +175,7 @@ pub struct Opts {
|
||||
/// it will do every port at the same time. Although, your OS may not
|
||||
/// support this.
|
||||
#[arg(short, long, default_value = "4500")]
|
||||
pub batch_size: u16,
|
||||
pub batch_size: usize,
|
||||
|
||||
/// The timeout in milliseconds before a port is assumed to be closed.
|
||||
#[arg(short, long, default_value = "1500")]
|
||||
@@ -125,9 +186,10 @@ pub struct Opts {
|
||||
#[arg(long, default_value = "1")]
|
||||
pub tries: u8,
|
||||
|
||||
/// Automatically ups the ULIMIT with the value you provided.
|
||||
/// Automatically increases the Unix file-descriptor limit.
|
||||
#[cfg_attr(not(unix), arg(hide = true))]
|
||||
#[arg(short, long)]
|
||||
pub ulimit: Option<u64>,
|
||||
pub ulimit: Option<usize>,
|
||||
|
||||
/// The order of scanning to be performed. The "serial" option will
|
||||
/// scan ports in ascending order while the "random" option will scan
|
||||
@@ -162,25 +224,84 @@ pub struct Opts {
|
||||
/// UDP scanning mode, finds UDP ports that send back responses
|
||||
#[arg(long)]
|
||||
pub udp: bool,
|
||||
|
||||
/// Also list TCP ports that actively refused the connection (closed).
|
||||
/// Closed ports are only printed; scripts are never run against them.
|
||||
#[arg(long)]
|
||||
pub closed: bool,
|
||||
|
||||
/// Milliseconds to wait after scanning a port (on every address) before
|
||||
/// scanning the next one, for slow, low-noise scans. 0 disables the delay.
|
||||
#[arg(long, default_value = "0", value_name = "MS")]
|
||||
pub interval: u64,
|
||||
|
||||
/// Ids of the options given explicitly on the command line. The
|
||||
/// configuration file never overrides these (see [`Opts::merge`]).
|
||||
#[arg(skip)]
|
||||
pub cli_args: HashSet<String>,
|
||||
}
|
||||
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
impl Opts {
|
||||
pub fn read() -> Self {
|
||||
let mut opts = Opts::parse();
|
||||
Self::try_read_from(std::env::args_os()).unwrap_or_else(|e| e.exit())
|
||||
}
|
||||
|
||||
/// Parses `args` like [`Opts::read`] does, returning an error instead of
|
||||
/// exiting.
|
||||
fn try_read_from<I, T>(args: I) -> Result<Self, clap::Error>
|
||||
where
|
||||
I: IntoIterator<Item = T>,
|
||||
T: Into<OsString> + Clone,
|
||||
{
|
||||
let mut command = Self::command();
|
||||
let mut matches = command.try_get_matches_from_mut(args)?;
|
||||
|
||||
// clap fills in default values, so the parsed struct alone cannot
|
||||
// tell `-b 4500` apart from no `-b` at all. Record what the user
|
||||
// actually typed so that `merge` lets it win over the config file.
|
||||
let cli_args = command
|
||||
.get_arguments()
|
||||
.map(|arg| arg.get_id().as_str())
|
||||
.filter(|id| matches.value_source(id) == Some(ValueSource::CommandLine))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
|
||||
let mut opts =
|
||||
Self::from_arg_matches_mut(&mut matches).map_err(|e| e.format(&mut command))?;
|
||||
opts.cli_args = cli_args;
|
||||
|
||||
if opts.ports.is_none() && opts.range.is_none() {
|
||||
opts.range = Some(PortRange {
|
||||
start: LOWEST_PORT_NUMBER,
|
||||
end: TOP_PORT_NUMBER,
|
||||
});
|
||||
opts.range = Some(PortRanges(vec![(LOWEST_PORT_NUMBER, TOP_PORT_NUMBER)]));
|
||||
}
|
||||
|
||||
opts
|
||||
Ok(opts)
|
||||
}
|
||||
|
||||
/// Validates options whose availability or semantics depend on the
|
||||
/// operating system.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error when an option is unsupported on the current platform.
|
||||
pub fn validate_platform(&self) -> Result<(), String> {
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
if self.ulimit.is_some() {
|
||||
return Err(
|
||||
"--ulimit is only supported on Unix-like operating systems. \
|
||||
On Windows, use --batch-size (-b) to control scan concurrency."
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads the command line arguments into an Opts struct and merge
|
||||
/// values found within the user configuration file.
|
||||
/// values found within the user configuration file. Options given on the
|
||||
/// command line take precedence over the configuration file.
|
||||
pub fn merge(&mut self, config: &Config) {
|
||||
if !self.no_config {
|
||||
self.merge_required(config);
|
||||
@@ -193,7 +314,9 @@ impl Opts {
|
||||
($($field: ident),+) => {
|
||||
$(
|
||||
if let Some(e) = &config.$field {
|
||||
self.$field = e.clone();
|
||||
if !self.cli_args.contains(stringify!($field)) {
|
||||
self.$field = e.clone();
|
||||
}
|
||||
}
|
||||
)+
|
||||
}
|
||||
@@ -201,7 +324,7 @@ impl Opts {
|
||||
|
||||
merge_required!(
|
||||
addresses, greppable, accessible, batch_size, timeout, tries, scan_order, scripts,
|
||||
command, udp
|
||||
command, udp, no_banner, closed, interval
|
||||
);
|
||||
}
|
||||
|
||||
@@ -209,7 +332,7 @@ impl Opts {
|
||||
macro_rules! merge_optional {
|
||||
($($field: ident),+) => {
|
||||
$(
|
||||
if config.$field.is_some() {
|
||||
if config.$field.is_some() && !self.cli_args.contains(stringify!($field)) {
|
||||
self.$field = config.$field.clone();
|
||||
}
|
||||
)+
|
||||
@@ -218,11 +341,7 @@ impl Opts {
|
||||
|
||||
// Only use top ports when the user asks for them
|
||||
if self.top && config.ports.is_some() {
|
||||
let mut ports: Vec<u16> = Vec::with_capacity(config.ports.as_ref().unwrap().len());
|
||||
for entry in config.ports.as_ref().unwrap().keys() {
|
||||
ports.push(entry.parse().unwrap());
|
||||
}
|
||||
self.ports = Some(ports);
|
||||
self.ports = config.ports.clone();
|
||||
}
|
||||
|
||||
merge_optional!(range, resolver, ulimit, exclude_ports, exclude_addresses);
|
||||
@@ -252,6 +371,9 @@ impl Default for Opts {
|
||||
exclude_ports: None,
|
||||
exclude_addresses: None,
|
||||
udp: false,
|
||||
closed: false,
|
||||
interval: 0,
|
||||
cli_args: HashSet::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -263,14 +385,14 @@ impl Default for Opts {
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Config {
|
||||
addresses: Option<Vec<String>>,
|
||||
ports: Option<HashMap<String, u16>>,
|
||||
range: Option<PortRange>,
|
||||
ports: Option<Vec<u16>>,
|
||||
range: Option<PortRanges>,
|
||||
greppable: Option<bool>,
|
||||
accessible: Option<bool>,
|
||||
batch_size: Option<u16>,
|
||||
batch_size: Option<usize>,
|
||||
timeout: Option<u32>,
|
||||
tries: Option<u8>,
|
||||
ulimit: Option<u64>,
|
||||
ulimit: Option<usize>,
|
||||
resolver: Option<String>,
|
||||
scan_order: Option<ScanOrder>,
|
||||
command: Option<Vec<String>>,
|
||||
@@ -278,6 +400,9 @@ pub struct Config {
|
||||
exclude_ports: Option<Vec<u16>>,
|
||||
exclude_addresses: Option<Vec<String>>,
|
||||
udp: Option<bool>,
|
||||
no_banner: Option<bool>,
|
||||
closed: Option<bool>,
|
||||
interval: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
@@ -295,10 +420,26 @@ impl Config {
|
||||
/// scan_order = "Serial"
|
||||
/// exclude_ports = [8080, 9090, 80]
|
||||
/// udp = false
|
||||
/// closed = false
|
||||
/// interval = 0
|
||||
///
|
||||
pub fn read(custom_config_path: Option<PathBuf>) -> Self {
|
||||
let mut content = String::new();
|
||||
let config_path = custom_config_path.unwrap_or_else(default_config_path);
|
||||
let config_path = custom_config_path.unwrap_or_else(|| {
|
||||
// Try the XDG-idiomatic location first, then fall back to legacy
|
||||
// paths so existing users keep working unchanged.
|
||||
for path in [
|
||||
default_config_path(),
|
||||
legacy_dot_config_path(),
|
||||
old_default_config_path(),
|
||||
] {
|
||||
if path.exists() {
|
||||
return path;
|
||||
}
|
||||
}
|
||||
default_config_path()
|
||||
});
|
||||
|
||||
if config_path.exists() {
|
||||
content = match fs::read_to_string(config_path) {
|
||||
Ok(content) => content,
|
||||
@@ -309,7 +450,9 @@ impl Config {
|
||||
let config: Config = match toml::from_str(&content) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
println!("Found {e} in configuration file.\nAborting scan.\n");
|
||||
crate::tui::println_safe(format_args!(
|
||||
"Found {e} in configuration file.\nAborting scan.\n"
|
||||
));
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
@@ -318,8 +461,30 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
/// Constructs default path to config toml
|
||||
/// Returns the preferred config file path: `$XDG_CONFIG_HOME/rustscan/config.toml`
|
||||
/// on Linux (with the usual `~/.config` fallback when the variable is unset),
|
||||
/// and the platform-equivalent `dirs::config_dir()` location on macOS / Windows.
|
||||
pub fn default_config_path() -> PathBuf {
|
||||
let Some(mut config_path) = dirs::config_dir() else {
|
||||
panic!("Could not infer config file path.");
|
||||
};
|
||||
config_path.push("rustscan");
|
||||
config_path.push("config.toml");
|
||||
config_path
|
||||
}
|
||||
|
||||
/// Returns the transitional `$XDG_CONFIG_HOME/.rustscan.toml` path that older
|
||||
/// builds wrote to. Kept readable for backwards compatibility.
|
||||
pub fn legacy_dot_config_path() -> PathBuf {
|
||||
let Some(mut config_path) = dirs::config_dir() else {
|
||||
panic!("Could not infer config file path.");
|
||||
};
|
||||
config_path.push(".rustscan.toml");
|
||||
config_path
|
||||
}
|
||||
|
||||
/// Returns the deprecated home directory config path used for backwards compatibility.
|
||||
pub fn old_default_config_path() -> PathBuf {
|
||||
let Some(mut config_path) = dirs::home_dir() else {
|
||||
panic!("Could not infer config file path.");
|
||||
};
|
||||
@@ -331,8 +496,9 @@ pub fn default_config_path() -> PathBuf {
|
||||
mod tests {
|
||||
use clap::{CommandFactory, Parser};
|
||||
use parameterized::parameterized;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use super::{Config, Opts, PortRange, ScanOrder, ScriptsRequired};
|
||||
use super::{Config, Opts, PortRanges, ScanOrder, ScriptsRequired};
|
||||
|
||||
impl Config {
|
||||
fn default() -> Self {
|
||||
@@ -353,6 +519,9 @@ mod tests {
|
||||
exclude_ports: None,
|
||||
exclude_addresses: None,
|
||||
udp: Some(false),
|
||||
no_banner: None,
|
||||
closed: Some(false),
|
||||
interval: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -382,6 +551,82 @@ mod tests {
|
||||
assert_eq!(command, opts.command);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_batch_size() {
|
||||
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "-b", "1234"]);
|
||||
|
||||
assert_eq!(opts.batch_size, 1234);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_explicit_long_batch_size() {
|
||||
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--batch-size", "4321"]);
|
||||
|
||||
assert_eq!(opts.batch_size, 4321);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_platform_validation_accepts_batch_size() {
|
||||
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--batch-size", "500"]);
|
||||
|
||||
assert!(opts.validate_platform().is_ok());
|
||||
assert_eq!(opts.batch_size, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_rejects_ulimit() {
|
||||
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--ulimit", "5000"]);
|
||||
|
||||
let error = opts
|
||||
.validate_platform()
|
||||
.expect_err("Windows must reject --ulimit");
|
||||
|
||||
assert!(error.contains("--ulimit"));
|
||||
assert!(error.contains("Unix"));
|
||||
assert!(error.contains("--batch-size"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_hides_ulimit_from_help() {
|
||||
let help = Opts::command().render_long_help().to_string();
|
||||
|
||||
assert!(
|
||||
!help.contains("--ulimit"),
|
||||
"--ulimit should not be advertised on Windows"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_rejects_ulimit_from_config_merge() {
|
||||
let mut opts = Opts {
|
||||
no_config: false,
|
||||
..Default::default()
|
||||
};
|
||||
let mut config = Config::default();
|
||||
config.ulimit = Some(5_000);
|
||||
|
||||
opts.merge(&config);
|
||||
|
||||
let error = opts
|
||||
.validate_platform()
|
||||
.expect_err("Windows must reject --ulimit supplied by configuration");
|
||||
|
||||
assert!(error.contains("--ulimit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn unix_platform_validation_accepts_ulimit() {
|
||||
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--ulimit", "5000"]);
|
||||
|
||||
assert!(opts.validate_platform().is_ok());
|
||||
assert_eq!(opts.ulimit, Some(5_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opts_no_merge_when_config_is_ignored() {
|
||||
let mut opts = Opts::default();
|
||||
@@ -417,10 +662,7 @@ mod tests {
|
||||
fn opts_merge_optional_arguments() {
|
||||
let mut opts = Opts::default();
|
||||
let mut config = Config::default();
|
||||
config.range = Some(PortRange {
|
||||
start: 1,
|
||||
end: 1_000,
|
||||
});
|
||||
config.range = Some(PortRanges(vec![(1, 1_000)]));
|
||||
config.ulimit = Some(1_000);
|
||||
config.resolver = Some("1.1.1.1".to_owned());
|
||||
|
||||
@@ -430,4 +672,159 @@ mod tests {
|
||||
assert_eq!(opts.ulimit, config.ulimit);
|
||||
assert_eq!(opts.resolver, config.resolver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cli_arguments_override_config() {
|
||||
// Regression test for #722: the config file used to silently replace
|
||||
// options given on the command line, e.g. `-r` with its `range`.
|
||||
let mut opts = Opts::try_read_from([
|
||||
"rustscan",
|
||||
"-a",
|
||||
"192.168.0.1",
|
||||
"-r",
|
||||
"8600-8650",
|
||||
"-b",
|
||||
"100",
|
||||
// Passing clap's default value explicitly must still win.
|
||||
"-t",
|
||||
"1500",
|
||||
"--tries",
|
||||
"3",
|
||||
"--scan-order",
|
||||
"serial",
|
||||
"--scripts",
|
||||
"none",
|
||||
"-g",
|
||||
"--",
|
||||
"-sV",
|
||||
])
|
||||
.unwrap();
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
addresses = ["127.0.0.1"]
|
||||
range = { start = 1, end = 100 }
|
||||
batch_size = 25000
|
||||
timeout = 1000
|
||||
tries = 1
|
||||
scan_order = "Random"
|
||||
scripts = "Custom"
|
||||
greppable = false
|
||||
command = ["-A"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
opts.merge(&config);
|
||||
|
||||
assert_eq!(opts.addresses, vec!["192.168.0.1".to_owned()]);
|
||||
assert_eq!(opts.range, Some(PortRanges(vec![(8_600, 8_650)])));
|
||||
assert_eq!(opts.batch_size, 100);
|
||||
assert_eq!(opts.timeout, 1_500);
|
||||
assert_eq!(opts.tries, 3);
|
||||
assert_eq!(opts.scan_order, ScanOrder::Serial);
|
||||
assert_eq!(opts.scripts, ScriptsRequired::None);
|
||||
assert!(opts.greppable);
|
||||
assert_eq!(opts.command, vec!["-sV".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_fills_arguments_not_given_on_cli() {
|
||||
let mut opts = Opts::try_read_from(["rustscan", "-a", "192.168.0.1"]).unwrap();
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
addresses = ["127.0.0.1"]
|
||||
range = { start = 1, end = 100 }
|
||||
batch_size = 25000
|
||||
timeout = 1000
|
||||
tries = 3
|
||||
scan_order = "Random"
|
||||
greppable = true
|
||||
command = ["-A"]
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
opts.merge(&config);
|
||||
|
||||
// Only `-a` was typed; clap defaults such as `-b 4500` must not count.
|
||||
assert_eq!(opts.cli_args, HashSet::from(["addresses".to_owned()]));
|
||||
assert_eq!(opts.addresses, vec!["192.168.0.1".to_owned()]);
|
||||
assert_eq!(opts.range, Some(PortRanges(vec![(1, 100)])));
|
||||
assert_eq!(opts.batch_size, 25_000);
|
||||
assert_eq!(opts.timeout, 1_000);
|
||||
assert_eq!(opts.tries, 3);
|
||||
assert_eq!(opts.scan_order, ScanOrder::Random);
|
||||
assert!(opts.greppable);
|
||||
assert_eq!(opts.command, vec!["-A".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_comma_separated_ranges() {
|
||||
let opts = Opts::parse_from([
|
||||
"rustscan",
|
||||
"-a",
|
||||
"127.0.0.1",
|
||||
"-r",
|
||||
"1-100, 200-300,5000-5100",
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
opts.range,
|
||||
Some(PortRanges(vec![(1, 100), (200, 300), (5_000, 5_100)]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_ranges() {
|
||||
for range in ["", "300-200", "1-100,", "1-2-3", "a-b", "1-70000"] {
|
||||
assert!(
|
||||
Opts::try_parse_from(["rustscan", "-a", "127.0.0.1", "-r", range]).is_err(),
|
||||
"{:?} should be rejected",
|
||||
range
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_range_accepts_legacy_table() {
|
||||
let config: Config = toml::from_str("range = { start = 1, end = 1000 }").unwrap();
|
||||
|
||||
assert_eq!(config.range, Some(PortRanges(vec![(1, 1_000)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_range_accepts_string_and_pairs() {
|
||||
let expected = Some(PortRanges(vec![(1, 100), (200, 300)]));
|
||||
|
||||
let config: Config = toml::from_str("range = \"1-100,200-300\"").unwrap();
|
||||
assert_eq!(config.range, expected);
|
||||
|
||||
let config: Config = toml::from_str("range = [[1, 100], [200, 300]]").unwrap();
|
||||
assert_eq!(config.range, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_range_rejects_invalid_ranges() {
|
||||
for range in [
|
||||
"range = { start = 10, end = 1 }",
|
||||
"range = [[10, 1]]",
|
||||
"range = []",
|
||||
"range = \"10-1\"",
|
||||
] {
|
||||
assert!(
|
||||
toml::from_str::<Config>(range).is_err(),
|
||||
"{:?} should be rejected",
|
||||
range
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_interval_in_milliseconds() {
|
||||
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1", "--interval", "250"]);
|
||||
assert_eq!(opts.interval, 250);
|
||||
|
||||
let opts = Opts::parse_from(["rustscan", "-a", "127.0.0.1"]);
|
||||
assert_eq!(opts.interval, 0);
|
||||
}
|
||||
}
|
||||
+19
-9
@@ -5,22 +5,26 @@
|
||||
//!
|
||||
//! 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).
|
||||
//!
|
||||
//! ```rust
|
||||
//! use async_std::task::block_on;
|
||||
//! 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 std::{net::IpAddr, time::Duration};
|
||||
//!
|
||||
//! use rustscan::input::{PortRange, ScanOrder};
|
||||
//! use rustscan::input::{PortRanges, 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 range = PortRanges(vec![(1, 1_000)]);
|
||||
//! let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random); // can be serial, random or manual https://github.com/RustScan/RustScan/blob/master/src/port_strategy/mod.rs
|
||||
//! let scanner = Scanner::new(
|
||||
//! &addrs, // the addresses to scan
|
||||
@@ -34,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);
|
||||
//! }
|
||||
|
||||
+162
-49
@@ -5,12 +5,12 @@
|
||||
use rustscan::benchmark::{Benchmark, NamedTimer};
|
||||
use rustscan::input::{self, Config, Opts, ScriptsRequired};
|
||||
use rustscan::port_strategy::PortStrategy;
|
||||
use rustscan::scanner::Scanner;
|
||||
use rustscan::scanner::{PortStatus, Scanner};
|
||||
use rustscan::scripts::{init_scripts, Script, ScriptFile};
|
||||
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;
|
||||
@@ -23,9 +23,10 @@ extern crate dirs;
|
||||
|
||||
// Average value for Ubuntu
|
||||
#[cfg(unix)]
|
||||
const DEFAULT_FILE_DESCRIPTORS_LIMIT: u64 = 8000;
|
||||
const DEFAULT_FILE_DESCRIPTORS_LIMIT: usize = 8000;
|
||||
// Safest batch size based on experimentation
|
||||
const AVERAGE_BATCH_SIZE: u16 = 3000;
|
||||
#[cfg(unix)]
|
||||
const AVERAGE_BATCH_SIZE: usize = 3000;
|
||||
|
||||
#[macro_use]
|
||||
extern crate log;
|
||||
@@ -46,7 +47,12 @@ fn main() {
|
||||
let config = Config::read(opts.config_path.clone());
|
||||
opts.merge(&config);
|
||||
|
||||
debug!("Main() `opts` arguments are {:?}", opts);
|
||||
if let Err(message) = opts.validate_platform() {
|
||||
eprintln!("error: {message}");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
debug!("Main() `opts` arguments are {opts:?}");
|
||||
|
||||
let scripts_to_run: Vec<ScriptFile> = match init_scripts(&opts.scripts) {
|
||||
Ok(scripts_to_run) => scripts_to_run,
|
||||
@@ -60,7 +66,7 @@ fn main() {
|
||||
}
|
||||
};
|
||||
|
||||
debug!("Scripts initialized {:?}", &scripts_to_run);
|
||||
debug!("Scripts initialized {scripts_to_run:?}");
|
||||
|
||||
if !opts.greppable && !opts.accessible && !opts.no_banner {
|
||||
print_opening(&opts);
|
||||
@@ -77,13 +83,10 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let batch_size: u16 = infer_batch_size(&opts, adjust_ulimit_size(&opts));
|
||||
let batch_size = effective_batch_size(&opts);
|
||||
debug!("Effective batch size: {batch_size}");
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let batch_size: u16 = AVERAGE_BATCH_SIZE;
|
||||
|
||||
let scanner = Scanner::new(
|
||||
let mut scanner = Scanner::new(
|
||||
&ips,
|
||||
batch_size,
|
||||
Duration::from_millis(opts.timeout.into()),
|
||||
@@ -93,21 +96,36 @@ fn main() {
|
||||
opts.accessible,
|
||||
opts.exclude_ports.unwrap_or_default(),
|
||||
opts.udp,
|
||||
);
|
||||
debug!("Scanner finished building: {:?}", scanner);
|
||||
)
|
||||
.with_open_port_output();
|
||||
if opts.closed {
|
||||
scanner = scanner.with_closed_ports();
|
||||
}
|
||||
if opts.interval > 0 {
|
||||
scanner = scanner.with_interval(Duration::from_millis(opts.interval));
|
||||
}
|
||||
debug!("Scanner finished building: {scanner:?}");
|
||||
|
||||
let mut portscan_bench = NamedTimer::start("Portscan");
|
||||
let scan_result = block_on(scanner.run());
|
||||
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);
|
||||
|
||||
let mut ports_per_ip = HashMap::new();
|
||||
let mut ports_per_ip: HashMap<IpAddr, Vec<u16>> = HashMap::new();
|
||||
let mut closed_ports_per_ip: HashMap<IpAddr, Vec<u16>> = HashMap::new();
|
||||
|
||||
for socket in scan_result {
|
||||
ports_per_ip
|
||||
.entry(socket.ip())
|
||||
.or_insert_with(Vec::new)
|
||||
.push(socket.port());
|
||||
for status in scan_result {
|
||||
let (map, socket) = match status {
|
||||
PortStatus::Open(socket) => (&mut ports_per_ip, socket),
|
||||
PortStatus::Closed(socket) => (&mut closed_ports_per_ip, socket),
|
||||
};
|
||||
map.entry(socket.ip()).or_default().push(socket.port());
|
||||
}
|
||||
|
||||
for ip in ips {
|
||||
@@ -122,7 +140,7 @@ fn main() {
|
||||
\n*I used {} batch size, consider lowering it with {} or a comfortable number for your system.
|
||||
\n Alternatively, increase the timeout if your ping is high. Rustscan -t 2000 for 2000 milliseconds (2s) timeout.\n",
|
||||
ip,
|
||||
opts.batch_size,
|
||||
batch_size,
|
||||
"'rustscan -b <batch_size> -a <ip address>'");
|
||||
warning!(x, opts.greppable, opts.accessible);
|
||||
}
|
||||
@@ -136,17 +154,19 @@ fn main() {
|
||||
|
||||
// if option scripts is none, no script will be spawned
|
||||
if opts.greppable || opts.scripts == ScriptsRequired::None {
|
||||
println!("{} -> [{}]", &ip, ports_str);
|
||||
println_safe(format_args!("{ip} -> [{ports_str}]"));
|
||||
continue;
|
||||
}
|
||||
detail!("Starting Script(s)", opts.greppable, opts.accessible);
|
||||
|
||||
// Run all the scripts we found and parsed based on the script config file tags field.
|
||||
for mut script_f in scripts_to_run.clone() {
|
||||
for script_f in &scripts_to_run {
|
||||
// Clone only the script being run instead of the whole list per IP.
|
||||
let mut script_f = script_f.clone();
|
||||
// This part allows us to add commandline arguments to the Script call_format, appending them to the end of the command.
|
||||
if !opts.command.is_empty() {
|
||||
let user_extra_args = &opts.command.join(" ");
|
||||
debug!("Extra args vec {:?}", user_extra_args);
|
||||
debug!("Extra args vec {user_extra_args:?}");
|
||||
if script_f.call_format.is_some() {
|
||||
let mut call_f = script_f.call_format.unwrap();
|
||||
call_f.push(' ');
|
||||
@@ -156,7 +176,7 @@ fn main() {
|
||||
opts.greppable,
|
||||
opts.accessible
|
||||
);
|
||||
debug!("Call format {}", call_f);
|
||||
debug!("Call format {call_f}");
|
||||
script_f.call_format = Some(call_f);
|
||||
}
|
||||
}
|
||||
@@ -173,7 +193,7 @@ fn main() {
|
||||
);
|
||||
match script.run() {
|
||||
Ok(script_result) => {
|
||||
detail!(script_result.to_string(), opts.greppable, opts.accessible);
|
||||
detail!(script_result.clone(), opts.greppable, opts.accessible);
|
||||
}
|
||||
Err(e) => {
|
||||
warning!(&format!("Error {e}"), opts.greppable, opts.accessible);
|
||||
@@ -182,15 +202,64 @@ fn main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Closed ports are only listed; scripts are never run against them.
|
||||
if opts.closed {
|
||||
println_safe(format_args!("closed ports:"));
|
||||
for (ip, ports) in &closed_ports_per_ip {
|
||||
let ports_str = ports
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
println_safe(format_args!("{ip} -> [{ports_str}]"));
|
||||
}
|
||||
}
|
||||
|
||||
// To use the runtime benchmark, run the process as: RUST_LOG=info ./rustscan
|
||||
script_bench.end();
|
||||
benchmarks.push(script_bench);
|
||||
rustscan_bench.end();
|
||||
benchmarks.push(rustscan_bench);
|
||||
debug!("Benchmarks raw {:?}", benchmarks);
|
||||
debug!("Benchmarks raw {benchmarks:?}");
|
||||
info!("{}", benchmarks.summary());
|
||||
}
|
||||
|
||||
/// Determines the actual batch size used by the scanner.
|
||||
///
|
||||
/// Unix systems may reduce the requested batch size according to the process
|
||||
/// file-descriptor limit. Other platforms, including Windows, use the batch
|
||||
/// size explicitly requested by the user.
|
||||
fn effective_batch_size(opts: &Opts) -> usize {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
infer_batch_size(opts, adjust_ulimit_size(opts))
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
opts.batch_size
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Vec<PortStatus>> {
|
||||
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) {
|
||||
@@ -201,12 +270,12 @@ fn print_opening(opts: &Opts) {
|
||||
`-' `-'`-----'`----' `-' `----' `---' `-' `-'`-' `-'
|
||||
The Modern Day Port Scanner."#;
|
||||
|
||||
println!("{}", s.gradient(Color::Green).bold());
|
||||
println_safe(format_args!("{}", s.gradient(Color::Green).bold()));
|
||||
let info = r#"________________________________________
|
||||
: http://discord.skerritt.blog :
|
||||
: https://github.com/RustScan/RustScan :
|
||||
--------------------------------------"#;
|
||||
println!("{}", info.gradient(Color::Yellow).bold());
|
||||
println_safe(format_args!("{}", info.gradient(Color::Yellow).bold()));
|
||||
funny_opening!();
|
||||
|
||||
let config_path = opts
|
||||
@@ -219,13 +288,26 @@ The Modern Day Port Scanner."#;
|
||||
opts.greppable,
|
||||
opts.accessible
|
||||
);
|
||||
|
||||
if opts.config_path.is_none() {
|
||||
let old_config_path = input::old_default_config_path();
|
||||
detail!(
|
||||
format!(
|
||||
"For backwards compatibility, the config file may also be at {old_config_path:?}"
|
||||
),
|
||||
opts.greppable,
|
||||
opts.accessible
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn adjust_ulimit_size(opts: &Opts) -> u64 {
|
||||
fn adjust_ulimit_size(opts: &Opts) -> usize {
|
||||
use rlimit::Resource;
|
||||
use std::convert::TryInto;
|
||||
|
||||
if let Some(limit) = opts.ulimit {
|
||||
let limit = limit as u64;
|
||||
if Resource::NOFILE.set(limit, limit).is_ok() {
|
||||
detail!(
|
||||
format!("Automatically increasing ulimit value to {limit}."),
|
||||
@@ -242,14 +324,15 @@ fn adjust_ulimit_size(opts: &Opts) -> u64 {
|
||||
}
|
||||
|
||||
let (soft, _) = Resource::NOFILE.get().unwrap();
|
||||
soft
|
||||
// On 32-bit targets the soft limit (e.g. RLIM_INFINITY) may not fit in a
|
||||
// usize. Fall back to the conservative default rather than usize::MAX,
|
||||
// which would skip every batch-size adjustment in infer_batch_size.
|
||||
soft.try_into().unwrap_or(DEFAULT_FILE_DESCRIPTORS_LIMIT)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
|
||||
use std::convert::TryInto;
|
||||
|
||||
let mut batch_size: u64 = opts.batch_size.into();
|
||||
fn infer_batch_size(opts: &Opts, ulimit: usize) -> usize {
|
||||
let mut batch_size = opts.batch_size;
|
||||
|
||||
// Adjust the batch size when the ulimit value is lower than the desired batch size
|
||||
if ulimit < batch_size {
|
||||
@@ -260,8 +343,8 @@ fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
|
||||
// When the OS supports high file limits like 8000, but the user
|
||||
// selected a batch size higher than this we should reduce it to
|
||||
// a lower number.
|
||||
if ulimit < AVERAGE_BATCH_SIZE.into() {
|
||||
// ulimit is smaller than aveage batch size
|
||||
if ulimit < AVERAGE_BATCH_SIZE {
|
||||
// ulimit is smaller than average batch size
|
||||
// user must have very small ulimit
|
||||
// decrease batch size to half of ulimit
|
||||
warning!("Your file limit is very small, which negatively impacts RustScan's speed. Use the Docker image, or up the Ulimit with '--ulimit 5000'. ", opts.greppable, opts.accessible);
|
||||
@@ -269,7 +352,7 @@ fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
|
||||
batch_size = ulimit / 2;
|
||||
} else if ulimit > DEFAULT_FILE_DESCRIPTORS_LIMIT {
|
||||
info!("Batch size is now average batch size");
|
||||
batch_size = AVERAGE_BATCH_SIZE.into();
|
||||
batch_size = AVERAGE_BATCH_SIZE;
|
||||
} else {
|
||||
batch_size = ulimit - 100;
|
||||
}
|
||||
@@ -282,12 +365,12 @@ fn infer_batch_size(opts: &Opts, ulimit: u64) -> u16 {
|
||||
}
|
||||
|
||||
batch_size
|
||||
.try_into()
|
||||
.expect("Couldn't fit the batch size into a u16.")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(windows)]
|
||||
use super::effective_batch_size;
|
||||
#[cfg(unix)]
|
||||
use super::{adjust_ulimit_size, infer_batch_size};
|
||||
use super::{print_opening, Opts};
|
||||
@@ -313,7 +396,7 @@ mod tests {
|
||||
};
|
||||
let batch_size = infer_batch_size(&opts, 9_000);
|
||||
|
||||
assert!(batch_size == 3_000);
|
||||
assert_eq!(batch_size, 3_000);
|
||||
}
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
@@ -326,7 +409,7 @@ mod tests {
|
||||
};
|
||||
let batch_size = infer_batch_size(&opts, 5_000);
|
||||
|
||||
assert!(batch_size == 4_900);
|
||||
assert_eq!(batch_size, 4_900);
|
||||
}
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
@@ -339,7 +422,7 @@ mod tests {
|
||||
};
|
||||
let batch_size = adjust_ulimit_size(&opts);
|
||||
|
||||
assert!(batch_size == 2_000);
|
||||
assert_eq!(batch_size, 2_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -353,16 +436,46 @@ mod tests {
|
||||
|
||||
let batch_size = infer_batch_size(&opts, 1_000_000);
|
||||
|
||||
assert!(batch_size == opts.batch_size);
|
||||
assert_eq!(batch_size, opts.batch_size);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_print_opening_no_panic() {
|
||||
let opts = Opts {
|
||||
ulimit: Some(2_000),
|
||||
..Default::default()
|
||||
};
|
||||
let opts = Opts::default();
|
||||
// print opening should not panic
|
||||
print_opening(&opts);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_batch_size_uses_requested_value() {
|
||||
let opts = Opts {
|
||||
batch_size: 50,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(effective_batch_size(&opts), 50);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_batch_size_preserves_large_requested_value() {
|
||||
let opts = Opts {
|
||||
batch_size: 12_345,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(effective_batch_size(&opts), 12_345);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn windows_batch_size_preserves_single_connection() {
|
||||
let opts = Opts {
|
||||
batch_size: 1,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(effective_batch_size(&opts), 1);
|
||||
}
|
||||
}
|
||||
+37
-27
@@ -1,6 +1,6 @@
|
||||
//! Provides a means to hold configuration options specifically for port scanning.
|
||||
mod range_iterator;
|
||||
use crate::input::{PortRange, ScanOrder};
|
||||
use crate::input::{PortRanges, ScanOrder};
|
||||
use rand::rng;
|
||||
use rand::seq::SliceRandom;
|
||||
use range_iterator::RangeIterator;
|
||||
@@ -17,20 +17,18 @@ pub enum PortStrategy {
|
||||
}
|
||||
|
||||
impl PortStrategy {
|
||||
pub fn pick(range: &Option<PortRange>, ports: Option<Vec<u16>>, order: ScanOrder) -> Self {
|
||||
pub fn pick(range: &Option<PortRanges>, ports: Option<Vec<u16>>, order: ScanOrder) -> Self {
|
||||
match order {
|
||||
ScanOrder::Serial if ports.is_none() => {
|
||||
let range = range.as_ref().unwrap();
|
||||
let port_ranges = range.as_ref().unwrap();
|
||||
PortStrategy::Serial(SerialRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
range: port_ranges.0.clone(),
|
||||
})
|
||||
}
|
||||
ScanOrder::Random if ports.is_none() => {
|
||||
let range = range.as_ref().unwrap();
|
||||
let port_ranges = range.as_ref().unwrap();
|
||||
PortStrategy::Random(RandomRange {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
range: port_ranges.0.clone(),
|
||||
})
|
||||
}
|
||||
ScanOrder::Serial => PortStrategy::Manual(ports.unwrap()),
|
||||
@@ -62,13 +60,12 @@ trait RangeOrder {
|
||||
/// ascending order.
|
||||
#[derive(Debug)]
|
||||
pub struct SerialRange {
|
||||
start: u16,
|
||||
end: u16,
|
||||
range: Vec<(u16, u16)>,
|
||||
}
|
||||
|
||||
impl RangeOrder for SerialRange {
|
||||
fn generate(&self) -> Vec<u16> {
|
||||
(self.start..=self.end).collect()
|
||||
RangeIterator::new_serial(&self.range).collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,8 +73,7 @@ impl RangeOrder for SerialRange {
|
||||
/// a random order. This vector is built following the LCG algorithm.
|
||||
#[derive(Debug)]
|
||||
pub struct RandomRange {
|
||||
start: u16,
|
||||
end: u16,
|
||||
range: Vec<(u16, u16)>,
|
||||
}
|
||||
|
||||
impl RangeOrder for RandomRange {
|
||||
@@ -91,33 +87,47 @@ impl RangeOrder for RandomRange {
|
||||
// port numbers close to each other are pretty slim due to the way the
|
||||
// algorithm works.
|
||||
fn generate(&self) -> Vec<u16> {
|
||||
RangeIterator::new(self.start.into(), self.end.into()).collect()
|
||||
RangeIterator::new_random(&self.range).collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PortStrategy;
|
||||
use crate::input::{PortRange, ScanOrder};
|
||||
use crate::input::{PortRanges, ScanOrder};
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn expected_ports_from_ranges(input: &[(u16, u16)]) -> Vec<u16> {
|
||||
let mut s = HashSet::new();
|
||||
for &(start, end) in input {
|
||||
for p in start..=end {
|
||||
s.insert(p);
|
||||
}
|
||||
}
|
||||
let mut v: Vec<u16> = s.into_iter().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
#[test]
|
||||
fn serial_strategy_with_range() {
|
||||
let range = PortRange { start: 1, end: 100 };
|
||||
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Serial);
|
||||
let ranges = PortRanges(vec![(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)]);
|
||||
let strategy = PortStrategy::pick(&Some(ranges.clone()), None, ScanOrder::Serial);
|
||||
let result = strategy.order();
|
||||
let expected_range = (1..=100).collect::<Vec<u16>>();
|
||||
assert_eq!(expected_range, result);
|
||||
let expected = expected_ports_from_ranges(&ranges.0);
|
||||
|
||||
assert_eq!(expected, result);
|
||||
}
|
||||
#[test]
|
||||
fn random_strategy_with_range() {
|
||||
let range = PortRange { start: 1, end: 100 };
|
||||
let strategy = PortStrategy::pick(&Some(range), None, ScanOrder::Random);
|
||||
let ranges = PortRanges(vec![(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)]);
|
||||
let strategy = PortStrategy::pick(&Some(ranges.clone()), None, ScanOrder::Random);
|
||||
let mut result = strategy.order();
|
||||
let expected_range = (1..=100).collect::<Vec<u16>>();
|
||||
assert_ne!(expected_range, result);
|
||||
let expected = expected_ports_from_ranges(&ranges.0);
|
||||
|
||||
assert_ne!(expected, result);
|
||||
result.sort_unstable();
|
||||
assert_eq!(expected_range, result);
|
||||
|
||||
assert_eq!(expected, result);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -131,10 +141,10 @@ mod tests {
|
||||
fn random_strategy_with_ports() {
|
||||
let strategy = PortStrategy::pick(&None, Some((1..10).collect()), ScanOrder::Random);
|
||||
let mut result = strategy.order();
|
||||
let expected_range = (1..10).collect::<Vec<u16>>();
|
||||
assert_ne!(expected_range, result);
|
||||
let expected = (1..10).collect::<Vec<u16>>();
|
||||
assert_ne!(expected, result);
|
||||
|
||||
result.sort_unstable();
|
||||
assert_eq!(expected_range, result);
|
||||
assert_eq!(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +1,196 @@
|
||||
use gcd::Gcd;
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use std::convert::TryInto;
|
||||
|
||||
/// Yields ports produced from a collection of (possibly overlapping)
|
||||
/// inclusive `u16` ranges in two modes:
|
||||
///
|
||||
/// **Randomized** — created by `RangeIterator::new_random`:
|
||||
/// - The algorithm generates a permutation of indices `0..N-1` using the
|
||||
/// additive congruential step `x_{i+1} = (x_i + step) % N`.
|
||||
/// - `step` is chosen so `gcd(step, N) == 1` to ensure the sequence is a
|
||||
/// full-length cycle (visits each index exactly once).
|
||||
/// - `x_0` (the seed stored as `normalized_first_pick`) is chosen uniformly
|
||||
/// in `0..N`.
|
||||
///
|
||||
/// For more information: <https://en.wikipedia.org/wiki/Linear_congruential_generator>
|
||||
///
|
||||
/// **Serial** — `RangeIterator::new_serial`:
|
||||
/// Iterates the input ranges in the **original input order** and yields
|
||||
/// each port the first time it is encountered. Duplicate ports (from
|
||||
/// overlapping ranges) are skipped using a 65_536-entry "seen" table.
|
||||
pub struct RangeIterator {
|
||||
active: bool,
|
||||
normalized_end: u32,
|
||||
total: u32,
|
||||
normalized_first_pick: u32,
|
||||
normalized_pick: u32,
|
||||
actual_start: u32,
|
||||
step: u32,
|
||||
ranges: Vec<(u32, u32)>,
|
||||
prefix: Vec<u32>,
|
||||
serial_itr: Option<Box<dyn Iterator<Item = u16>>>,
|
||||
/// `seen[port]` is true once `port` has been yielded (serial mode only).
|
||||
serial_seen: Option<Vec<bool>>,
|
||||
}
|
||||
|
||||
/// An iterator that follows the `Linear Congruential Generator` algorithm.
|
||||
///
|
||||
/// For more information: <https://en.wikipedia.org/wiki/Linear_congruential_generator>
|
||||
impl RangeIterator {
|
||||
/// Receives the start and end of a range and normalize
|
||||
/// these values before selecting a coprime for the end of the range
|
||||
/// which will server as the step for the algorithm.
|
||||
/// Construct a randomized iterator (LCG permutation).
|
||||
///
|
||||
/// For example, the range `1000-2500` will be normalized to `0-1500`
|
||||
/// before going through the algorithm.
|
||||
pub fn new(start: u32, end: u32) -> Self {
|
||||
let normalized_end = end - start + 1;
|
||||
let step = pick_random_coprime(normalized_end);
|
||||
/// Preconditions:
|
||||
/// - `input` must contain at least one `(u16,u16)`
|
||||
/// and each pair must satisfy `start <= end`.
|
||||
pub fn new_random(input: &[(u16, u16)]) -> Self {
|
||||
// normalize & merge into (start, len) u32 pairs
|
||||
// Example: [(10,12),(11,15)] -> merged [(10,6)]
|
||||
let mut ranges: Vec<(u32, u32)> = input
|
||||
.iter()
|
||||
.map(|(s, e)| {
|
||||
let start = *s as u32;
|
||||
let end_excl = (*e as u32) + 1; // convert inclusive -> exclusive
|
||||
(start, end_excl)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Randomly choose a number within the range to be the first
|
||||
// and assign it as a pick.
|
||||
ranges.sort_unstable_by_key(|&(s, _)| s);
|
||||
|
||||
let mut merged: Vec<(u32, u32)> = Vec::with_capacity(ranges.len());
|
||||
if !ranges.is_empty() {
|
||||
let mut iter = ranges.into_iter();
|
||||
let (mut cur_s, mut cur_end) = iter.next().unwrap(); // cur_end exclusive
|
||||
for (s, end_excl) in iter {
|
||||
if s <= cur_end {
|
||||
// overlap/adjacent -> extend
|
||||
if end_excl > cur_end {
|
||||
cur_end = end_excl;
|
||||
}
|
||||
} else {
|
||||
// push disjoint segment as (start, len)
|
||||
merged.push((cur_s, cur_end - cur_s)); // len = excl - start
|
||||
cur_s = s;
|
||||
cur_end = end_excl;
|
||||
}
|
||||
}
|
||||
merged.push((cur_s, cur_end - cur_s));
|
||||
}
|
||||
// build prefix sums (prefix[0] = 0; prefix.len() == merged.len() + 1)
|
||||
let prefix = merged.iter().fold(vec![0u32], |mut acc, (_, len)| {
|
||||
let last = acc.last().unwrap();
|
||||
acc.push(last.saturating_add(*len));
|
||||
acc
|
||||
});
|
||||
|
||||
// total is guaranteed > 0 by precondition (input.len() >= 1)
|
||||
let total = *prefix.last().unwrap();
|
||||
|
||||
// pick step and seed
|
||||
let step = pick_random_coprime(total);
|
||||
let mut rng = rand::rng();
|
||||
let normalized_first_pick = rng.random_range(0..normalized_end);
|
||||
let first = rng.random_range(0..total);
|
||||
|
||||
Self {
|
||||
active: true,
|
||||
normalized_end,
|
||||
total,
|
||||
normalized_first_pick: first,
|
||||
normalized_pick: first,
|
||||
step,
|
||||
normalized_first_pick,
|
||||
normalized_pick: normalized_first_pick,
|
||||
actual_start: start,
|
||||
ranges: merged,
|
||||
prefix,
|
||||
serial_itr: None,
|
||||
serial_seen: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct a serial iterator that yields ports in original input order,
|
||||
/// skipping duplicates. The deduplication is done on the fly with a seen-table.
|
||||
///
|
||||
/// Preconditions:
|
||||
/// - `input` must contain at least one `(u16,u16)` and each pair must satisfy `start <= end`.
|
||||
pub fn new_serial(input: &[(u16, u16)]) -> Self {
|
||||
// Build a serial iterator that yields ports in *input order* (start..=end).
|
||||
// We keep the merged ranges/prefix empty here (they are not needed for serial mode).
|
||||
let input = input.to_vec();
|
||||
let serial_itr = input.into_iter().flat_map(|(start, end)| start..=end);
|
||||
|
||||
let serial_itr_boxed: Box<dyn Iterator<Item = u16>> = Box::new(serial_itr);
|
||||
// One entry per possible port (0..=65535).
|
||||
let seen = vec![false; usize::from(u16::MAX) + 1];
|
||||
|
||||
Self {
|
||||
active: true,
|
||||
total: 0,
|
||||
normalized_first_pick: 0,
|
||||
normalized_pick: 0,
|
||||
step: 0,
|
||||
ranges: Vec::new(),
|
||||
prefix: Vec::new(),
|
||||
serial_itr: Some(serial_itr_boxed),
|
||||
serial_seen: Some(seen),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for RangeIterator {
|
||||
type Item = u16;
|
||||
|
||||
// The next step is always bound by the formula: N+1 = (N + STEP) % TOP_OF_THE_RANGE
|
||||
// It will only stop once we generate a number equal to the first generated number.
|
||||
/// Advance the iterator by one port.
|
||||
///
|
||||
/// 1. Read the current normalized index `cur`.
|
||||
/// 2. Compute `next = (cur + step) % total` and update `normalized_pick`.
|
||||
/// 3. If `next == normalized_first_pick` mark `active = false` (we completed the cycle).
|
||||
/// 4. Map the returned index `cur` into the merged ranges via the prefix array:
|
||||
/// - find the range index `idx` where `prefix[idx] <= cur < prefix[idx+1]`,
|
||||
/// - offset = `cur - prefix[idx]`,
|
||||
/// - port = `ranges[idx].0 + offset`.
|
||||
/// 5. Return `port as u16`.
|
||||
///
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
if !self.active {
|
||||
return None;
|
||||
}
|
||||
|
||||
let current_pick = self.normalized_pick;
|
||||
let next_pick = (current_pick + self.step) % self.normalized_end;
|
||||
// SERIAL iterator fast-path: preserve original input order but skip duplicates.
|
||||
if let (Some(it), Some(seen)) = (self.serial_itr.as_mut(), self.serial_seen.as_mut()) {
|
||||
for p in it.by_ref() {
|
||||
// Yield each port only the first time it is seen.
|
||||
if !std::mem::replace(&mut seen[usize::from(p)], true) {
|
||||
return Some(p);
|
||||
}
|
||||
// otherwise skip duplicate and continue
|
||||
}
|
||||
// serial iterator exhausted: drop it and mark inactive
|
||||
self.serial_itr = None;
|
||||
self.serial_seen = None;
|
||||
self.active = false;
|
||||
return None;
|
||||
}
|
||||
|
||||
// If the next pick is equal to the first pick this means that
|
||||
// we have iterated through the entire range.
|
||||
if next_pick == self.normalized_first_pick {
|
||||
// RANDOMIZED (LCG) path
|
||||
let cur = self.normalized_pick;
|
||||
let next = (cur + self.step) % self.total;
|
||||
|
||||
// if next equals the original seed we finished the cycle after returning cur
|
||||
if next == self.normalized_first_pick {
|
||||
self.active = false;
|
||||
}
|
||||
|
||||
self.normalized_pick = next_pick;
|
||||
Some(
|
||||
(self.actual_start + current_pick)
|
||||
.try_into()
|
||||
.expect("Could not convert u32 to u16"),
|
||||
)
|
||||
self.normalized_pick = next;
|
||||
|
||||
// Map cur -> port using prefix + ranges (binary search)
|
||||
let mut lo: usize = 0;
|
||||
let mut hi: usize = self.ranges.len();
|
||||
while lo < hi {
|
||||
let mid = (lo + hi) / 2;
|
||||
if self.prefix[mid + 1] > cur {
|
||||
hi = mid;
|
||||
} else {
|
||||
lo = mid + 1;
|
||||
}
|
||||
}
|
||||
let idx = lo;
|
||||
let offset = cur - self.prefix[idx];
|
||||
let (start, _len) = self.ranges[idx];
|
||||
let port = (start + offset)
|
||||
.try_into()
|
||||
.expect("Could not convert u32 to u16");
|
||||
Some(port)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,36 +223,105 @@ fn pick_random_coprime(end: u32) -> u32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RangeIterator;
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Helper: collect, sort and return ports produced by randomized RangeIterator
|
||||
fn generate_sorted_from_ranges_random(input: &[(u16, u16)]) -> Vec<u16> {
|
||||
let mut it = RangeIterator::new_random(input);
|
||||
let mut v: Vec<u16> = it.by_ref().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
// Helper: collect, sort and return ports produced by serial RangeIterator
|
||||
fn generate_sorted_from_ranges_serial(input: &[(u16, u16)]) -> Vec<u16> {
|
||||
let mut it = RangeIterator::new_serial(input);
|
||||
let mut v: Vec<u16> = it.by_ref().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
// Build expected sorted unique ports from input ranges (inclusive)
|
||||
fn expected_ports_from_ranges(input: &[(u16, u16)]) -> Vec<u16> {
|
||||
let mut s = HashSet::new();
|
||||
for &(start, end) in input {
|
||||
for p in start..=end {
|
||||
s.insert(p);
|
||||
}
|
||||
}
|
||||
let mut v: Vec<u16> = s.into_iter().collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_iterator_iterates_through_the_entire_range() {
|
||||
let result = generate_sorted_range(1, 10);
|
||||
let expected_range = (1..=10).collect::<Vec<u16>>();
|
||||
assert_eq!(expected_range, result);
|
||||
fn random_range_iterator_test() {
|
||||
// small disjoint ranges
|
||||
let input = &[(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)];
|
||||
let result = generate_sorted_from_ranges_random(input);
|
||||
let expected = expected_ports_from_ranges(input);
|
||||
assert_eq!(expected, result);
|
||||
|
||||
let result = generate_sorted_range(1, 100);
|
||||
let expected_range = (1..=100).collect::<Vec<u16>>();
|
||||
assert_eq!(expected_range, result);
|
||||
// larger disjoint ranges
|
||||
let input = &[(1u16, 100u16), (200u16, 500u16)];
|
||||
let result = generate_sorted_from_ranges_random(input);
|
||||
let expected = expected_ports_from_ranges(input);
|
||||
assert_eq!(expected, result);
|
||||
|
||||
let result = generate_sorted_range(1, 1000);
|
||||
let expected_range = (1..=1000).collect::<Vec<u16>>();
|
||||
assert_eq!(expected_range, result);
|
||||
// overlapping and adjacent
|
||||
let input = &[(10u16, 20u16), (15u16, 25u16), (26u16, 30u16)];
|
||||
let result = generate_sorted_from_ranges_random(input);
|
||||
let expected = expected_ports_from_ranges(input);
|
||||
assert_eq!(expected, result);
|
||||
|
||||
let result = generate_sorted_range(1, 65_535);
|
||||
let expected_range = (1..=65_535).collect::<Vec<u16>>();
|
||||
assert_eq!(expected_range, result);
|
||||
// near-full domain (heavy): we only assert lengths & equality
|
||||
let input = &[(1u16, 65_535u16)];
|
||||
let result = generate_sorted_from_ranges_random(input);
|
||||
let expected = expected_ports_from_ranges(input);
|
||||
assert_eq!(expected.len(), result.len());
|
||||
assert_eq!(expected, result);
|
||||
|
||||
let result = generate_sorted_range(1000, 2000);
|
||||
let expected_range = (1000..=2000).collect::<Vec<u16>>();
|
||||
assert_eq!(expected_range, result);
|
||||
// multiple disjoint ranges - check dedupe & coverage
|
||||
let input = &[(50u16, 100u16), (1000u16, 2000u16), (30000u16, 30010u16)];
|
||||
let result = generate_sorted_from_ranges_random(input);
|
||||
let set_len = result.iter().copied().collect::<HashSet<u16>>().len();
|
||||
assert_eq!(set_len, result.len());
|
||||
let expected = expected_ports_from_ranges(input);
|
||||
assert_eq!(expected, result);
|
||||
}
|
||||
|
||||
fn generate_sorted_range(start: u32, end: u32) -> Vec<u16> {
|
||||
let range = RangeIterator::new(start, end);
|
||||
let mut result = range.into_iter().collect::<Vec<u16>>();
|
||||
result.sort_unstable();
|
||||
#[test]
|
||||
fn serial_range_iterator_test() {
|
||||
// serial should preserve input-order semantics but here we only assert
|
||||
// coverage (no duplicates) by sorting results and comparing expected set.
|
||||
|
||||
result
|
||||
// small disjoint ranges
|
||||
let input = &[(1u16, 10u16), (20u16, 30u16), (100u16, 110u16)];
|
||||
let result = generate_sorted_from_ranges_serial(input);
|
||||
let expected = expected_ports_from_ranges(input);
|
||||
assert_eq!(expected, result);
|
||||
|
||||
// overlapping and adjacent
|
||||
let input = &[(10u16, 20u16), (15u16, 25u16), (26u16, 30u16)];
|
||||
let result = generate_sorted_from_ranges_serial(input);
|
||||
let expected = expected_ports_from_ranges(input);
|
||||
assert_eq!(expected, result);
|
||||
|
||||
// multiple disjoint ranges
|
||||
let input = &[(50u16, 100u16), (1000u16, 2000u16), (30000u16, 30010u16)];
|
||||
let result = generate_sorted_from_ranges_serial(input);
|
||||
let set_len = result.iter().copied().collect::<HashSet<u16>>().len();
|
||||
assert_eq!(set_len, result.len());
|
||||
let expected = expected_ports_from_ranges(input);
|
||||
assert_eq!(expected, result);
|
||||
|
||||
// all possible inputs
|
||||
let input = &[(u16::MIN, u16::MAX)];
|
||||
let result = generate_sorted_from_ranges_serial(input);
|
||||
let set_len = result.iter().copied().collect::<HashSet<u16>>().len();
|
||||
assert_eq!(set_len, result.len());
|
||||
let expected = expected_ports_from_ranges(input);
|
||||
assert_eq!(expected, result);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::{collections::HashSet, fmt, io, net::IpAddr};
|
||||
|
||||
/// Keep the OS error and its target intact until diagnostics need formatting.
|
||||
#[derive(Debug)]
|
||||
struct ScanError {
|
||||
ip: IpAddr,
|
||||
error: io::Error,
|
||||
}
|
||||
|
||||
impl fmt::Display for ScanError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{} {}", self.error, self.ip)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ScanError {}
|
||||
|
||||
/// Preserve the normal I/O result layout; only diagnostics need target context.
|
||||
pub(super) fn diagnostic_error(error: io::Error, ip: IpAddr, enabled: bool) -> io::Error {
|
||||
if enabled {
|
||||
io::Error::other(ScanError { ip, error })
|
||||
} else {
|
||||
error
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct ScanErrors {
|
||||
enabled: bool,
|
||||
limit: usize,
|
||||
messages: HashSet<String>,
|
||||
}
|
||||
|
||||
impl ScanErrors {
|
||||
pub(super) fn new(enabled: bool, limit: usize) -> Self {
|
||||
Self {
|
||||
enabled,
|
||||
limit,
|
||||
messages: HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn record(&mut self, failure: io::Error) {
|
||||
if self.enabled && self.messages.len() < self.limit {
|
||||
self.messages.insert(failure.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn messages(&self) -> &HashSet<String> {
|
||||
&self.messages
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect descriptor exhaustion without allocating or interpreting OS messages.
|
||||
#[cfg(unix)]
|
||||
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
|
||||
matches!(error.raw_os_error(), Some(libc::EMFILE | libc::ENFILE))
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
|
||||
error.raw_os_error() == Some(windows_sys::Win32::Networking::WinSock::WSAEMFILE)
|
||||
}
|
||||
|
||||
// Retain the previous fallback for targets without Unix or Winsock error codes.
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub(super) fn is_descriptor_exhaustion(error: &io::Error) -> bool {
|
||||
error
|
||||
.to_string()
|
||||
.to_lowercase()
|
||||
.contains("too many open files")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::{fmt, net::SocketAddr};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MustNotFormat;
|
||||
|
||||
impl fmt::Display for MustNotFormat {
|
||||
fn fmt(&self, _: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
panic!("discarded diagnostics must not format errors")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MustNotFormat {}
|
||||
|
||||
fn failure(error: io::Error) -> io::Error {
|
||||
diagnostic_error(error, "127.0.0.1".parse().unwrap(), true)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_diagnostics_do_not_format_errors() {
|
||||
let mut errors = ScanErrors::new(false, 1000);
|
||||
errors.record(diagnostic_error(
|
||||
io::Error::other(MustNotFormat),
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
false,
|
||||
));
|
||||
assert!(errors.messages().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_diagnostics_preserve_the_original_os_error() {
|
||||
let original = io::Error::from_raw_os_error(123);
|
||||
let error = diagnostic_error(original, "127.0.0.1".parse().unwrap(), false);
|
||||
assert_eq!(error.raw_os_error(), Some(123));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_diagnostics_do_not_format_more_errors() {
|
||||
let mut errors = ScanErrors::new(true, 1);
|
||||
errors.record(failure(io::ErrorKind::ConnectionRefused.into()));
|
||||
errors.record(failure(io::Error::other(MustNotFormat)));
|
||||
assert_eq!(errors.messages().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_diagnostics_keep_error_details_and_deduplicate_per_ip() {
|
||||
let mut errors = ScanErrors::new(true, 1000);
|
||||
for port in [80, 443] {
|
||||
errors.record(diagnostic_error(
|
||||
io::Error::other("test socket error"),
|
||||
SocketAddr::from(([127, 0, 0, 1], port)).ip(),
|
||||
true,
|
||||
));
|
||||
}
|
||||
assert_eq!(errors.messages().len(), 1);
|
||||
assert!(errors.messages().contains("test socket error 127.0.0.1"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn detects_unix_descriptor_limits_by_code() {
|
||||
for code in [libc::EMFILE, libc::ENFILE] {
|
||||
assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error(
|
||||
code
|
||||
)));
|
||||
}
|
||||
assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error(
|
||||
libc::ECONNREFUSED
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn detects_winsock_descriptor_limits_by_code() {
|
||||
use windows_sys::Win32::Networking::WinSock::{WSAECONNREFUSED, WSAEMFILE};
|
||||
assert!(is_descriptor_exhaustion(&io::Error::from_raw_os_error(
|
||||
WSAEMFILE
|
||||
)));
|
||||
assert!(!is_descriptor_exhaustion(&io::Error::from_raw_os_error(
|
||||
WSAECONNREFUSED
|
||||
)));
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
#[test]
|
||||
fn descriptor_check_never_formats_an_error() {
|
||||
assert!(!is_descriptor_exhaustion(&io::Error::other(MustNotFormat)));
|
||||
}
|
||||
}
|
||||
+569
-318
File diff suppressed because it is too large.
Load diff
+101
-29
@@ -1,55 +1,78 @@
|
||||
use itertools::{iproduct, Product};
|
||||
use std::iter::FusedIterator;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::slice;
|
||||
|
||||
/// An iterator that receives a slice of IPs and ports and returns a
|
||||
/// [`SocketAddr`] for each IP/port pair until all combinations are exhausted.
|
||||
///
|
||||
/// The goal of this iterator is to walk every IP and port combination
|
||||
/// *without* allocating a big intermediate buffer — the alternative would be
|
||||
/// materialising a `Vec<SocketAddr>` of every pair up front.
|
||||
///
|
||||
/// # Ordering
|
||||
///
|
||||
/// The IP/port order is intentionally reversed inside `product_it`: we want
|
||||
/// `iproduct!` to iterate *all IPs for one port* before advancing to the next
|
||||
/// port ("hold the port, go through all the IPs, then advance the port").
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// `SocketIterator` lives in a private module, so this example is not run as
|
||||
/// a doctest; `goes_through_every_ip_port_combination` checks the same order.
|
||||
///
|
||||
/// ```ignore
|
||||
/// # use std::net::IpAddr;
|
||||
/// let ips = [
|
||||
/// "127.0.0.1".parse::<IpAddr>().unwrap(),
|
||||
/// "192.168.0.1".parse::<IpAddr>().unwrap(),
|
||||
/// ];
|
||||
/// let ports = [80u16, 443];
|
||||
///
|
||||
/// let mut it = SocketIterator::new(&ips, &ports);
|
||||
/// assert_eq!(it.next(), Some("127.0.0.1:80".parse().unwrap()));
|
||||
/// assert_eq!(it.next(), Some("192.168.0.1:80".parse().unwrap()));
|
||||
/// assert_eq!(it.next(), Some("127.0.0.1:443".parse().unwrap()));
|
||||
/// assert_eq!(it.next(), Some("192.168.0.1:443".parse().unwrap()));
|
||||
/// assert_eq!(it.next(), None);
|
||||
/// ```
|
||||
#[derive(Clone)]
|
||||
pub struct SocketIterator<'s> {
|
||||
// product_it is a cartesian product iterator over
|
||||
// the slices of ports and IP addresses.
|
||||
//
|
||||
// The IP/port order is intentionally reversed here since we want
|
||||
// the itertools::iproduct! macro below to generate the pairs with
|
||||
// all the IPs for one port before moving on to the next one
|
||||
// ("hold the port, go through all the IPs, then advance the port...").
|
||||
// See also the comments in the iterator implementation for an example.
|
||||
product_it:
|
||||
Product<Box<std::slice::Iter<'s, u16>>, Box<std::slice::Iter<'s, std::net::IpAddr>>>,
|
||||
// No boxing: the iterator owns the concrete slice iterators directly.
|
||||
// This keeps `SocketIterator` allocation-free and makes `Clone` free too.
|
||||
product_it: Product<slice::Iter<'s, u16>, slice::Iter<'s, IpAddr>>,
|
||||
}
|
||||
|
||||
/// An iterator that receives a slice of IPs and ports and returns a Socket
|
||||
/// for each IP and port pair until all of these combinations are exhausted.
|
||||
/// The goal of this iterator is to go over every IP and port combination
|
||||
/// without generating a big memory footprint. The alternative would be
|
||||
/// generating a vector containing all these combinations.
|
||||
impl<'s> SocketIterator<'s> {
|
||||
pub fn new(ips: &'s [IpAddr], ports: &'s [u16]) -> Self {
|
||||
let ports_it = Box::new(ports.iter());
|
||||
let ips_it = Box::new(ips.iter());
|
||||
Self {
|
||||
product_it: iproduct!(ports_it, ips_it),
|
||||
// `iproduct!` calls `.into_iter()` on each argument; `slice::Iter`
|
||||
// is already an iterator, so this is a no-op (no allocation).
|
||||
product_it: iproduct!(ports.iter(), ips.iter()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::doc_link_with_quotes)]
|
||||
impl Iterator for SocketIterator<'_> {
|
||||
type Item = SocketAddr;
|
||||
|
||||
/// Returns a socket based on the combination of one of the provided
|
||||
/// IPs and ports or None when these combinations are exhausted. Every
|
||||
/// IP will have the same port until a port is incremented.
|
||||
///
|
||||
/// let it = SocketIterator::new(&["127.0.0.1", "192.168.0.1"], &[80, 443]);
|
||||
/// it.next(); // 127.0.0.1:80
|
||||
/// it.next(); // 192.168.0.1:80
|
||||
/// it.next(); // 127.0.0.1:443
|
||||
/// it.next(); // 192.168.0.1:443
|
||||
/// it.next(); // None
|
||||
/// Returns the next socket, or `None` when all combinations are exhausted.
|
||||
/// Every IP is paired with the same port until the port advances.
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
self.product_it
|
||||
.next()
|
||||
.map(|(port, ip)| SocketAddr::new(*ip, *port))
|
||||
}
|
||||
|
||||
// Delegate the size hint so callers like `collect()` can pre-size buffers.
|
||||
fn size_hint(&self) -> (usize, Option<usize>) {
|
||||
self.product_it.size_hint()
|
||||
}
|
||||
}
|
||||
|
||||
// `Product` and `slice::Iter` are both fused, so we are too.
|
||||
impl FusedIterator for SocketIterator<'_> {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::SocketIterator;
|
||||
@@ -72,4 +95,53 @@ mod tests {
|
||||
assert_eq!(Some(SocketAddr::new(addrs[1], ports[2])), it.next());
|
||||
assert_eq!(None, it.next());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn size_hint_is_exact() {
|
||||
let addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
|
||||
let ports: Vec<u16> = vec![22, 80, 443];
|
||||
let mut it = SocketIterator::new(&addrs, &ports);
|
||||
|
||||
assert_eq!(it.size_hint(), (3, Some(3)));
|
||||
it.next();
|
||||
assert_eq!(it.size_hint(), (2, Some(2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clone_resumes_independently() {
|
||||
let addrs = [
|
||||
"127.0.0.1".parse::<IpAddr>().unwrap(),
|
||||
"192.168.0.1".parse::<IpAddr>().unwrap(),
|
||||
];
|
||||
let ports: Vec<u16> = vec![22, 80];
|
||||
|
||||
let mut a = SocketIterator::new(&addrs, &ports);
|
||||
assert_eq!(a.next(), Some(SocketAddr::new(addrs[0], ports[0])));
|
||||
let b = a.clone(); // b starts where a is now
|
||||
|
||||
// 2 IPs x 2 ports = 4 sockets, one of which was consumed before cloning.
|
||||
let rest = [
|
||||
SocketAddr::new(addrs[1], ports[0]),
|
||||
SocketAddr::new(addrs[0], ports[1]),
|
||||
SocketAddr::new(addrs[1], ports[1]),
|
||||
];
|
||||
assert_eq!(a.collect::<Vec<_>>(), rest);
|
||||
assert_eq!(b.collect::<Vec<_>>(), rest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_inputs_yield_nothing() {
|
||||
let addrs = ["127.0.0.1".parse::<IpAddr>().unwrap()];
|
||||
let ports = [22u16];
|
||||
|
||||
for mut it in [
|
||||
SocketIterator::new(&[], &ports),
|
||||
SocketIterator::new(&addrs, &[]),
|
||||
] {
|
||||
assert_eq!(it.size_hint(), (0, Some(0)));
|
||||
assert_eq!(it.next(), None);
|
||||
// Stays exhausted, as promised by the `FusedIterator` impl.
|
||||
assert_eq!(it.next(), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+68
-88
@@ -46,12 +46,9 @@
|
||||
//!
|
||||
//! - `fixtures/test_rustscan_scripts.toml`
|
||||
//!
|
||||
//! Script file examples:
|
||||
//! Metadata-only test fixture:
|
||||
//!
|
||||
//! - `fixtures/test_script.py`
|
||||
//! - `fixtures/test_script.pl`
|
||||
//! - `fixtures/test_script.sh`
|
||||
//! - `fixtures/test_script.txt`
|
||||
//! - `fixtures/.rustscan_scripts/test_script.txt`
|
||||
//!
|
||||
//! `call_format` in script files can be of 2 variants:
|
||||
//!
|
||||
@@ -108,16 +105,20 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
|
||||
scripts_to_run.push(default_script);
|
||||
}
|
||||
ScriptsRequired::Custom => {
|
||||
let scripts_dir_base =
|
||||
dirs::home_dir().ok_or_else(|| anyhow!("Could not infer scripts path."))?;
|
||||
let script_paths = find_scripts(scripts_dir_base)?;
|
||||
debug!("Scripts paths \n{:?}", script_paths);
|
||||
let script_config = ScriptConfig::read_config()?;
|
||||
debug!("Script config \n{script_config:?}");
|
||||
|
||||
let script_dir_base = if let Some(config_directory) = &script_config.directory {
|
||||
PathBuf::from(config_directory)
|
||||
} else {
|
||||
dirs::home_dir().ok_or_else(|| anyhow!("Could not infer scripts path."))?
|
||||
};
|
||||
|
||||
let script_paths = find_scripts(script_dir_base)?;
|
||||
debug!("Scripts paths \n{script_paths:?}");
|
||||
|
||||
let parsed_scripts = parse_scripts(script_paths);
|
||||
debug!("Scripts parsed \n{:?}", parsed_scripts);
|
||||
|
||||
let script_config = ScriptConfig::read_config()?;
|
||||
debug!("Script config \n{:?}", script_config);
|
||||
debug!("Scripts parsed \n{parsed_scripts:?}");
|
||||
|
||||
// Only Scripts that contain all the tags found in ScriptConfig will be selected.
|
||||
if let Some(config_hashset) = script_config.tags {
|
||||
@@ -131,14 +132,14 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
|
||||
} else {
|
||||
debug!(
|
||||
"\nScript tags does not match config tags {:?} {}",
|
||||
&script_hashset,
|
||||
script_hashset,
|
||||
script.path.unwrap().display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
debug!("\nScript(s) to run {:?}", scripts_to_run);
|
||||
debug!("\nScript(s) to run {scripts_to_run:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +149,7 @@ pub fn init_scripts(scripts: &ScriptsRequired) -> Result<Vec<ScriptFile>> {
|
||||
pub fn parse_scripts(scripts: Vec<PathBuf>) -> Vec<ScriptFile> {
|
||||
let mut parsed_scripts: Vec<ScriptFile> = Vec::with_capacity(scripts.len());
|
||||
for script in scripts {
|
||||
debug!("Parsing script {}", &script.display());
|
||||
debug!("Parsing script {}", script.display());
|
||||
if let Some(script_file) = ScriptFile::new(script) {
|
||||
parsed_scripts.push(script_file);
|
||||
}
|
||||
@@ -220,7 +221,7 @@ impl Script {
|
||||
// Some variables get changed before read, and compiler throws warning on warn(unused_assignments)
|
||||
#[allow(unused_assignments)]
|
||||
pub fn run(self) -> Result<String> {
|
||||
debug!("run self {:?}", &self);
|
||||
debug!("run self {:?}", self);
|
||||
|
||||
let separator = self.ports_separator.unwrap_or_else(|| ",".into());
|
||||
|
||||
@@ -265,14 +266,14 @@ impl Script {
|
||||
};
|
||||
to_run = default_template.fill_with_struct(&exec_parts)?;
|
||||
}
|
||||
debug!("\nScript format to run {}", to_run);
|
||||
debug!("\nScript format to run {to_run}");
|
||||
execute_script(&to_run)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn execute_script(script: &str) -> Result<String> {
|
||||
debug!("\nScript arguments {}", script);
|
||||
debug!("\nScript arguments {script}");
|
||||
|
||||
let (cmd, arg) = if cfg!(unix) {
|
||||
("sh", "-c")
|
||||
@@ -310,16 +311,15 @@ fn execute_script(script: &str) -> Result<String> {
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
Err(error) => {
|
||||
debug!("Command error {}", error.to_string());
|
||||
debug!("Command error {error}",);
|
||||
Err(anyhow!(error.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_scripts(mut path: PathBuf) -> Result<Vec<PathBuf>> {
|
||||
path.push(".rustscan_scripts");
|
||||
pub fn find_scripts(path: PathBuf) -> Result<Vec<PathBuf>> {
|
||||
if path.is_dir() {
|
||||
debug!("Scripts folder found {}", &path.display());
|
||||
debug!("Scripts folder found {}", path.display());
|
||||
let mut files_vec: Vec<PathBuf> = Vec::new();
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
@@ -357,20 +357,20 @@ impl ScriptFile {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
debug!("Failed to read file: {}", &real_path.display());
|
||||
debug!("Failed to read file: {}", real_path.display());
|
||||
return None;
|
||||
}
|
||||
debug!("ScriptFile {} lines\n{}", &real_path.display(), &lines_buf);
|
||||
debug!("ScriptFile {} lines\n{}", real_path.display(), lines_buf);
|
||||
|
||||
match toml::from_str::<ScriptFile>(&lines_buf) {
|
||||
Ok(mut parsed) => {
|
||||
debug!("Parsed ScriptFile{} \n{:?}", &real_path.display(), &parsed);
|
||||
debug!("Parsed ScriptFile{} \n{:?}", real_path.display(), parsed);
|
||||
parsed.path = Some(real_path);
|
||||
// parsed_scripts.push(parsed);
|
||||
Some(parsed)
|
||||
}
|
||||
Err(e) => {
|
||||
debug!("Failed to parse ScriptFile headers {}", e.to_string());
|
||||
debug!("Failed to parse ScriptFile headers {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -382,6 +382,7 @@ pub struct ScriptConfig {
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub ports: Option<Vec<String>>,
|
||||
pub developer: Option<Vec<String>>,
|
||||
pub directory: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
@@ -400,27 +401,13 @@ impl ScriptConfig {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{find_scripts, parse_scripts, Script, ScriptFile};
|
||||
|
||||
// Function for testing only, it inserts static values into ip and open_ports
|
||||
// Doesn't use impl in case it's implemented in the super module at some point
|
||||
fn into_script(script_f: ScriptFile) -> Script {
|
||||
Script::build(
|
||||
script_f.path,
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
vec![80, 8080],
|
||||
script_f.port,
|
||||
script_f.ports_separator,
|
||||
script_f.tags,
|
||||
script_f.call_format,
|
||||
)
|
||||
}
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn find_and_parse_scripts() {
|
||||
let scripts = find_scripts("fixtures/".into()).unwrap();
|
||||
let scripts = find_scripts("fixtures/.rustscan_scripts".into()).unwrap();
|
||||
let scripts = parse_scripts(scripts);
|
||||
assert_eq!(scripts.len(), 4);
|
||||
assert_eq!(scripts.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -436,26 +423,6 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn open_script_file_invalid_call_format() {
|
||||
let mut script_f =
|
||||
ScriptFile::new("fixtures/.rustscan_scripts/test_script.txt".into()).unwrap();
|
||||
script_f.call_format = Some("qwertyuiop".to_string());
|
||||
let script: Script = into_script(script_f);
|
||||
let _output = script.run().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn open_script_file_missing_call_format() {
|
||||
let mut script_f =
|
||||
ScriptFile::new("fixtures/.rustscan_scripts/test_script.txt".into()).unwrap();
|
||||
script_f.call_format = None;
|
||||
let script: Script = into_script(script_f);
|
||||
let _output = script.run().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic]
|
||||
fn open_nonexisting_script_file() {
|
||||
@@ -480,39 +447,52 @@ mod tests {
|
||||
assert_eq!(script_f.ports_separator, Some(",".to_string()));
|
||||
assert_eq!(
|
||||
script_f.call_format,
|
||||
Some("nmap -vvv -p {{port}} {{ip}}".to_string())
|
||||
Some("fixture {{ip}} {{port}}".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn run_bash_script() {
|
||||
let script_f = ScriptFile::new("fixtures/.rustscan_scripts/test_script.sh".into()).unwrap();
|
||||
let script: Script = into_script(script_f);
|
||||
let output = script.run().unwrap();
|
||||
// output has a newline at the end by default, .trim() trims it
|
||||
assert_eq!(output.trim(), "127.0.0.1 80,8080");
|
||||
}
|
||||
fn test_custom_directory_config() {
|
||||
// Create test config
|
||||
let config_str = r#"
|
||||
tags = ["core_approved", "example"]
|
||||
directory = "fixtures/.rustscan_scripts"
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn run_python_script() {
|
||||
let script_f = ScriptFile::new("fixtures/.rustscan_scripts/test_script.py".into()).unwrap();
|
||||
let script: Script = into_script(script_f);
|
||||
let output = script.run().unwrap();
|
||||
// output has a newline at the end by default, .trim() trims it
|
||||
let config: ScriptConfig = toml::from_str(config_str).unwrap();
|
||||
assert_eq!(
|
||||
output.trim(),
|
||||
"Python script ran with arguments ['fixtures/.rustscan_scripts/test_script.py', '127.0.0.1', '80,8080']"
|
||||
config.directory,
|
||||
Some("fixtures/.rustscan_scripts".to_string())
|
||||
);
|
||||
|
||||
// Test that the directory is actually used
|
||||
let script_dir_base = PathBuf::from(config.directory.unwrap());
|
||||
let scripts = find_scripts(script_dir_base).unwrap();
|
||||
|
||||
// Verify we found the test script
|
||||
assert!(scripts.iter().any(|p| p
|
||||
.file_name()
|
||||
.and_then(|f| f.to_str())
|
||||
.map(|s| s == "test_script.txt")
|
||||
.unwrap_or(false)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn run_perl_script() {
|
||||
let script_f = ScriptFile::new("fixtures/.rustscan_scripts/test_script.pl".into()).unwrap();
|
||||
let script: Script = into_script(script_f);
|
||||
let output = script.run().unwrap();
|
||||
// output has a newline at the end by default, .trim() trims it
|
||||
assert_eq!(output.trim(), "Total args passed to fixtures/.rustscan_scripts/test_script.pl : 2\nArg # 1 : 127.0.0.1\nArg # 2 : 80,8080");
|
||||
fn test_default_directory_fallback() {
|
||||
let config_str = r#"
|
||||
tags = ["core_approved", "example"]
|
||||
"#;
|
||||
|
||||
let config: ScriptConfig = toml::from_str(config_str).unwrap();
|
||||
assert_eq!(config.directory, None);
|
||||
|
||||
// Test fallback to home directory
|
||||
let script_dir_base = if let Some(config_directory) = &config.directory {
|
||||
PathBuf::from(config_directory)
|
||||
} else {
|
||||
dirs::home_dir().unwrap()
|
||||
};
|
||||
|
||||
assert_eq!(script_dir_base, dirs::home_dir().unwrap());
|
||||
}
|
||||
}
|
||||
+57
-13
@@ -1,20 +1,55 @@
|
||||
//! Utilities for terminal output during scanning.
|
||||
|
||||
use std::io::Write;
|
||||
|
||||
/// Prints a single line to stdout without ever panicking.
|
||||
///
|
||||
/// `println!` panics when writing to stdout fails, which is fatal under the
|
||||
/// `panic = "abort"` release profile. That includes the `BrokenPipe` error
|
||||
/// produced when a downstream consumer (e.g. `rustscan -g ... | head`)
|
||||
/// closes the pipe, so instead the process exits quietly when that happens.
|
||||
/// Other output errors are reported on stderr before exiting.
|
||||
///
|
||||
/// All user-facing output should go through this function (the `warning!`,
|
||||
/// `detail!`, `output!` and `funny_opening!` macros already do) instead of
|
||||
/// `println!`.
|
||||
pub fn println_safe(args: std::fmt::Arguments<'_>) {
|
||||
match writeln!(std::io::stdout().lock(), "{args}") {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
|
||||
// the reader end of the pipe is gone; nothing left to print to
|
||||
std::process::exit(0);
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = writeln!(std::io::stderr(), "rustscan: failed writing to stdout: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminal User Interface Module for RustScan
|
||||
/// Defines macros to use
|
||||
#[macro_export]
|
||||
macro_rules! warning {
|
||||
($name:expr) => {
|
||||
println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name);
|
||||
$crate::tui::println_safe(format_args!(
|
||||
"{} {}",
|
||||
ansi_term::Colour::Red.bold().paint("[!]"),
|
||||
$name
|
||||
));
|
||||
};
|
||||
($name:expr, $greppable:expr, $accessible:expr) => {
|
||||
// if not greppable then print, otherwise no else statement so do not print.
|
||||
if !$greppable {
|
||||
if $accessible {
|
||||
// Don't print the ascii art
|
||||
println!("{}", $name);
|
||||
$crate::tui::println_safe(format_args!("{}", $name));
|
||||
} else {
|
||||
println!("{} {}", ansi_term::Colour::Red.bold().paint("[!]"), $name);
|
||||
$crate::tui::println_safe(format_args!(
|
||||
"{} {}",
|
||||
ansi_term::Colour::Red.bold().paint("[!]"),
|
||||
$name
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -23,16 +58,24 @@ macro_rules! warning {
|
||||
#[macro_export]
|
||||
macro_rules! detail {
|
||||
($name:expr) => {
|
||||
println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name);
|
||||
$crate::tui::println_safe(format_args!(
|
||||
"{} {}",
|
||||
ansi_term::Colour::Blue.bold().paint("[~]"),
|
||||
$name
|
||||
));
|
||||
};
|
||||
($name:expr, $greppable:expr, $accessible:expr) => {
|
||||
// if not greppable then print, otherwise no else statement so do not print.
|
||||
if !$greppable {
|
||||
if $accessible {
|
||||
// Don't print the ascii art
|
||||
println!("{}", $name);
|
||||
$crate::tui::println_safe(format_args!("{}", $name));
|
||||
} else {
|
||||
println!("{} {}", ansi_term::Colour::Blue.bold().paint("[~]"), $name);
|
||||
$crate::tui::println_safe(format_args!(
|
||||
"{} {}",
|
||||
ansi_term::Colour::Blue.bold().paint("[~]"),
|
||||
$name
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -41,24 +84,24 @@ macro_rules! detail {
|
||||
#[macro_export]
|
||||
macro_rules! output {
|
||||
($name:expr) => {
|
||||
println!(
|
||||
$crate::tui::println_safe(format_args!(
|
||||
"{} {}",
|
||||
RGansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
|
||||
ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
|
||||
$name
|
||||
);
|
||||
));
|
||||
};
|
||||
($name:expr, $greppable:expr, $accessible:expr) => {
|
||||
// if not greppable then print, otherwise no else statement so do not print.
|
||||
if !$greppable {
|
||||
if $accessible {
|
||||
// Don't print the ascii art
|
||||
println!("{}", $name);
|
||||
$crate::tui::println_safe(format_args!("{}", $name));
|
||||
} else {
|
||||
println!(
|
||||
$crate::tui::println_safe(format_args!(
|
||||
"{} {}",
|
||||
ansi_term::Colour::RGB(0, 255, 9).bold().paint("[>]"),
|
||||
$name
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -97,9 +140,10 @@ macro_rules! funny_opening {
|
||||
"RustScan: Exploring the digital landscape, one IP at a time.",
|
||||
"TreadStone was here 🚀",
|
||||
"With RustScan, I scan ports so fast, even my firewall gets whiplash 💨",
|
||||
"Scanning ports so fast, even the internet got a speeding ticket!",
|
||||
];
|
||||
let random_quote = quotes.choose(&mut rand::rng()).unwrap();
|
||||
|
||||
println!("{}\n", random_quote);
|
||||
$crate::tui::println_safe(format_args!("{}\n", random_quote));
|
||||
};
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
/*
|
||||
* Test rustscan against different targets with a time limit.
|
||||
* The tests assumes target/debug/rustscan has already been built.
|
||||
*
|
||||
* The tests are #[ignore] to avoid running them during normal development.
|
||||
*
|
||||
* Their tests in the timelimits module are run by travis during CI.
|
||||
*/
|
||||
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use wait_timeout::ChildExt;
|
||||
|
||||
const TIMEOUT_MARGIN: u32 = 3;
|
||||
|
||||
#[cfg(not(tarpaulin_include))]
|
||||
fn run_rustscan_with_timeout(args: &[&str], timeout: Duration) {
|
||||
println!("Running: target/debug/rustscan: {}", args.join(" "));
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
let mut child = Command::new("target/debug/rustscan")
|
||||
.args(args)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
|
||||
let mut tries = TIMEOUT_MARGIN;
|
||||
loop {
|
||||
match child.wait_timeout(timeout).unwrap() {
|
||||
Some(_status) => break,
|
||||
None => {
|
||||
tries -= 1;
|
||||
if tries == 0 {
|
||||
// child hasn't exited yet
|
||||
child.kill().unwrap();
|
||||
child.wait().unwrap().code().unwrap();
|
||||
panic!("Timeout while running command");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let end = Instant::now();
|
||||
let duration = end.saturating_duration_since(start).as_secs_f32();
|
||||
|
||||
println!("time: {:1.1}s", duration);
|
||||
}
|
||||
|
||||
mod timelimits {
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scan_localhost() {
|
||||
let timeout = super::Duration::from_secs(25);
|
||||
super::run_rustscan_with_timeout(&["--greppable", "--no-nmap", "127.0.0.1"], timeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scan_google_com() {
|
||||
super::run_rustscan_with_timeout(
|
||||
&[
|
||||
"--greppable",
|
||||
"--no-nmap",
|
||||
"-u",
|
||||
"5000",
|
||||
"-b",
|
||||
"2500",
|
||||
"google.com",
|
||||
],
|
||||
super::Duration::from_secs(28),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scan_example_com() {
|
||||
super::run_rustscan_with_timeout(
|
||||
&[
|
||||
"--greppable",
|
||||
"--no-nmap",
|
||||
"-u",
|
||||
"5000",
|
||||
"-b",
|
||||
"2500",
|
||||
"example.com",
|
||||
],
|
||||
super::Duration::from_secs(28),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn scan_rustscan_cmnatic_co_uk() {
|
||||
super::run_rustscan_with_timeout(
|
||||
&[
|
||||
"--greppable",
|
||||
"--no-nmap",
|
||||
"-u",
|
||||
"5000",
|
||||
"-b",
|
||||
"2500",
|
||||
"rustscan.cmnatic.co.uk",
|
||||
],
|
||||
super::Duration::from_secs(26),
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn udp_scan_localhost() {
|
||||
let timeout = super::Duration::from_secs(25);
|
||||
super::run_rustscan_with_timeout(&["--greppable", "127.0.0.1", "--udp"], timeout);
|
||||
}
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn udp_scan_google_com() {
|
||||
super::run_rustscan_with_timeout(
|
||||
&[
|
||||
"--udp",
|
||||
"--greppable",
|
||||
"-u",
|
||||
"5000",
|
||||
"-b",
|
||||
"2500",
|
||||
"google.com",
|
||||
],
|
||||
super::Duration::from_secs(28),
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn udp_scan_example_com() {
|
||||
super::run_rustscan_with_timeout(
|
||||
&[
|
||||
"--udp",
|
||||
"--greppable",
|
||||
"-u",
|
||||
"5000",
|
||||
"-b",
|
||||
"2500",
|
||||
"example.com",
|
||||
],
|
||||
super::Duration::from_secs(28),
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn udp_scan_rustscan_cmnatic_co_uk() {
|
||||
super::run_rustscan_with_timeout(
|
||||
&[
|
||||
"--udp",
|
||||
"--greppable",
|
||||
"-u",
|
||||
"5000",
|
||||
"-b",
|
||||
"2500",
|
||||
"rustscan.cmnatic.co.uk",
|
||||
],
|
||||
super::Duration::from_secs(26),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use rustscan::generated::get_parsed_data;
|
||||
use rustscan::scanner::build_udp_payload_lookup;
|
||||
|
||||
#[test]
|
||||
fn udp_payload_lookup_contains_common_udp_ports() {
|
||||
let udp_map = get_parsed_data();
|
||||
let lookup = build_udp_payload_lookup(udp_map);
|
||||
|
||||
// These are common UDP services; the payload database should include them.
|
||||
assert!(
|
||||
lookup.contains_key(&53),
|
||||
"expected UDP payload for DNS (53)"
|
||||
);
|
||||
assert!(
|
||||
lookup.contains_key(&123),
|
||||
"expected UDP payload for NTP (123)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_payload_lookup_payloads_are_non_empty_for_known_ports() {
|
||||
let udp_map = get_parsed_data();
|
||||
let lookup = build_udp_payload_lookup(udp_map);
|
||||
|
||||
// Don't assert exact bytes (the generated payload set may evolve),
|
||||
// but it should not be empty for these well-known protocols.
|
||||
let dns = lookup.get(&53).expect("missing DNS payload");
|
||||
assert!(!dns.is_empty(), "DNS payload should not be empty");
|
||||
|
||||
let ntp = lookup.get(&123).expect("missing NTP payload");
|
||||
assert!(!ntp.is_empty(), "NTP payload should not be empty");
|
||||
}
|
||||
Reference in new issue
Block a user