How a request travels from an MCP client to packets on the wire, and where each limit is enforced.
/root/pentest-mcp
├── src/ mcp-netscan binary (MCP server)
│ ├── main.rs config load, listeners, auth middleware, admin spawn, shutdown
│ ├── server.rs McpServer: list_tools / call_tool, quota chain, progress
│ ├── config.rs full config schema, defaults, validation, MCP_CONFIG lookup
│ ├── auth.rs header -> Principal (allow/deny tools, quota)
│ ├── limits.rs global + per-(caller, tool) metering: rate, concurrency, daily budgets
│ ├── targets.rs target parsing, IP counting, TargetPolicy (private/special/metadata)
│ ├── exec.rs sandboxed subprocess runner: argv only, caps, process-group kill
│ └── tools/
│ ├── mod.rs registry builder, shared limits, progress sink, run_command
│ ├── registry.rs ToolHandler trait, Registry, CommandTool (config-declared plugins)
│ ├── net_scan.rs native engine as a tool (in-process, progress, findings)
│ ├── native.rs shared scaffolding for the pure-Rust tools (ToolLimits, RateGate)
│ ├── data_suite.rs hash_identify, dedup_lines, json_flatten
│ ├── dns_suite.rs dns_query, dns_bruteforce, dns_zone_transfer, subdomain_permute
│ ├── secrets_suite.rs secret_scan, cewl
│ ├── service_suite.rs redis_check, smtp_enum, ftp_probe, ssh_audit, tls_scan, ike_scan
│ ├── web_recon.rs http_probe, web_crawl, web_tech, waf_detect, security_headers, cors_check
│ ├── web_audit.rs web_fuzz, param_discover, js_endpoints, xss_scan, sqli_scan, ssti_scan, open_redirect, jwt_inspect, cms_detect, graphql_probe
│ ├── discovery_suite.rs ping_sweep, nbtscan, snmp_check, snmp_walk, packet_send
│ └── http.rs | ssh.rs HTTP client and SSH client tools
├── crates/zscan/ scan engine library
│ ├── engine.rs ScanSpec, Engine, TX/RX tasks, findings broadcast
│ ├── addr.rs TargetSet, next_prime/primitive_root, Permutation (Z/p walk)
│ ├── rate.rs fixed-point pacer (pps + bytes/s), CpuThrottle
│ ├── state.rs Cookie validation, BannerTable
│ ├── packet.rs frame parsing/building, checksums
│ ├── probe/mod.rs probe modules + classify_simple
│ ├── probe/payload.rs UDP payload templates and cookie fields
│ ├── io/{mod,ring,socket,mem}.rs backends: TPACKET_V3 rings, plain socket, in-memory
│ ├── blacklist.rs exclusion intervals
│ └── stats.rs atomic counters + snapshots
├── crates/zscan-cli/ `zscan` binary: scan + migrate/user/quota subcommands
├── crates/quota/ QuotaProfile/WindowSpec/Deny, QuotaManager, backends (memory, redis)
├── crates/store/ Mongo collections + Redis sessions/bus, file-backed local store, TOML migration
├── crates/admin/ axum routes + askama templates for the web panel
└── docs/ this documentation set
flowchart TD
C[MCP client] -->|HTTP POST /mcp, Authorization: Bearer| T[axum router + Streamable HTTP transport]
T --> H{Host / Origin allow-list}
H -->|reject| E401[4xx / JSON-RPC error]
H --> A[authenticate middleware]
A -->|unknown token| E401
A -->|Principal injected into request extensions| S[McpServer.call_tool]
S --> P{tool permitted for principal?}
P -->|no| TE[tool error text]
P --> Q1[Cluster quota: QuotaManager.acquire subject = name or name:tool]
Q1 -->|Deny| TE
Q1 --> Q2[IP budget: Limiter.check_ip_count]
Q2 --> Q3[Limiter.admit: daily calls, daily IPs, global slot, per-tool slot, cooldown, token bucket]
Q3 -->|LimitError| TE
Q3 --> D{tool kind}
D -->|net_scan| Z[zscan Engine in-process]
D -->|dns / service / web_recon / web_audit / discovery / secrets / data| I[in-process tool code]
D -->|ssh| X[exec::run: argv, caps, kill_on_drop]
D -->|http_request| W[reqwest with SSRF guards]
Z --> R[AF_PACKET TPACKET_V3 ring, per TX worker]
R --> N[(wire)]
N --> RX[RX task: parse, cookie check, classify]
RX --> F[findings broadcast]
F --> M[MCP result + progress notifications]
I --> M
X --> M
W --> M
Z --> M
Reading of the diagram: the auth middleware runs for every route except the
health probes, and it bounds the request body before anything parses it
(src/main.rs:360-392). The principal is stashed in the request extensions so
the tool layer learns who is calling (src/main.rs:390).
Every tool runs in-process except ssh and operator-declared
[[tools.custom]] entries, which go through exec::run; the pure-Rust suites
(dns, service, web_recon, web_audit, discovery, secrets, data)
speak HTTP, DNS and raw packets directly (src/tools/native.rs:1-13).
- Loads
config.toml(or$MCP_CONFIG) and exits with code 2 on a configuration error (src/main.rs:38-44). - Builds one
Authenticatorfrom[auth](src/main.rs:78). - Opens shared state: Mongo + Redis when both URIs are set, otherwise the
file-backed local store (
connect_shared_state,src/main.rs:225-243). A configured-but-unreachable cluster logs a warning and continues without shared state (src/main.rs:97-103). - Spawns the admin panel on its own listener when
[store].admin_enabled, with the cookie key from[store].cookie_keyor an ephemeral one (src/main.rs:246-288,src/main.rs:258-267). - Loads every Mongo quota profile into a
QuotaManager<InMemoryBackend>and subscribes to the Redis invalidation channel (src/main.rs:113-122). - Builds the router in one of two branches because
SessionManagerhas RPITIT associated types:LocalSessionManagerforsession_mode = true,NeverSessionManagerfor the stateless default (src/main.rs:126-143).
list_tools publishes the enabled tools filtered by the principal's
allow/deny lists (src/server.rs:97-121). call_tool:
- Resolves the tool and refuses unpermitted names without revealing whether an
unpermitted tool exists (
src/server.rs:130-137). - Resolves the cluster quota subject: a per-tool
"<caller>:<tool>"profile wins over a caller-wide"<caller>"profile; when neither exists the call is not cluster-gated at all. A gated call acquires a permit with a wait ofmin([store].cluster_queue_wait_secs, max_tool_call_secs); only profiles withqueue_when_busywait (src/server.rs:156-193). - Charges the IP budget from the argument-derived address count
(
src/server.rs:195-198). - Takes the TOML/limiter ticket (
src/server.rs:200-215). - Wires a progress channel if the client supplied a progress token
(
src/server.rs:217-242). - Runs the tool, then completes the cluster permit and bills the returned
bytes (
src/server.rs:244-263).
A request maps to a Principal { name, anonymous, allow_tools, deny_tools, quota }. deny_tools wins over allow_tools; an empty allow-list allows
everything (src/auth.rs:78-85). Token comparison scans every configured secret
with a length-independent, early-exit-free byte compare, so response time does
not reveal which token matched (src/auth.rs:154-171, src/auth.rs:176-189).
Per (caller, tool) meters hold a token bucket, a concurrency semaphore, a
daily counter set with a UTC-day rollover, and a cooldown timestamp
(src/limits.rs:225-245, src/limits.rs:153-186). admit runs the cheap
checks first so a refused call never holds a semaphore slot
(src/limits.rs:360-435). The queue wait for a busy slot is 10 s
(src/limits.rs:325, src/limits.rs:441-462), then the call fails with a
retry hint.
registry(config) decides the exposed set from configuration, then adds
[[tools.custom]] entries and any in-process plugins
(src/tools/mod.rs:443-595). Every handler implements ToolHandler
(src/tools/registry.rs:52-56), so built-ins, config-declared commands and
plugins share one quota/timeout/output path. run_command clamps a tool's own
timeout to the call ceiling and bills output bytes to the caller's ticket
(src/tools/mod.rs:406-436). Config-declared tools expand {{key}}
placeholders only from caller-supplied values, with per-key allow-lists and a
deny-list, and never reach a shell (src/tools/registry.rs:154-210).
The pure-Rust suites do not go through run_command at all: they are built
with native_tool! / native::<Args> (src/tools/native.rs:135-173) and apply
the shared ToolLimits envelope themselves — timeout clamped to the call
ceiling, target count checked before work starts, output capped, and a
RateGate for request pacing (src/tools/native.rs:27-89,
src/tools/native.rs:175-225).
There are two backends behind one Store facade (crates/store/src/lib.rs:18-57):
- MongoDB + Redis — the multi-node mode. Mongo holds users, API secrets and
quota profiles; Redis holds admin sessions and the
zq:invalidatebus that tells every node to reload a changed profile (crates/store/src/lib.rs:36-48,crates/store/src/bus.rs:45-56). - File-backed local — the default for a standalone node. One JSON file at
[store].local_path(defaultadmin-store.json) holds users (argon2id password hashes), secrets (argon2 hashes) and quota profiles; every mutation rewrites it atomically via temp-file + rename, with0600permissions (crates/store/src/local.rs:1-17,crates/store/src/local.rs:156-182).
connect_shared_state chooses the backend from configuration: both URIs set
means Mongo + Redis, anything else means the local file
(src/main.rs:225-243). The admin panel and the cluster quota manager both
work in local mode — profiles are loaded into the in-process QuotaManager
from whichever store is active (src/main.rs:112-125). What local mode does
not provide is cross-node consistency: the invalidation publish/subscribe pair
returns early (crates/store/src/bus.rs:45-50,
crates/store/src/bus.rs:58-65), and admin sessions live in one process's
memory (crates/store/src/local.rs:378-411).
Engine::start opens one independent I/O backend per TX worker and keeps
the first RX half (crates/zscan/src/engine.rs:166-178). The ring backend
therefore gives each worker its own AF_PACKET socket and mmap ring, so worker
threads never contend on a single ring.
Ring setup details (crates/zscan/src/io/ring.rs):
- RX and TX rings must live on separate sockets — configuring both on one fd
returns
EBUSY(crates/zscan/src/io/ring.rs:101-107). - The RX socket binds
ETH_P_ALLand setsPACKET_IGNORE_OUTGOINGso the scan does not see its own probes (crates/zscan/src/io/ring.rs:116-121). - The TX socket binds
ETH_P_IP(0x0800) and setsPACKET_QDISC_BYPASSunlessZSCAN_QDISC_BYPASS=0(crates/zscan/src/io/ring.rs:122-133). BindingETH_P_ALLon TX would tag injected frames0x0003and the kernel would drop them before TCP or ICMP saw them (crates/zscan/src/io/ring.rs:122-126). - RX ring geometry: 1 MiB blocks × 32 with
tp_retire_blk_tov = 1, falling back to 256 KiB × 16 and 64 KiB × 16 (crates/zscan/src/io/ring.rs:401-416). - TX ring geometry: 4 KiB blocks (one page, so
tp_block_size % PAGE_SIZE == 0holds) × up to 32 768 frames of 2 048 bytes, falling back to 4 096 and 1 024 frames (crates/zscan/src/io/ring.rs:418-440).
Rendering is zero-copy: try_render hands the caller the ring slot itself and
the probe patches destination, port and cookie straight into that slice;
render_slot then publishes the slot by writing TP_STATUS_AVAILABLE and
advancing the cursor (crates/zscan/src/io/ring.rs:202-219; try_render
delegates to it at crates/zscan/src/io/ring.rs:278-282). No intermediate Vec and no memcpy
of a whole frame. The header sits at the fixed offset
TX_PKT_MAC = 48 (TPACKET_ALIGN(sizeof tpacket3_hdr), crates/zscan/src/io/ring.rs:40).
Kicking is the transmission. On a full ring the worker does not sleep: it calls
kick() on the same probe index and spins, only yielding after 512 consecutive
stalls (crates/zscan/src/engine.rs:598-606, and the staged-fragment path at
crates/zscan/src/engine.rs:625-637):
// Ring full: kick IS the transmission (the
// syscall drains synchronously), so spin on
// kicks rather than sleeping — retry the SAME
// probe index, only pacing after long stalls.
pending = Some(u);
tx.kick();
stall += 1;
if stall > 512 {
tokio::time::sleep(crate::rate::TICK).await;
stall = 0;
}kick_io is a zero-length sendto with an explicit sockaddr_ll (a NULL
address yields ENXIO on some kernels) using MSG_DONTWAIT; WouldBlock is
treated as success (crates/zscan/src/io/ring.rs:233-256). The batch fallback path (non-ring
backends) renders into pooled buffers and calls transmit
(crates/zscan/src/engine.rs:651-671).
rate.rs is a paced limiter, not a governor. The TX loop asks for a budget per
tick and spends it without per-packet clock reads. Tokens accrue in 16.16
fixed point, so a 10 Mpps cap loses nothing to float rounding and fractional
leftovers carry to the next tick (crates/zscan/src/rate.rs:1-12, crates/zscan/src/rate.rs:98-116). The tick
is 250 µs (TICK, crates/zscan/src/rate.rs:12) and the burst bucket tops out at 16 ticks
(4 ms) so an idle limiter cannot release a whole second of backlog
(BURST_TICKS, crates/zscan/src/rate.rs:22). The loop passes the minimum wire size (64 bytes)
to budget so even an absurd byte cap still affords one frame
(crates/zscan/src/engine.rs:550-551, crates/zscan/src/rate.rs:98-116).
Additional pacing layers:
- Per-worker rate split:
per_pps = rate_pps / workers,per_bpslikewise (crates/zscan/src/engine.rs:434-437). CpuThrottle: an advisory duty-cycle governor measured over 100 ms windows (crates/zscan/src/engine.rs:677-686,crates/zscan/src/rate.rs:214-249).
A single RX task drains the ring and, for every frame (crates/zscan/src/engine.rs:224-431):
- Parses the frame; unparseable frames are dropped (
crates/zscan/src/engine.rs:257). - Drops any source address present in the exclusion index
(
crates/zscan/src/engine.rs:259). - For TCP, drops replies whose source port is not in the configured port list
and counts them as foreign (
crates/zscan/src/engine.rs:262-265). - Classifies with
probe::classify_simple, which checks the cookie (crates/zscan/src/engine.rs:267).
The cookie is a per-run 64-bit secret mixed with the 4-tuple
(src_ip, dst_ip, sport, dport) through a splitmix64-style finaliser
(crates/zscan/src/state.rs:32-46). A TCP reply is ours only when its ACK equals
cookie + 1 (crates/zscan/src/state.rs:50-52). Replies for a SYN/ACK whose cookie does not
match are classified Foreign and dropped, so a random host cannot fake a hit
and the engine keeps no per-probe state (crates/zscan/src/state.rs:1-9,
crates/zscan/src/probe/mod.rs:73-92). The cookie is patched into the TCP sequence number, the
ICMP echo id, or the payload field of DNS/mDNS (txid), SNMP (request id) and NTP
(originate timestamp) probes (crates/zscan/src/probe/mod.rs:525-547,
crates/zscan/src/probe/mod.rs:628-657, crates/zscan/src/probe/payload.rs:106-140). SSDP, memcached and raw templates have no field the
reply echoes, so they validate on the UDP 4-tuple only — zmap documents the same
limitation (crates/zscan/src/probe/payload.rs:24-30).
Deduplication uses a HashSet of (ip, port, proto) cleared every 10 s, so a
long scan self-heals instead of growing without bound (crates/zscan/src/engine.rs:240-254).
Banner grabbing keeps a BannerTable of at most 65 536 half-open connections
with a 10 s lifetime (crates/zscan/src/engine.rs:237, crates/zscan/src/state.rs:72-81); replies to banner
handshakes travel RX → reply channel → TX workers, and TX workers keep flushing
them while the engine drains (crates/zscan/src/engine.rs:218-220, crates/zscan/src/engine.rs:525-537).
TargetSet flattens ranges, merges overlaps, and stores exclusive cumulative
sizes so a dense index maps to an address with a binary search
(crates/zscan/src/addr.rs:59-118). The scan unit is targets × ports; each worker walks its
own slice of a multiplicative group:
let modulus = next_prime(count.max(2) + 1);
let generator = primitive_root(modulus);
let order = modulus - 1;
let len = order / shards as u64;
let start = len * shard as u64;
let budget = if shard == shards - 1 { order - start } else { len };
let e0 = ((seed % order) + start) % order;
Self { modulus, generator, step: powmod(generator, e0, modulus), budget, count }(crates/zscan/src/addr.rs:228-241.) next() emits the current value, multiplies by the
generator modulo p, and skips values above count (prime padding, rare
because the next prime sits within O(log count)) (crates/zscan/src/addr.rs:245-258).
next_prime uses deterministic Miller–Rabin bases up to 37 (crates/zscan/src/addr.rs:119-147)
and primitive_root factors p-1 and rejects non-generators
(crates/zscan/src/addr.rs:164-187).
Sharding composes the CLI/API shard with the worker index:
eff_shards = shards × workers, eff_shard = shard × workers + w
(crates/zscan/src/engine.rs:491-494). Retries re-walk the space with a different seed:
seed ^ 0x9e3779b97f4a7c15 × round (crates/zscan/src/engine.rs:570-572). Identical seeds
reproduce identical scans, which is what makes distributed shards disjoint and
resumable.
The exclusion list is snapshotted once into an interval index at start, so both
the TX generation path and the RX classification path do an array index instead
of a binary search per probe (crates/zscan/src/engine.rs:196-201,
crates/zscan/src/state.rs:279-351; crates/zscan/src/blacklist.rs:79-98 is
the binary search that path used to take).
Findings go out on a broadcast channel (crates/zscan/src/engine.rs:101-107 holds the
sender; the channel is created at crates/zscan/src/engine.rs:144, and
Engine::subscribe at crates/zscan/src/engine.rs:162 hands the reader a
receiver). The CLI prints them as JSON/CSV/list
(crates/zscan-cli/src/main.rs:1189-1205, crates/zscan-cli/src/main.rs:1257-1270);
the MCP tool collects them and reports notifications/progress at most every
250 ms with probes sent, findings and tx_done (src/tools/net_scan.rs:873-891).
The engine stops when the permutation is exhausted (tx_done) plus wait
seconds of drain (crates/zscan/src/engine.rs:721-739), or when the call
ceiling or a stop token fires.
There are four independent layers. Every layer can only lower a limit, so
the effective value is the minimum of node, secret, cluster and request
(src/tools/net_scan.rs:528-553).
| Layer | Where it lives | What it enforces |
|---|---|---|
| Node | config.toml [tools.net_scan].max_rate_pps, max_ip_count, [node].max_pps, [node].max_bytes_per_sec |
Hard ceiling no caller can exceed. |
| Secret (TOML) | [[auth.secrets.quota]] and [auth.secrets.quota.per_tool.<tool>] |
rpm + burst token bucket, per-secret and per-tool concurrency, rolling UTC-day calls, output bytes, IP budget, cooldown, pps/bytes ceilings, per-tool max_ip_count. |
| Cluster | MongoDB quota_profiles, one document per subject |
max_concurrency, cooldown, sliding windows (60/3600/86400/604800 s, requests and bytes in/out), max_pps, bytes/s, cpu_quota_pct. |
| Request | tool arguments such as ratePps, numProbes |
Can only request less. |
Concretely for the native scanner:
let mut rate = config.max_rate_pps; // node/tool ceiling
if let Some(q) = timing_pps { rate = rate.min(q); } // nmap -T
if let Some(q) = ctx.config.node.max_pps { rate = rate.min(q); } // [node] NIC ceiling
if let Some(q) = ctx.ticket.max_rate_pps() { rate = rate.min(q); } // TOML quota
if let Some(shaping) = &ctx.shaping
&& let Some(q) = shaping.max_pps { rate = rate.min(q); } // cluster profile
if let Some(requested) = args.rate_pps { rate = rate.min(requested); } // request(src/tools/net_scan.rs:528-553.) The byte-rate chain is identical with
[node].max_bytes_per_sec, ticket.max_bytes_per_second() and
shaping.max_bytes_per_sec_out (src/tools/net_scan.rs:554-563).
Inside Limiter::admit the order is: daily call counter, daily IP budget,
global concurrency semaphore, per-tool concurrency semaphore, per-module
cooldown, then the rate token bucket (src/limits.rs:360-435). The cluster
permit is acquired before all of that in call_tool, with a wait of
min([store].cluster_queue_wait_secs, [limits].max_tool_call_secs); only a
profile with queue_when_busy waits at all, everything else fails immediately
(src/server.rs:170-193).
Per-subject keys: the MCP server prefers a "<caller>:<tool>" profile, then a
"<caller>" profile, and skips the cluster gate entirely when neither exists
(src/server.rs:158-168).
TOML meters are always keyed (caller, tool) (src/limits.rs:333-336).
- The MCP listener accepts SIGINT or SIGTERM and then drains, but the drain
timeout is hard-coded by axum's default:
server.graceful_shutdown_secsis parsed and never read (src/main.rs:167-169). - Health (
/healthz) and readiness (/readyz) are the same handler and are exempt from auth so orchestrators can probe them; they expose only counters and configuration summaries (src/main.rs:360-364,src/main.rs:407-424). - With no
[store].mongo_urithe server opens the file-backed store and the admin panel runs normally (src/main.rs:225-237). If a configured Mongo or Redis is unreachable at startup, it logs a warning and runs with TOML quotas only; the admin panel and cluster profiles stay off (src/main.rs:97-103). - If Redis dies later,
quota_cache::watchlogs and stops refreshing; profiles already loaded stay in memory (crates/store/src/quota_cache.rs:44-52).