Cybersecurity-Projects/PROJECTS/advanced/zig-stateless-scanner/learn/02-ARCHITECTURE.md

7.9 KiB

Architecture

zingela is two machines bolted together: a stateless line-rate transmit and receive core that owns the packet hot path, and a memory-safe control plane that parses your intent, paces the sending, and presents the results. This doc shows how they fit.

The big picture

        you                       control plane                     data plane
   ------------      ------------------------------------      --------------------
   --target      ->  cli.zig  -> scancmd.zig                    tx.zig  (transmit)
   --ports           parse       validate, build Config    ->    pull target
   --rate            help        select backend                  stamp frame
   --backend         banner      launch engines                  rate-gate
                                   |          \                   push to ring
                                   |           \                       |
                                   v            v                      v
                              output.zig     rx.zig  (receive)   packet_io.zig
                              dashboard        read frame          AF_XDP or
                              table            validate cookie     AF_PACKET or
                              NDJSON           classify            connect
                                   ^            dedup
                                   |            push found host
                                   +---------------+

The control plane never touches a raw socket in the hot loop. The data plane never allocates after startup. That separation is what keeps the scanner both safe and fast.

The stateless data flow

Follow one probe and its reply all the way through. This is the heart of the design.

  TRANSMIT                                            RECEIVE
  --------                                            -------
  targets.Engine.next()                               packet_io rxPoll()
     gives (ip, port)                                    reads a frame
        |                                                   |
        v                                                   v
  cookie.seq(ip,port,src,srcport)                     classify.classifyTcp()
     = low 32 bits of                                    is it SYN-ACK / RST / ICMP?
       SipHash(4-tuple)                                    |
        |                                                  v
        v                                             cookie.validateSynAck(ack,...)
  template.stamp(frame, ip, port)                        ack == cookie + 1 ?
     writes Eth+IP+TCP,                                    |
     puts cookie in TCP seq                          yes --+-- no
        |                                             |          |
        v                                             v          v
  ratelimit token gate                          dedup.insert   drop (stray/spoof)
        |                                          new host?
        v                                             |
  packet_io txSubmit + txKick  --- wire --->      output emit

Nothing links the left column to the right except arithmetic. The cookie written into the sequence number on transmit is the same cookie recomputed and checked on receive. There is no shared table, no correlation ID lookup, no per-probe memory. A reply that does not carry a valid cookie is discarded before it can pollute the results.

The two engines

The transmit engine and the receive engine run as concurrent tasks launched with io.concurrent over Zig's std.Io.Threaded. This is not the old async keyword, which Zig removed, and it is not a future per probe. It is two long-lived workers.

The transmit engine runs hot. It pulls targets from the address permutation, stamps each into a preallocated frame buffer, passes it through the token-bucket rate gate, and hands it to the packet backend. After startup it does not allocate: the transmit ring is memory-mapped once, and per-packet scratch comes from a fixed buffer.

The receive engine reads frames from the backend, classifies each as SYN-ACK, RST, or a relevant ICMP error, validates the cookie, deduplicates against a lockless open-addressed hash table so a host that answers twice is reported once, and pushes newly found hosts to the output side. Replies are sparse compared to probes, so the receive engine is far less hot than the transmit engine, which is why the backend design below can afford to be asymmetric.

The backend ladder

The packet input and output path is chosen at runtime through one interface (packet_io.zig) with several implementations. The scanner core does not know or care which one is active.

  --backend auto  picks the first that works:

   1. AF_XDP zero-copy      fastest, driver-dependent          -Dxdp build
   2. AF_XDP with XDP_SKB   copy mode, works on more drivers    -Dxdp build
   3. AF_PACKET + TX_RING   universal, kernel-bypass TX ring    always built
      + QDISC_BYPASS        the v1 default, matches masscan-stock
   4. AF_PACKET sendto      plain, used by the ground-truth smoke
   5. connect scan          TCP via the OS stack, no raw needed  --connect

The AF_XDP path is asymmetric: it accelerates transmit and pairs with an AF_PACKET receive path. Replies are sparse, so this trades a few percent of theoretical receive throughput for a much simpler design with no receive-side steering or map wiring. When --backend auto runs, a two-socket self-probe first checks whether raw sends actually leave the machine. On a cloud instance or a VM where the hypervisor silently drops raw frames, that probe fails and zingela falls back to the connect scanner with a clear notice, rather than reporting a scan that sent nothing.

The module map

Module Responsibility
main.zig entry point: parse arguments, build config, dispatch to a subcommand
cli.zig argument parsing, help, the banner and version
scancmd.zig the scan command: validate, select backend, launch transmit and receive, drive output
txcmd.zig the transmit-only blast command
targets.zig the cyclic-group permutation engine, primitive-root finder, address and port picker, exclusion floor
numtheory.zig modular exponentiation, primality, primitive-root testing for the permutation
packet.zig wire-format headers, the RFC 1071 checksum (scalar and SIMD), OS-realistic SYN presets
cookie.zig SipHash SYN-cookie generation and validation, IPv4 and IPv6
template.zig the SYN frame template that gets stamped per target
ratelimit.zig the token-bucket pacer
tx.zig the transmit engine
rx.zig the receive engine
classify.zig reply classification into open, closed, filtered
dedup.zig the lockless open-addressed dedup table
packet_io.zig the backend interface, with afpacket.zig, afxdp.zig, and connect.zig implementations
udp.zig UDP payloads and UDP-specific classification
service.zig the opt-in banner and service detection phase
stealth.zig the gated evasion features
output.zig the live dashboard, results table, and NDJSON
netutil.zig, ndp.zig, rawprobe.zig interface and gateway resolution, IPv6 neighbor discovery, the cloud self-probe

Why the split matters for safety

The 2003 SQL Slammer worm and every buffer-overflow scanning worm since share a root cause: hand-written packet code in C that corrupts memory under load. zingela builds and tests under Zig's ReleaseSafe mode first, where an out-of-bounds slice, an integer overflow, or a null dereference is a defined trap rather than silent memory corruption. The wire-format structs carry compile-time size assertions, every checksum and cookie is checked against a published test vector, and the whole scan is validated end to end inside a network namespace before any release. The implementation walkthrough shows where each of these guards lives.