Skip to content

Latest commit

 

History

History
409 lines (349 loc) · 22.3 KB

ARCHITECTURE.md

File metadata and controls

409 lines (349 loc) · 22.3 KB

Architecture

How a request travels from an MCP client to packets on the wire, and where each limit is enforced.

Workspace map

/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

Request flow

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
Loading

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).

Server module walk-through

src/main.rs — wiring

  • Loads config.toml (or $MCP_CONFIG) and exits with code 2 on a configuration error (src/main.rs:38-44).
  • Builds one Authenticator from [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_key or 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 SessionManager has RPITIT associated types: LocalSessionManager for session_mode = true, NeverSessionManager for the stateless default (src/main.rs:126-143).

src/server.rs — the MCP handler

list_tools publishes the enabled tools filtered by the principal's allow/deny lists (src/server.rs:97-121). call_tool:

  1. Resolves the tool and refuses unpermitted names without revealing whether an unpermitted tool exists (src/server.rs:130-137).
  2. 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 of min([store].cluster_queue_wait_secs, max_tool_call_secs); only profiles with queue_when_busy wait (src/server.rs:156-193).
  3. Charges the IP budget from the argument-derived address count (src/server.rs:195-198).
  4. Takes the TOML/limiter ticket (src/server.rs:200-215).
  5. Wires a progress channel if the client supplied a progress token (src/server.rs:217-242).
  6. Runs the tool, then completes the cluster permit and bills the returned bytes (src/server.rs:244-263).

src/auth.rs

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).

src/limits.rs

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.

src/tools/mod.rs and registry.rs

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).

Store backends

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:invalidate bus 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 (default admin-store.json) holds users (argon2id password hashes), secrets (argon2 hashes) and quota profiles; every mutation rewrites it atomically via temp-file + rename, with 0600 permissions (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 internals (crates/zscan)

TX: one ring socket per worker

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_ALL and sets PACKET_IGNORE_OUTGOING so 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 sets PACKET_QDISC_BYPASS unless ZSCAN_QDISC_BYPASS=0 (crates/zscan/src/io/ring.rs:122-133). Binding ETH_P_ALL on TX would tag injected frames 0x0003 and 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 == 0 holds) × 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 control

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_bps likewise (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).

RX: classification and cookie validation

A single RX task drains the ring and, for every frame (crates/zscan/src/engine.rs:224-431):

  1. Parses the frame; unparseable frames are dropped (crates/zscan/src/engine.rs:257).
  2. Drops any source address present in the exclusion index (crates/zscan/src/engine.rs:259).
  3. 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).
  4. 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).

Target order: the Z/p permutation walk

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 and progress

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.

Quota layers and how they combine

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).

Failure and lifecycle notes

  • The MCP listener accepts SIGINT or SIGTERM and then drains, but the drain timeout is hard-coded by axum's default: server.graceful_shutdown_secs is 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_uri the 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::watch logs and stops refreshing; profiles already loaded stay in memory (crates/store/src/quota_cache.rs:44-52).