8.6 KiB
Implementation Walkthrough
A guided tour of the code. It names functions and files, never line numbers, so it stays correct as the source moves. Read it with the source open beside you. For the byte-level and math-level details, follow the pointers into MECHANICS.md.
Wire formats and the checksum: packet.zig
Everything the scanner puts on the wire starts here. The Ethernet, IPv4, TCP, UDP, and IPv6 headers are declared as extern struct so their field order and packing match the wire exactly, and each carries a compile-time size assertion so a mistaken field can never silently change the layout. Multi-byte fields are converted to network byte order explicitly at the point of writing, never left to chance.
The checksum lives in two functions. checksum is the scalar one's-complement sum from RFC 1071. checksumSimd is the same math over a vector accumulator for large buffers. Both are validated against the published RFC 1071 worked example. tcpChecksum and tcpChecksum6 build the pseudo-header and run the sum over the TCP segment for IPv4 and IPv6. There is also incrementalUpdate, the RFC 1624 trick that patches a checksum when a single field changes without resumming the whole packet.
ScanType and OsProfile encode the stealth variations. ScanType.probeFlags returns the TCP flag byte for a syn, fin, null, xmas, maimon, ack, or window scan, and OsProfile.options returns the TCP option bytes of a realistic Linux, Windows, or macOS SYN so a fingerprint looks like a real client rather than a scanner.
The Addr union is how one result path serves both address families: it holds either a u32 for IPv4 or a [16]u8 for IPv6, so the classification, output, and dedup code is written once.
The cookie: cookie.zig
This is statelessness in about sixty lines. A Cookie is a 16-byte key, created from per-scan random entropy. generate packs the four-tuple into a small buffer and returns std.hash.SipHash64(2, 4).toInt over it, a full 64-bit hash. seq truncates that to the 32 bits that fit in a TCP sequence number. validateSynAck recomputes seq from the reply's addresses and ports and returns whether the acknowledgement equals seq +% 1, using wrapping addition because the cookie can be 0xFFFFFFFF and a plain add would overflow-trap in a safe build.
The IPv6 variants generate6, seq6, and validateSynAck6 do the same over 16-byte addresses. udpSrcPort reuses the hash to derive a per-target source port for UDP, so the reply's destination port itself carries validating information. A unit test reproduces the canonical SipHash empty-message vector, so the primitive is proven, not assumed.
The address engine: targets.zig and numtheory.zig
parseCidr turns 192.0.2.0/24 into a Range of start and end addresses. IpPicker.build takes your ranges, subtracts every reserved block through subtractReserved, sorts what remains, and builds a cumulative-prefix index so that a flat integer can be mapped to a concrete address with a binary search in IpPicker.at. This is where 10.0.0.0/8 collapses to nothing: the reserved subtraction removes the whole private block.
Engine is the permutation. Engine.init computes the total size of the address-and-port space, finds the smallest prime above it with numtheory.smallestPrimeAbove, and picks a fresh primitive root with numtheory.findPrimitiveRoot. Note the difference from zmap here: zmap hardcodes a table of eleven prime and primitive-root pairs, while zingela computes the prime for the exact size of your scan at runtime, so a small scan gets a small group and wastes no iterations re-rolling out-of-range values. Engine.next advances the group with numtheory.mulMod, decodes the resulting element into an address and port through the picker, and returns one Target. initShard slices the group into contiguous arcs so several machines can share one scan by seed.
numtheory.findPrimitiveRoot is the interesting one. Rather than brute-force testing, it factors prime - 1 with distinctPrimeFactors and, for each random candidate, checks with isPrimitiveRoot that no modExp(candidate, (prime-1)/q, prime) equals one. A candidate that passes generates the whole group. The math is in MECHANICS.md.
Stamping frames: template.zig
SynTemplate.init builds a base frame once from a Config (source and destination MAC, source address and port, cookie, OS profile, scan type), laying down the Ethernet, IPv4, and TCP headers with everything that does not change between targets. stamp is the per-target hot function: it copies the base, writes the destination address, varies the IP identification field, writes the destination port, computes the cookie with cookie.seq and places it in the sequence or acknowledgement field depending on the scan type, and finishes with the IP and TCP checksums. stampVariant layers decoy source addresses on top for the stealth suite. SynTemplate6 is the IPv6 counterpart.
Pacing: ratelimit.zig
TokenBucket is a nanosecond bank. init sets a per-token step in nanoseconds from your requested packets per second and a capacity for short bursts. takeBatch reads the monotonic clock, credits elapsed time into the bank, and returns how many tokens the transmit engine may spend right now, which is how the sender stays at rate without busy-waiting. refund returns tokens when a send could not be completed, and withJitter perturbs the timing for the Poisson jitter mode. This is a genuine token bucket, distinct from masscan's proportional-feedback controller over a timestamp ring.
The engines: tx.zig and rx.zig
tx.zig is the transmit loop: prime the bucket, pull targets from the Engine, stamp them through the template, gate on the bucket, and batch them into the backend's transmit ring before kicking the kernel to send. It allocates nothing after startup. rx.zig opens the receive socket for the right ethertype and reads frames in a loop.
Classification lives in classify.zig. classifyTcp inspects a received TCP segment and decides open, closed, or filtered, gated on a valid cookie so that only genuine replies count, and maps relevant ICMP errors to filtered. classifyTcp6 does the same for IPv6, including ICMPv6 unreachables. Deduplication lives in dedup.zig: Dedup.insert mixes the key with an fmix64 finalizer and places it in an open-addressed table with linear probing, returning whether the host was newly seen so each host is reported once.
The backends: packet_io.zig, afpacket.zig, afxdp.zig, connect.zig
packet_io.zig defines the interface the engines call: open, reserve a transmit slot, submit, kick, poll for received frames, close. afpacket.zig implements it over an AF_PACKET socket with a memory-mapped PACKET_TX_RING and PACKET_QDISC_BYPASS, the universal path that needs no kernel modules. afxdp.zig implements the accelerated path over AF_XDP with its own user-memory region and rings, built only when -Dxdp is set. connect.zig is the unprivileged fallback: it makes raw non-blocking connect calls through std.os.linux, waits on them with poll, and reads the socket error to classify open, closed, or filtered. It has its own sized thread pool because a connect blocks a thread, so concurrency equals pool size.
This raw-socket approach is deliberate. The connect scanner drives non-blocking sockets and poll itself rather than going through the standard networking layer's connect-with-timeout, which connect.zig does not use for the scan path.
UDP, service detection, stealth, and output
udp.zig holds the compile-time payload table and the UDP classification, where an ICMP type-3 code-3 unreachable means closed and silence is reported honestly as open-or-filtered because a UDP service may simply not answer a probe it does not recognize. service.zig is the opt-in second phase: on an open port it sends a NULL probe and an HTTP request, grabs the banner, and detects TLS without decrypting it. stealth.zig gathers the gated evasion features. output.zig renders the truecolor live dashboard and the results table to the terminal while writing NDJSON to standard output, so the visual and machine views never collide.
Where the proofs live
Every one of these modules ships unit tests in the same file, run with zig build test. The checksum has its RFC vector, the cookie has its SipHash vector and a cookie-plus-one round trip, the address engine has a bijection property test over a small space, and the dedup table has growth and collision tests. The whole pipeline is exercised end to end inside a network namespace before release. That is the repository rule: no code without proof.