diff --git a/deploy.sh b/deploy.sh index b02b883..b3eb444 100644 --- a/deploy.sh +++ b/deploy.sh @@ -35,6 +35,11 @@ ENABLED_EXTENSION_MANIFESTS="$LOCAL_BOOTSTRAP_MANIFEST" # §3.6 and anygpt-42. When set to 1 the prod-host install path passes # USE_AF_XDP=1 to make and rejects a cached AF_PACKET-only binary. ANYSCAN_USE_AF_XDP="${ANYSCAN_USE_AF_XDP:-0}" +# Build-time DPDK opt-in. Mirrors ANYSCAN_USE_AF_XDP / ANYSCAN_USE_PFRING_ZC. +# When 1, deploy.sh forwards `USE_DPDK=1` to make and rejects a cached +# non-DPDK binary the same way it does for the AF_XDP cache path. +# See plans/2026-04-28-portscan-dpdk-impl-v1.md §3.10.3. +ANYSCAN_USE_DPDK="${ANYSCAN_USE_DPDK:-0}" # Build-time PF_RING ZC opt-in (anygpt-46). Same shape as # ANYSCAN_USE_AF_XDP but probes for libpfring linkage and forwards # USE_PFRING_ZC=1 to make. PF_RING ZC requires a commercial ntop license @@ -126,6 +131,22 @@ binary_has_afxdp_linkage() { return 1 } +binary_has_dpdk_linkage() { + local bin="$1" + [ -x "$bin" ] || return 1 + if command -v ldd >/dev/null 2>&1; then + if ldd "$bin" 2>/dev/null | grep -q 'librte_eal\.so'; then + return 0 + fi + fi + if command -v readelf >/dev/null 2>&1; then + if readelf -d "$bin" 2>/dev/null | grep -E '\(NEEDED\)' | grep -q 'librte_eal\.so'; then + return 0 + fi + fi + return 1 +} + binary_has_pfring_zc_linkage() { local bin="$1" [ -x "$bin" ] || return 1 @@ -255,6 +276,9 @@ install_vulnscanner_binary() { if [ "${ANYSCAN_USE_PFRING_ZC:-0}" = "1" ]; then make_args+=("USE_PFRING_ZC=1") fi + if [ "${ANYSCAN_USE_DPDK:-0}" = "1" ]; then + make_args+=("USE_DPDK=1") + fi # When AF_XDP is requested but the cached source binary lacks libxdp # linkage, drop it so the build branch below fires. anygpt-42: the # previous logic short-circuited on the existence of a stale @@ -283,6 +307,18 @@ install_vulnscanner_binary() { make -C "$VULNSCANNER_SOURCE_DIR" clean >/dev/null 2>&1 || true fi fi + # Same logic for DPDK: drop a non-DPDK cached binary so the build path + # with USE_DPDK=1 fires. plans/2026-04-28-portscan-dpdk-impl-v1.md §3.10.3. + if [ "${ANYSCAN_USE_DPDK:-0}" = "1" ] \ + && [ -x "$VULNSCANNER_SOURCE_BIN" ] \ + && ! binary_has_dpdk_linkage "$VULNSCANNER_SOURCE_BIN"; then + printf '[*] Removing pre-DPDK scanner at %s so the build path with USE_DPDK=1 fires.\n' \ + "$VULNSCANNER_SOURCE_BIN" + rm -f "$VULNSCANNER_SOURCE_BIN" + if [ -f "$VULNSCANNER_SOURCE_DIR/Makefile" ] && command -v make >/dev/null 2>&1; then + make -C "$VULNSCANNER_SOURCE_DIR" clean >/dev/null 2>&1 || true + fi + fi if [ -x "$VULNSCANNER_SOURCE_BIN" ]; then source_bin="$VULNSCANNER_SOURCE_BIN" printf '[*] Installing VulnScanner binary from %s...\n' "$source_bin" @@ -319,6 +355,12 @@ install_vulnscanner_binary() { return 1 fi + if [ "${ANYSCAN_USE_DPDK:-0}" = "1" ] && ! binary_has_dpdk_linkage "$source_bin"; then + printf '[!] ANYSCAN_USE_DPDK=1 but %s does not link librte_eal.so. Install libdpdk-dev and re-run.\n' \ + "$source_bin" >&2 + return 1 + fi + install -m 0755 "$source_bin" "$VULNSCANNER_INSTALL_BIN" return 0 } diff --git a/install-external-deps.sh b/install-external-deps.sh index b77efac..a974ca4 100755 --- a/install-external-deps.sh +++ b/install-external-deps.sh @@ -44,6 +44,23 @@ ANYSCAN_USE_AF_XDP="${ANYSCAN_USE_AF_XDP:-0}" # the runtime-side gating knob ANYSCAN_PFRING_ZC_AVAILABLE. ANYSCAN_USE_PFRING_ZC="${ANYSCAN_USE_PFRING_ZC:-0}" +# Build-time DPDK opt-in. Mirrors ANYSCAN_USE_AF_XDP / ANYSCAN_USE_PFRING_ZC. +# When 1 the engine make is invoked with `USE_DPDK=1` so the scanner gets +# librte_eal + librte_ethdev + librte_mbuf + librte_net_ena linked in and the +# io_engine_dpdk vtable in src/engine.c is reachable from pick_io_engine(). +# Without this flag, --io-engine=dpdk fails at parse time with +# "binary not built with USE_DPDK=1". +# +# DPDK additionally requires HOST setup the apt-get install does NOT cover — +# hugepages reserved + the target NIC bound to vfio-pci. Those are owned by +# tools/setup-dpdk.sh (idempotent + reversible). install-worker-bundle.sh's +# probe_dpdk_runtime_available checks both at runtime so an in-place upgrade +# that flipped USE_DPDK=1 but never ran the host-setup script gets +# ANYSCAN_DPDK_AVAILABLE=false and the adapter falls back to af_packet. +# +# See plans/2026-04-28-portscan-dpdk-impl-v1.md §3.10 for the full wire-up. +ANYSCAN_USE_DPDK="${ANYSCAN_USE_DPDK:-0}" + # Opt-in kernel backport upgrade. Default 0 leaves the running kernel # untouched (existing AMIs unchanged). Setting 1 installs a Debian # backports kernel image so the host can run kernel 6.16+ with the @@ -155,6 +172,29 @@ binary_has_pfring_zc_linkage() { return 1 } +# True when the existing scanner binary was linked against librte_eal at +# build time. The DPDK build path (USE_DPDK=1) pulls in libdpdk via +# pkg-config which produces ~50 -lrte_* link flags; we probe for librte_eal +# specifically because every DPDK-built binary links it (it's the EAL core +# library) and PMD-only / mempool-only DPDK applications still need it. +# Same ldd → readelf -d fallback shape as binary_has_afxdp_linkage so the +# check works on hosts that strip glibc. +binary_has_dpdk_linkage() { + local bin="$1" + [ -x "$bin" ] || return 1 + if command -v ldd >/dev/null 2>&1; then + if ldd "$bin" 2>/dev/null | grep -q 'librte_eal\.so'; then + return 0 + fi + fi + if command -v readelf >/dev/null 2>&1; then + if readelf -d "$bin" 2>/dev/null | grep -E '\(NEEDED\)' | grep -q 'librte_eal\.so'; then + return 0 + fi + fi + return 1 +} + # Resolve the make argv once so install/bundle/deploy paths produce # byte-identical invocations and the unit tests in # tools/test-install-external-deps-{afxdp,pfring-zc}.sh can assert the @@ -167,6 +207,9 @@ vulnscanner_make_args() { if [ "${ANYSCAN_USE_PFRING_ZC:-0}" = "1" ]; then printf 'USE_PFRING_ZC=1\n' fi + if [ "${ANYSCAN_USE_DPDK:-0}" = "1" ]; then + printf 'USE_DPDK=1\n' + fi } # Lexicographic numeric compare of two `.` version strings. @@ -421,6 +464,59 @@ install_pfring_zc_build_deps() { fi } +# Install build-time dependencies for the DPDK I/O path the scanner gains +# under USE_DPDK=1 in the engine Makefile (-lrte_eal -lrte_ethdev -lrte_mbuf +# etc, pulled in via `pkg-config --libs libdpdk`). libdpdk-dev is in main on +# Debian bookworm/trixie + Ubuntu 24.04 noble. Same fail-open semantics as +# install_afxdp_build_deps: skip if apt-get missing, skip if no privilege, +# skip if sudo would prompt. The default `make` does not need these +# packages — only `make USE_DPDK=1` does — so failure to install just +# means USE_DPDK=1 builds will fail loudly later, which is the correct +# escalation rather than silently producing a non-DPDK binary. +# +# DPDK additionally requires HOST setup (hugepages + vfio-pci binding) +# that this function does NOT do — that lives in tools/setup-dpdk.sh and +# is the install-time, not build-time, prerequisite. The split exists +# because hugepages reservation modifies system memory pressure and +# binding NICs to vfio-pci removes them from kernel networking; both +# need an explicit operator action, not an apt-get side-effect. +# +# Set ANYSCAN_INSTALL_DPDK_DEPS=false to suppress this block (e.g. on +# AMIs where the operator pre-pinned a different libdpdk version). +install_dpdk_build_deps() { + if [ "${ANYSCAN_INSTALL_DPDK_DEPS:-true}" != "true" ]; then + return 0 + fi + if ! command -v apt-get >/dev/null 2>&1; then + printf '[*] Skipping DPDK build deps: apt-get not on PATH (non-Debian host).\n' + return 0 + fi + local apt_cmd=() + if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then + apt_cmd=(apt-get) + elif command -v sudo >/dev/null 2>&1; then + apt_cmd=(sudo -n apt-get) + else + printf '[*] Skipping DPDK build deps: not root and sudo is not available.\n' + printf ' Install manually if you plan to build the scanner with USE_DPDK=1:\n' + printf ' sudo apt-get install -y libdpdk-dev dpdk\n' + return 0 + fi + if [ "${apt_cmd[0]}" = "sudo" ] && ! sudo -n true >/dev/null 2>&1; then + printf '[*] Skipping DPDK build deps: sudo would prompt for a password.\n' + printf ' Install manually if you plan to build the scanner with USE_DPDK=1:\n' + printf ' sudo apt-get install -y libdpdk-dev dpdk\n' + return 0 + fi + printf '[*] Installing DPDK build deps (libdpdk-dev dpdk)...\n' + if ! "${apt_cmd[@]}" install -y --no-install-recommends \ + libdpdk-dev dpdk >/dev/null 2>&1; then + printf '[!] apt-get install of DPDK build deps failed; the scanner will still build with default `make`.\n' >&2 + printf ' Re-run with USE_DPDK=1 only after libdpdk-dev is present.\n' >&2 + return 0 + fi +} + upsert_env_value() { local key="$1" local value="$2" @@ -453,6 +549,7 @@ fi install_afxdp_build_deps install_pfring_zc_build_deps +install_dpdk_build_deps install_kernel_backport_if_requested if [ -d "$VULNSCANNER_REPO_DIR/.git" ]; then @@ -491,6 +588,18 @@ elif [ "$ANYSCAN_USE_PFRING_ZC" = "1" ] && ! binary_has_pfring_zc_linkage "$VULN fi rm -f "$VULNSCANNER_BIN_PATH" need_build=1 +elif [ "$ANYSCAN_USE_DPDK" = "1" ] && ! binary_has_dpdk_linkage "$VULNSCANNER_BIN_PATH"; then + # Same shape as the AF_XDP / PF_RING cache checks: force clean + # rebuild when the cached binary lacks librte_eal linkage. Without + # this the cache short-circuit above would keep shipping a non-DPDK + # binary and --io-engine=dpdk would error at parse time with + # "binary not built with USE_DPDK=1". + printf '[*] Existing scanner at %s lacks librte_eal linkage; forcing rebuild because ANYSCAN_USE_DPDK=1.\n' "$VULNSCANNER_BIN_PATH" + if [ -f "$VULNSCANNER_REPO_DIR/Makefile" ] && command -v make >/dev/null 2>&1; then + make -C "$VULNSCANNER_REPO_DIR" clean >/dev/null 2>&1 || true + fi + rm -f "$VULNSCANNER_BIN_PATH" + need_build=1 fi if [ "$need_build" = "1" ]; then @@ -526,6 +635,12 @@ if [ "$ANYSCAN_USE_PFRING_ZC" = "1" ] && ! binary_has_pfring_zc_linkage "$VULNSC exit 1 fi +if [ "$ANYSCAN_USE_DPDK" = "1" ] && ! binary_has_dpdk_linkage "$VULNSCANNER_BIN_PATH"; then + printf '[!] ANYSCAN_USE_DPDK=1 but %s does not link librte_eal.so. Build deps were probably missing — install libdpdk-dev and re-run.\n' \ + "$VULNSCANNER_BIN_PATH" >&2 + exit 1 +fi + mkdir -p "$(dirname "$LOCAL_ENV_FILE")" "$LOCAL_BOOTSTRAP_ARTIFACT_DIR" printf '[*] Writing repo-local AnyScan env snippet to %s...\n' "$LOCAL_ENV_FILE" touch "$LOCAL_ENV_FILE" diff --git a/install-worker-bundle.sh b/install-worker-bundle.sh index 49b5cf1..0158c15 100755 --- a/install-worker-bundle.sh +++ b/install-worker-bundle.sh @@ -421,6 +421,110 @@ apply_afxdp_availability() { fi } +# True when the installed scanner binary at $1 was linked against librte_eal +# at build time (i.e. compiled with USE_DPDK=1). Mirrors +# binary_has_afxdp_linkage / binary_has_pfring_zc_linkage; same ldd → +# readelf -d fallback so the check works on stripped or static glibc hosts. +binary_has_dpdk_linkage() { + local bin="$1" + [ -x "$bin" ] || return 1 + if command_exists ldd; then + if ldd "$bin" 2>/dev/null | grep -q 'librte_eal\.so'; then + return 0 + fi + fi + if command_exists readelf; then + if readelf -d "$bin" 2>/dev/null | grep -E '\(NEEDED\)' | grep -q 'librte_eal\.so'; then + return 0 + fi + fi + return 1 +} + +probe_dpdk_runtime_available() { + # Phase 2 of plans/2026-04-28-portscan-dpdk-impl-v1.md §4.3. The bundled + # scanner can be invoked with --io-engine=dpdk only when ALL of: + # (a) the installed scanner binary at $VULNSCANNER_BIN_DEST was built + # with USE_DPDK=1 (probed by checking librte_eal linkage on the + # on-disk binary). Mirrors the (c) gate the PR #75 review added + # to probe_pfring_zc_runtime_available. + # (b) librte_eal.so is loadable on the host (the scanner is dynamically + # linked against it; without the runtime libs the scanner crashes + # at startup with a dlopen error). + # (c) the vfio-pci kernel module is loaded (the EAL bring-up calls + # rte_eal_init which probes vfio-pci-bound devices; without the + # module no DPDK port is reachable). + # (d) at least one hugepage is reserved (the DPDK mempool needs + # hugepage-backed memory for the mbuf pool — no hugepages → + # rte_pktmbuf_pool_create fails). We probe both 2 MiB and 1 GiB + # hugepage pools because tools/setup-dpdk.sh prefers 1 GiB when + # available and falls back to 2 MiB. + # (e) at least one NIC is bound to vfio-pci. We don't probe this + # directly because dpdk-devbind.py may not be on PATH yet at + # install time; instead we check that a vfio control device + # (/dev/vfio/vfio) exists, which is the kernel-side prerequisite + # that vfio-pci's binding step would have created. + # If any check fails ANYSCAN_DPDK_AVAILABLE=false and the adapter falls + # back to af_packet — no silent failure modes. + if ! binary_has_dpdk_linkage "$VULNSCANNER_BIN_DEST"; then + printf 'false' + return 0 + fi + if ! command_exists ldconfig; then + printf 'false' + return 0 + fi + if ! ldconfig -p 2>/dev/null | grep -q '\ 0. Matches both 2 MiB + # and 1 GiB pages without hard-coding which one is expected. + local hugepages_total=0 + if [ -d /sys/kernel/mm/hugepages ]; then + local hp_dir count + for hp_dir in /sys/kernel/mm/hugepages/hugepages-*kB; do + [ -e "$hp_dir/nr_hugepages" ] || continue + count="$(cat "$hp_dir/nr_hugepages" 2>/dev/null || echo 0)" + if [[ "$count" =~ ^[0-9]+$ ]]; then + hugepages_total=$(( hugepages_total + count )) + fi + done + fi + if [ "$hugepages_total" -le 0 ]; then + printf 'false' + return 0 + fi + if [ ! -e /dev/vfio/vfio ]; then + # The vfio control char device is created by the vfio-pci module + # when at least one device has been bound. If it's missing, no NIC + # has been bound yet — operator must run tools/setup-dpdk.sh bind. + printf 'false' + return 0 + fi + printf 'true' +} + +apply_dpdk_availability() { + # Mirror of apply_afxdp_availability / apply_pfring_zc_availability: + # always write the flag so /etc/agentd/runtime.env carries an explicit + # value and a partial upgrade can't leave a stale "true" in place + # after vfio-pci was unloaded or hugepages were freed. + local dpdk_available + dpdk_available="$(probe_dpdk_runtime_available)" + upsert_env_value "ANYSCAN_DPDK_AVAILABLE" "$dpdk_available" "$RUNTIME_ENV_FILE" + if [ "$dpdk_available" = "true" ]; then + printf '[*] DPDK runtime probe passed (binary + librte_eal.so + vfio_pci + hugepages + /dev/vfio); ANYSCAN_DPDK_AVAILABLE=true.\n' + else + printf '[*] DPDK runtime probe failed (binary not librte_eal-linked, librte_eal.so missing, vfio_pci unloaded, no hugepages reserved, or /dev/vfio absent); ANYSCAN_DPDK_AVAILABLE=false. Run tools/setup-dpdk.sh bind on the host to fix the runtime side.\n' + fi +} + # True when the installed scanner binary at $1 was linked against # libpfring at build time (i.e. compiled with USE_PFRING_ZC=1). Same # probe shape as binary_has_pfring_zc_linkage in install-external-deps.sh @@ -875,6 +979,7 @@ main() { apply_host_resource_defaults "$cpu_threads" apply_afxdp_availability apply_pfring_zc_availability + apply_dpdk_availability apply_scanner_host_tunings if [ "$existing_install" = "true" ]; then diff --git a/package-worker-bundle.sh b/package-worker-bundle.sh index e2b0b7d..0c7874d 100755 --- a/package-worker-bundle.sh +++ b/package-worker-bundle.sh @@ -40,6 +40,12 @@ ANYSCAN_USE_AF_XDP="${ANYSCAN_USE_AF_XDP:-0}" # See install-external-deps.sh for license obligation notes (PF_RING ZC # requires a commercial ntop license at runtime). ANYSCAN_USE_PFRING_ZC="${ANYSCAN_USE_PFRING_ZC:-0}" +# Build-time DPDK opt-in (mirrors ANYSCAN_USE_AF_XDP / ANYSCAN_USE_PFRING_ZC). +# When 1 and the staged scanner binary does not link librte_eal, the bundle +# script rebuilds the engine with `make USE_DPDK=1` so the bundle ships a +# DPDK-capable scanner. Default 0 keeps existing bundle CI green. +# Source: plans/2026-04-28-portscan-dpdk-impl-v1.md §3.10.2. +ANYSCAN_USE_DPDK="${ANYSCAN_USE_DPDK:-0}" ANYSCAN_VULNSCANNER_REPO_DIR_DEFAULT="$SCRIPT_DIR/../../anyscan-engine-c" ANYSCAN_VULNSCANNER_REPO_DIR="${ANYSCAN_VULNSCANNER_REPO_DIR:-$ANYSCAN_VULNSCANNER_REPO_DIR_DEFAULT}" @@ -369,6 +375,26 @@ binary_has_pfring_zc_linkage() { return 1 } +# Mirror of binary_has_dpdk_linkage in install-external-deps.sh — kept inline +# so this script remains stand-alone for CI. librte_eal.so is the canonical +# probe target: every USE_DPDK=1 build links it (it's the EAL core), and +# legacy AF_PACKET-only builds never do. +binary_has_dpdk_linkage() { + local bin="$1" + [ -x "$bin" ] || return 1 + if command_exists ldd; then + if ldd "$bin" 2>/dev/null | grep -q 'librte_eal\.so'; then + return 0 + fi + fi + if command_exists readelf; then + if readelf -d "$bin" 2>/dev/null | grep -E '\(NEEDED\)' | grep -q 'librte_eal\.so'; then + return 0 + fi + fi + return 1 +} + # Resolve the engine make argv so cached invocations match # install-external-deps.sh::vulnscanner_make_args byte-for-byte. Stays # empty when both build flags are 0; otherwise emits one token per line. @@ -379,6 +405,9 @@ bundle_engine_make_args() { if [ "${ANYSCAN_USE_PFRING_ZC:-0}" = "1" ]; then printf 'USE_PFRING_ZC=1\n' fi + if [ "${ANYSCAN_USE_DPDK:-0}" = "1" ]; then + printf 'USE_DPDK=1\n' + fi } # Fire a `make USE_AF_XDP=1` in the engine repo so the bundle ships an @@ -426,6 +455,29 @@ rebuild_scanner_with_pfring_zc() { make -C "$repo_dir" "${make_args[@]}" } +# Fire a `make USE_DPDK=1` (plus any other USE_* opt-ins requested) so the +# bundle ships a librte_eal-linked scanner. Mirrors rebuild_scanner_with_afxdp +# and rebuild_scanner_with_pfring_zc; all three funnel through +# bundle_engine_make_args so multi-engine builds (USE_AF_XDP=1 USE_DPDK=1) +# produce a single binary that can dispatch either engine at runtime. +rebuild_scanner_with_dpdk() { + local repo_dir="$1" + if [ ! -f "$repo_dir/Makefile" ]; then + printf '[!] ANYSCAN_USE_DPDK=1 requested but no Makefile at %s.\n' "$repo_dir" >&2 + printf ' Run install-external-deps.sh ANYSCAN_USE_DPDK=1 first, or set ANYSCAN_PACKAGE_VULNSCANNER_BIN to a pre-built DPDK-capable scanner binary.\n' >&2 + return 1 + fi + if ! command_exists make; then + printf '[!] make not on PATH; cannot rebuild scanner with USE_DPDK=1.\n' >&2 + return 1 + fi + # shellcheck disable=SC2046 + local make_args=( $(bundle_engine_make_args) ) + printf '[*] Rebuilding scanner in %s with %s...\n' "$repo_dir" "${make_args[*]}" + make -C "$repo_dir" clean >/dev/null 2>&1 || true + make -C "$repo_dir" "${make_args[@]}" +} + copy_runtime_file() { local source="$1" local dest_dir="$2" @@ -702,6 +754,35 @@ main() { exit 1 fi fi + # DPDK wire-up (plans/2026-04-28-portscan-dpdk-impl-v1.md §3.10.2): + # same shape as AF_XDP. When ANYSCAN_USE_DPDK=1 and the candidate + # binary lacks librte_eal linkage, fire `make USE_DPDK=1` so the + # bundled scanner has the io_engine_dpdk vtable wired in. Composes + # with AF_XDP and PF_RING — when multiple flags are 1, the earliest + # rebuild block produced a binary linked with all the requested + # engines (bundle_engine_make_args emits all tokens) so this branch + # becomes a no-op linkage check. + if [ "${ANYSCAN_USE_DPDK:-0}" = "1" ]; then + local needs_dpdk_rebuild=0 + if [ -z "$SCANNER_SOURCE_BIN" ] || [ ! -x "$SCANNER_SOURCE_BIN" ]; then + needs_dpdk_rebuild=1 + elif ! binary_has_dpdk_linkage "$SCANNER_SOURCE_BIN"; then + printf '[*] %s lacks librte_eal linkage; ANYSCAN_USE_DPDK=1 requires a rebuild.\n' "$SCANNER_SOURCE_BIN" + needs_dpdk_rebuild=1 + fi + if [ "$needs_dpdk_rebuild" = "1" ]; then + if ! rebuild_scanner_with_dpdk "$ANYSCAN_VULNSCANNER_REPO_DIR"; then + printf '[!] ANYSCAN_USE_DPDK=1 but unable to produce a DPDK-linked scanner. Aborting bundle.\n' >&2 + exit 1 + fi + SCANNER_SOURCE_BIN="$ANYSCAN_VULNSCANNER_REPO_DIR/scanner" + fi + if [ ! -x "$SCANNER_SOURCE_BIN" ] || ! binary_has_dpdk_linkage "$SCANNER_SOURCE_BIN"; then + printf '[!] Scanner at %s still lacks librte_eal linkage after rebuild. Aborting bundle.\n' \ + "$SCANNER_SOURCE_BIN" >&2 + exit 1 + fi + fi include_scanner="false" if [ -n "$SCANNER_SOURCE_BIN" ] && [ -x "$SCANNER_SOURCE_BIN" ]; then printf '[*] Including scanner binary from %s...\n' "$SCANNER_SOURCE_BIN" @@ -844,6 +925,7 @@ Bundle scanner build: scanner_included: ${include_scanner} use_af_xdp: ${ANYSCAN_USE_AF_XDP} use_pfring_zc: ${ANYSCAN_USE_PFRING_ZC} + use_dpdk: ${ANYSCAN_USE_DPDK} install_kernel_backport: ${ANYSCAN_INSTALL_KERNEL_BACKPORT} EOF diff --git a/runtime.worker.env.template b/runtime.worker.env.template index 4655897..fcb5156 100644 --- a/runtime.worker.env.template +++ b/runtime.worker.env.template @@ -132,11 +132,11 @@ POLL_INTERVAL_SECONDS=15 # ANYSCAN_RESERVE_TOR_PORTS=9001,9030,9101 # Scanner I/O engine. Phase 2 PR D of plans/2026-04-27-portscan-afxdp-plan-v1.md -# §3.7. The bundled scanner exposes --io-engine={af_packet,af_xdp,pfring_zc} -# (anygpt-46 added the pfring_zc value); the adapter reads this knob and -# forwards it as the flag value. AF_PACKET is the unconditional default -# and the unconditional fallback. AF_XDP is opt-in per worker and is only -# honored when: +# §3.7 added af_xdp; plans/2026-04-28-portscan-dpdk-impl-v1.md §3.10 adds dpdk. +# The bundled scanner exposes --io-engine={af_packet,af_xdp,pfring_zc,dpdk}; +# the adapter reads this knob and forwards it as the flag value. AF_PACKET +# is the unconditional default and the unconditional fallback. AF_XDP is +# opt-in per worker and is only honored when: # 1. ANYSCAN_AF_XDP_AVAILABLE=true, which install-worker-bundle.sh writes # after probing kernel >=5.10 + libxdp.so loadable; AND # 2. The systemd unit grants CAP_BPF (anyscan-worker.service / @@ -205,6 +205,55 @@ POLL_INTERVAL_SECONDS=15 # refuses to forward the flag unless this is true. # ANYSCAN_PFRING_ZC_AVAILABLE=false +# DPDK userspace-networking I/O engine. Phase 2 of +# plans/2026-04-28-portscan-dpdk-impl-v1.md. DPDK is opt-in per worker and +# is only honored when: +# 1. ANYSCAN_DPDK_AVAILABLE=true, which install-worker-bundle.sh writes +# after probing librte_eal.so loadable + scanner USE_DPDK-built + +# vfio_pci kernel module loaded + hugepages reserved at +# /sys/kernel/mm/hugepages/* + /dev/vfio/vfio present; AND +# 2. tools/setup-dpdk.sh has been run successfully on the host, binding +# the listed ENIs to vfio-pci and reserving hugepages. +# 3. The systemd unit grants CAP_SYS_RAWIO + CAP_IPC_LOCK + CAP_NET_ADMIN +# (Phase 2 systemd-unit edit; until that lands operators must add +# these caps manually before flipping the runtime knob). +# When ANYSCAN_SCANNER_IO_ENGINE=dpdk is requested but ANYSCAN_DPDK_AVAILABLE +# is false, the adapter logs a warning to stderr and falls back to +# af_packet so the scanner does not crash at startup with a dlopen / EAL +# init error. +# +# Build-time DPDK opt-in (read by install-external-deps.sh, +# package-worker-bundle.sh, deploy.sh — NOT by the agentd runtime). The +# runtime knobs above only matter when the scanner C source was actually +# compiled with `make USE_DPDK=1`; without that, --io-engine=dpdk has no +# DPDK code linked and the scanner falls back at parse time. Keep at 0 +# to stay on the AF_PACKET-only build that has shipped historically. +# ANYSCAN_USE_DPDK=0 +# +# Runtime DPDK availability flag (written by install-worker-bundle.sh at +# install time; safe for operators to override only when they know the +# probe is wrong, e.g. when /dev/vfio/vfio appears after the install +# probe ran). Defaults to false; when ANYSCAN_SCANNER_IO_ENGINE=dpdk the +# adapter refuses to forward the flag unless this is true. +# ANYSCAN_DPDK_AVAILABLE=false +# +# DPDK NIC binding. Comma-separated PCI BDFs (e.g. "0000:00:06.0,0000:00:07.0") +# OR comma-separated kernel iface names (e.g. "eth1,eth2"); tools/setup-dpdk.sh +# resolves iface names to BDFs at bind time. tools/setup-dpdk.sh refuses to +# bind eth0 (the agentd control-plane interface) and refuses to bind the +# only NIC in a single-NIC instance, so eth0 always retains kernel +# networking. On c6in.metal with eth0..eth7, set this to eth1..eth7 (or +# their BDFs) — eth0 stays kernel-bound for agentd heartbeat. +# ANYSCAN_DPDK_PCI_BDFS= +# +# DPDK hugepages reservation in GiB. Default 4. c6in.metal has 192 GiB so +# 4 GiB is a rounding error; smaller instance shapes may want less. The +# install-time probe asserts at least 1 GiB worth of hugepages are +# reserved before marking DPDK available, but the actual reservation is +# done by tools/setup-dpdk.sh on the host (sysctl vm.nr_hugepages writes +# require root, so this script keeps that as an explicit operator step). +# ANYSCAN_DPDK_HUGEPAGES_GB=4 + # Opt-in kernel backport upgrade (read by install-external-deps.sh, # package-worker-bundle.sh, deploy.sh — NOT by the agentd runtime). # Set 1 to install the Debian bookworm-backports kernel image diff --git a/test_vulnscanner_adapter_io_engine.py b/test_vulnscanner_adapter_io_engine.py index 70e3136..79f49a8 100644 --- a/test_vulnscanner_adapter_io_engine.py +++ b/test_vulnscanner_adapter_io_engine.py @@ -49,6 +49,7 @@ def _load_adapter(): "ANYSCAN_SCANNER_IO_ENGINE", "ANYSCAN_AF_XDP_AVAILABLE", "ANYSCAN_PFRING_ZC_AVAILABLE", + "ANYSCAN_DPDK_AVAILABLE", ) @@ -121,11 +122,16 @@ def test_af_xdp_without_availability_var_falls_back(self) -> None: self.assertIn("ANYSCAN_AF_XDP_AVAILABLE", captured.getvalue()) def test_invalid_value_falls_back_to_af_packet_with_warning(self) -> None: - os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "dpdk" + # Use a value that is NOT in SUPPORTED_IO_ENGINES. dpdk used to be + # the canonical "invalid" placeholder here; once the dpdk plan + # landed it became a valid engine name, so this test has to use a + # different unrecognized value. "fake_engine" is unlikely to ever + # be promoted to a real engine. + os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "fake_engine" captured = io.StringIO() with contextlib.redirect_stderr(captured): self.assertEqual(adapter.resolve_io_engine(), "af_packet") - self.assertIn("dpdk", captured.getvalue()) + self.assertIn("fake_engine", captured.getvalue()) def test_pfring_zc_with_runtime_available(self) -> None: os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "pfring_zc" @@ -165,6 +171,50 @@ def test_pfring_zc_does_not_consult_af_xdp_available(self) -> None: os.environ["ANYSCAN_AF_XDP_AVAILABLE"] = "false" self.assertEqual(adapter.resolve_io_engine(), "pfring_zc") + def test_dpdk_with_runtime_available(self) -> None: + os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "dpdk" + os.environ["ANYSCAN_DPDK_AVAILABLE"] = "true" + captured = io.StringIO() + with contextlib.redirect_stderr(captured): + self.assertEqual(adapter.resolve_io_engine(), "dpdk") + self.assertEqual(captured.getvalue(), "") + + def test_dpdk_request_uppercase_normalizes(self) -> None: + os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "DPDK" + os.environ["ANYSCAN_DPDK_AVAILABLE"] = "true" + self.assertEqual(adapter.resolve_io_engine(), "dpdk") + + def test_dpdk_with_unavailable_runtime_falls_back_with_warning(self) -> None: + os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "dpdk" + os.environ["ANYSCAN_DPDK_AVAILABLE"] = "false" + captured = io.StringIO() + with contextlib.redirect_stderr(captured): + self.assertEqual(adapter.resolve_io_engine(), "af_packet") + message = captured.getvalue() + self.assertIn("dpdk", message) + self.assertIn("ANYSCAN_DPDK_AVAILABLE", message) + + def test_dpdk_without_availability_var_falls_back(self) -> None: + # Missing ANYSCAN_DPDK_AVAILABLE behaves the same as false: the + # installer always writes the value (true OR false), so a missing + # key implies an old install where the DPDK probe never ran. + # Be conservative. + os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "dpdk" + captured = io.StringIO() + with contextlib.redirect_stderr(captured): + self.assertEqual(adapter.resolve_io_engine(), "af_packet") + self.assertIn("ANYSCAN_DPDK_AVAILABLE", captured.getvalue()) + + def test_dpdk_does_not_consult_other_availability_flags(self) -> None: + # Cross-engine availability flags must not interfere: AF_XDP and + # PF_RING ZC being unavailable should have zero effect on a dpdk + # request when ANYSCAN_DPDK_AVAILABLE=true. + os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "dpdk" + os.environ["ANYSCAN_DPDK_AVAILABLE"] = "true" + os.environ["ANYSCAN_AF_XDP_AVAILABLE"] = "false" + os.environ["ANYSCAN_PFRING_ZC_AVAILABLE"] = "false" + self.assertEqual(adapter.resolve_io_engine(), "dpdk") + def test_blank_value_defaults_to_af_packet_silently(self) -> None: os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "" captured = io.StringIO() @@ -231,6 +281,23 @@ def test_pfring_zc_request_with_runtime_available_appends_pfring_zc(self) -> Non self.assertIn("--io-engine=pfring_zc", cmd) self.assertNotIn("--io-engine=af_packet", cmd) + def test_dpdk_request_with_runtime_available_appends_dpdk(self) -> None: + os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "dpdk" + os.environ["ANYSCAN_DPDK_AVAILABLE"] = "true" + cmd = self._build() + self.assertIn("--io-engine=dpdk", cmd) + self.assertNotIn("--io-engine=af_packet", cmd) + + def test_dpdk_request_without_runtime_falls_back_to_af_packet(self) -> None: + os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "dpdk" + os.environ["ANYSCAN_DPDK_AVAILABLE"] = "false" + captured = io.StringIO() + with contextlib.redirect_stderr(captured): + cmd = self._build() + self.assertIn("--io-engine=af_packet", cmd) + self.assertNotIn("--io-engine=dpdk", cmd) + self.assertIn("ANYSCAN_DPDK_AVAILABLE", captured.getvalue()) + def test_pfring_zc_request_without_runtime_falls_back_to_af_packet(self) -> None: os.environ["ANYSCAN_SCANNER_IO_ENGINE"] = "pfring_zc" os.environ["ANYSCAN_PFRING_ZC_AVAILABLE"] = "false" diff --git a/tools/setup-dpdk.sh b/tools/setup-dpdk.sh new file mode 100755 index 0000000..a887c78 --- /dev/null +++ b/tools/setup-dpdk.sh @@ -0,0 +1,428 @@ +#!/usr/bin/env bash +# tools/setup-dpdk.sh — host-side DPDK setup for the AnyScan port scanner. +# +# Phase 2 of plans/2026-04-28-portscan-dpdk-impl-v1.md §3.10.5. +# +# Two subcommands: +# bind — reserve hugepages, load vfio-pci, bind the listed ENIs to +# vfio-pci. Idempotent: re-running on an already-bound system +# is a no-op (logs "already bound" and exits 0). +# unbind — reverse. Returns the listed ENIs to the kernel ENA driver +# and frees hugepages back to the system. Leaves the vfio-pci +# module loaded (other consumers may still need it). +# +# Refusal rules (HARD-CODED, not configurable — these prevent operator +# error from cutting the worker off from the orchestrator): +# 1. Refuses to bind eth0 (the agentd control-plane interface). Even +# if eth0 appears in ANYSCAN_DPDK_PCI_BDFS the script skips it with +# a one-line warning. The whole point of the dedicated-DPDK-NIC +# design (plan §3.11) is keeping kernel networking on at least one +# interface for heartbeat / journal / token refresh. +# 2. Refuses to bind the only NIC. If the resolved NIC list would +# result in zero kernel-networking interfaces remaining, the script +# bails with a clear error pointing at the dedicated-DPDK-NIC +# requirement. Single-NIC instance shapes are NOT eligible for +# DPDK mode in v1. +# +# Inputs (env / argv): +# ANYSCAN_DPDK_PCI_BDFS — comma-separated PCI BDFs (e.g. +# "0000:00:06.0,0000:00:07.0") OR kernel iface names (e.g. +# "eth1,eth2"). Iface names are resolved to BDFs by walking +# /sys/class/net//device. +# ANYSCAN_DPDK_HUGEPAGES_GB — total hugepages reservation in GiB +# (default 4). 1 GiB pages are tried first; falls back to 2 MiB +# pages on systems where 1 GiB pages aren't available. Phase 2 +# micro-bench may adjust this default per instance shape. +# +# Out of scope (handled elsewhere): +# - libdpdk-dev install (install-external-deps.sh::install_dpdk_build_deps). +# - Probing whether the host is "DPDK-ready" +# (install-worker-bundle.sh::probe_dpdk_runtime_available). +# - The actual scanner build flag +# (install-external-deps.sh::vulnscanner_make_args ANYSCAN_USE_DPDK=1). +# - Live bench (separate worker, plan §5.3). + +set -euo pipefail + +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <<'USAGE' +Usage: + setup-dpdk.sh bind [--bdfs=] [--hugepages-gb=] + setup-dpdk.sh unbind [--bdfs=] + setup-dpdk.sh status + +Environment: + ANYSCAN_DPDK_PCI_BDFS CSV of PCI BDFs or iface names (used when --bdfs is omitted) + ANYSCAN_DPDK_HUGEPAGES_GB Hugepages reservation in GiB (default 4) + ANYSCAN_DPDK_DEVBIND dpdk-devbind.py path (auto-detected when unset) + +Refusal rules (hard-coded): + - eth0 is never bound (agentd control-plane interface). + - The only NIC is never bound (would leave the host without kernel networking). +USAGE +} + +ANYSCAN_DPDK_HUGEPAGES_GB="${ANYSCAN_DPDK_HUGEPAGES_GB:-4}" +ANYSCAN_DPDK_PCI_BDFS="${ANYSCAN_DPDK_PCI_BDFS:-}" +ANYSCAN_DPDK_DEVBIND="${ANYSCAN_DPDK_DEVBIND:-}" + +# Resolve dpdk-devbind.py once. The script ships with the `dpdk` package on +# Debian/Ubuntu at /usr/share/dpdk/usertools/dpdk-devbind.py, but source +# builds put it under /share/dpdk/usertools. Operators can override +# via ANYSCAN_DPDK_DEVBIND. +resolve_devbind() { + if [ -n "$ANYSCAN_DPDK_DEVBIND" ] && [ -x "$ANYSCAN_DPDK_DEVBIND" ]; then + printf '%s' "$ANYSCAN_DPDK_DEVBIND" + return + fi + local candidate + for candidate in \ + /usr/share/dpdk/usertools/dpdk-devbind.py \ + /usr/local/share/dpdk/usertools/dpdk-devbind.py \ + /opt/dpdk/usertools/dpdk-devbind.py; do + if [ -x "$candidate" ]; then + printf '%s' "$candidate" + return + fi + done + if command -v dpdk-devbind.py >/dev/null 2>&1; then + command -v dpdk-devbind.py + return + fi + return 1 +} + +# Walk /sys/class/net//device → resolve to a PCI BDF. Returns 0 + +# prints the BDF on success, returns 1 on failure (interface doesn't exist +# or is virtual / non-PCI). +iface_to_bdf() { + local iface="$1" + local sysdev="/sys/class/net/$iface/device" + if [ ! -e "$sysdev" ]; then + return 1 + fi + local resolved + resolved="$(readlink -f "$sysdev" 2>/dev/null || true)" + if [ -z "$resolved" ]; then + return 1 + fi + # /sys/devices/pci0000:00/0000:00:06.0 → 0000:00:06.0 (last component) + basename "$resolved" +} + +# Parse the user-supplied list (BDFs or iface names) into a deduplicated +# list of BDFs. eth0 is silently dropped per the refusal rules. Returns +# the list one BDF per line on stdout. +resolve_bdf_list() { + local raw="$1" + [ -n "$raw" ] || return 0 + local entry resolved + local -A seen=() + IFS=',' read -ra entries <<<"$raw" + for entry in "${entries[@]}"; do + entry="$(printf '%s' "$entry" | tr -d '[:space:]')" + [ -n "$entry" ] || continue + if [ "$entry" = "eth0" ]; then + printf '[!] %s: skipping eth0 (agentd control-plane interface, never bound to vfio-pci).\n' "$SCRIPT_NAME" >&2 + continue + fi + # Looks like a BDF (e.g. 0000:00:06.0)? + if [[ "$entry" =~ ^[0-9a-fA-F]{4}:[0-9a-fA-F]{2}:[0-9a-fA-F]{2}\.[0-9a-fA-F]+$ ]]; then + resolved="$entry" + else + # iface name → BDF + if ! resolved="$(iface_to_bdf "$entry")"; then + printf '[!] %s: could not resolve iface "%s" to a PCI BDF (no /sys/class/net/%s/device); skipping.\n' \ + "$SCRIPT_NAME" "$entry" "$entry" >&2 + continue + fi + fi + # Dedup + if [ -z "${seen[$resolved]:-}" ]; then + seen[$resolved]=1 + printf '%s\n' "$resolved" + fi + done +} + +# Count kernel-networked, IPv4-bearing, non-loopback NICs that AREN'T in +# the vfio-pci-target list. Used to enforce the "never leave the host +# with zero kernel NICs" refusal. +count_remaining_kernel_nics() { + local target_bdfs_csv="$1" + local iface bdf + local count=0 + for iface in /sys/class/net/*; do + [ -d "$iface" ] || continue + local name + name="$(basename "$iface")" + case "$name" in + lo|docker*|br-*|veth*|tun*|tap*|wg*|zt*|cni*|cilium*|flannel*|kube-*) continue ;; + esac + # Must be UP with an IPv4 address. + if ! ip -4 -o addr show dev "$name" 2>/dev/null | grep -q 'inet '; then + continue + fi + bdf="$(iface_to_bdf "$name" 2>/dev/null || true)" + if [ -n "$bdf" ] && [[ ",${target_bdfs_csv}," == *",${bdf},"* ]]; then + # This iface IS being bound to vfio-pci — doesn't count. + continue + fi + count=$(( count + 1 )) + done + printf '%s\n' "$count" +} + +# Reserve hugepages. Tries 1 GiB pages first (lower TLB pressure on +# c6in.metal-class hardware); falls back to 2 MiB pages on hosts where +# 1 GiB pages are unavailable (kernel built without GB-page support, or +# /proc/sys/vm/nr_hugepages_mempolicy is the only knob). Idempotent: +# re-running with the same target reservation is a no-op. +reserve_hugepages() { + local target_gb="$1" + [ "$target_gb" -gt 0 ] || return 0 + local hp1g_dir="/sys/kernel/mm/hugepages/hugepages-1048576kB" + local hp2m_dir="/sys/kernel/mm/hugepages/hugepages-2048kB" + if [ -d "$hp1g_dir" ]; then + local current + current="$(cat "$hp1g_dir/nr_hugepages" 2>/dev/null || echo 0)" + if [ "$current" -ge "$target_gb" ]; then + printf '[*] %s: %s 1 GiB hugepages already reserved (target %s).\n' \ + "$SCRIPT_NAME" "$current" "$target_gb" + return 0 + fi + printf '[*] %s: reserving %s 1 GiB hugepages...\n' "$SCRIPT_NAME" "$target_gb" + if printf '%s\n' "$target_gb" > "$hp1g_dir/nr_hugepages" 2>/dev/null; then + current="$(cat "$hp1g_dir/nr_hugepages" 2>/dev/null || echo 0)" + if [ "$current" -ge "$target_gb" ]; then + printf '[*] %s: 1 GiB hugepages reserved=%s.\n' "$SCRIPT_NAME" "$current" + return 0 + fi + printf '[!] %s: 1 GiB hugepages reservation fell short (got %s, wanted %s); falling back to 2 MiB.\n' \ + "$SCRIPT_NAME" "$current" "$target_gb" >&2 + fi + fi + if [ -d "$hp2m_dir" ]; then + # 2 MiB pages: target_gb GiB → target_gb * 512 pages of 2 MiB. + local target_2m=$(( target_gb * 512 )) + local current + current="$(cat "$hp2m_dir/nr_hugepages" 2>/dev/null || echo 0)" + if [ "$current" -ge "$target_2m" ]; then + printf '[*] %s: %s 2 MiB hugepages already reserved (target %s).\n' \ + "$SCRIPT_NAME" "$current" "$target_2m" + return 0 + fi + printf '[*] %s: reserving %s 2 MiB hugepages...\n' "$SCRIPT_NAME" "$target_2m" + if printf '%s\n' "$target_2m" > "$hp2m_dir/nr_hugepages" 2>/dev/null; then + current="$(cat "$hp2m_dir/nr_hugepages" 2>/dev/null || echo 0)" + if [ "$current" -ge "$target_2m" ]; then + printf '[*] %s: 2 MiB hugepages reserved=%s.\n' "$SCRIPT_NAME" "$current" + return 0 + fi + fi + printf '[!] %s: 2 MiB hugepages reservation also failed.\n' "$SCRIPT_NAME" >&2 + fi + printf '[!] %s: could not reserve any hugepages. Check /proc/meminfo HugePages_Total and free system memory.\n' \ + "$SCRIPT_NAME" >&2 + return 1 +} + +# Free hugepages back to the system (set nr_hugepages to 0). +release_hugepages() { + local hp_dir + for hp_dir in /sys/kernel/mm/hugepages/hugepages-*kB; do + [ -e "$hp_dir/nr_hugepages" ] || continue + printf '0\n' > "$hp_dir/nr_hugepages" 2>/dev/null || true + done + printf '[*] %s: hugepages released.\n' "$SCRIPT_NAME" +} + +# Disable Transparent Hugepages (THP). DPDK explicitly recommends this +# (and the kernel docs flag THP+hugepages as a poor combo: THP fragments +# memory in ways that can starve the static hugepage pool). Best-effort: +# some kernels don't expose /sys/kernel/mm/transparent_hugepage/enabled. +# Reversible — release_thp() flips it back to its previous value. +disable_thp_if_possible() { + local thp_file="/sys/kernel/mm/transparent_hugepage/enabled" + if [ -w "$thp_file" ]; then + local prev + prev="$(cat "$thp_file" 2>/dev/null || true)" + printf 'never\n' > "$thp_file" 2>/dev/null || true + printf '[*] %s: transparent_hugepage set to never (was: %s).\n' \ + "$SCRIPT_NAME" "${prev:-unknown}" + fi +} + +cmd_bind() { + if [ "$(id -u)" != "0" ]; then + printf '[!] %s: bind requires root (modprobe + sysfs writes).\n' "$SCRIPT_NAME" >&2 + exit 1 + fi + local bdfs + if ! bdfs="$(resolve_bdf_list "$ANYSCAN_DPDK_PCI_BDFS")" || [ -z "$bdfs" ]; then + printf '[!] %s: no valid PCI BDFs to bind. Set ANYSCAN_DPDK_PCI_BDFS to a CSV of BDFs or iface names.\n' \ + "$SCRIPT_NAME" >&2 + exit 1 + fi + local bdfs_csv + bdfs_csv="$(printf '%s' "$bdfs" | tr '\n' ',' | sed 's/,$//')" + + # Refusal rule 2: would binding leave us with zero kernel NICs? + local remaining + remaining="$(count_remaining_kernel_nics "$bdfs_csv")" + if [ "$remaining" -lt 1 ]; then + printf '[!] %s: refusing to bind because doing so would leave 0 kernel-networked NICs (host would lose orchestrator connectivity).\n' \ + "$SCRIPT_NAME" >&2 + printf ' Resolved BDFs: %s\n' "$bdfs_csv" >&2 + printf ' See plan §3.11: DPDK requires a dedicated NIC AND at least one kernel-networked NIC.\n' >&2 + exit 1 + fi + + local devbind + if ! devbind="$(resolve_devbind)"; then + printf '[!] %s: dpdk-devbind.py not found. Install the `dpdk` apt package or set ANYSCAN_DPDK_DEVBIND.\n' \ + "$SCRIPT_NAME" >&2 + exit 1 + fi + + # Hugepages first — DPDK's mempool create needs them BEFORE rte_eal_init. + if ! reserve_hugepages "$ANYSCAN_DPDK_HUGEPAGES_GB"; then + printf '[!] %s: hugepages reservation failed; aborting bind to avoid leaving the host in a half-configured state.\n' \ + "$SCRIPT_NAME" >&2 + exit 1 + fi + disable_thp_if_possible + + if ! lsmod 2>/dev/null | grep -q '^vfio_pci\b'; then + printf '[*] %s: loading vfio-pci module...\n' "$SCRIPT_NAME" + modprobe vfio-pci || { + printf '[!] %s: modprobe vfio-pci failed. Is the kernel built with CONFIG_VFIO_PCI?\n' "$SCRIPT_NAME" >&2 + exit 1 + } + fi + + # AWS bare-metal hosts often lack a real IOMMU exposed to userspace; the + # `enable_unsafe_noiommu_mode` knob lets vfio-pci function without one. + # Best-effort — if the knob is missing (older kernel) the bind step + # below will fail with a clear message and the operator will know. + local noiommu_knob="/sys/module/vfio/parameters/enable_unsafe_noiommu_mode" + if [ -w "$noiommu_knob" ]; then + printf 'Y\n' > "$noiommu_knob" 2>/dev/null || true + fi + + local bdf + while IFS= read -r bdf; do + [ -n "$bdf" ] || continue + # Idempotency: if already bound to vfio-pci, skip. + local current_driver + current_driver="$(readlink -f "/sys/bus/pci/devices/$bdf/driver" 2>/dev/null | xargs -r basename || true)" + if [ "$current_driver" = "vfio-pci" ]; then + printf '[*] %s: %s already bound to vfio-pci; skipping.\n' "$SCRIPT_NAME" "$bdf" + continue + fi + printf '[*] %s: binding %s to vfio-pci (was: %s)...\n' "$SCRIPT_NAME" "$bdf" "${current_driver:-none}" + if ! "$devbind" --bind=vfio-pci "$bdf"; then + printf '[!] %s: failed to bind %s. Check `dpdk-devbind.py --status`; the device may have an active route or be the only NIC.\n' \ + "$SCRIPT_NAME" "$bdf" >&2 + exit 1 + fi + done <<<"$bdfs" + + printf '[*] %s: bind complete. Run `dpdk-devbind.py --status` to confirm.\n' "$SCRIPT_NAME" +} + +cmd_unbind() { + if [ "$(id -u)" != "0" ]; then + printf '[!] %s: unbind requires root.\n' "$SCRIPT_NAME" >&2 + exit 1 + fi + local bdfs + if ! bdfs="$(resolve_bdf_list "$ANYSCAN_DPDK_PCI_BDFS")" || [ -z "$bdfs" ]; then + printf '[!] %s: no BDFs to unbind. Set ANYSCAN_DPDK_PCI_BDFS to the CSV used at bind time.\n' \ + "$SCRIPT_NAME" >&2 + exit 1 + fi + local devbind + if ! devbind="$(resolve_devbind)"; then + printf '[!] %s: dpdk-devbind.py not found.\n' "$SCRIPT_NAME" >&2 + exit 1 + fi + local bdf + while IFS= read -r bdf; do + [ -n "$bdf" ] || continue + local current_driver + current_driver="$(readlink -f "/sys/bus/pci/devices/$bdf/driver" 2>/dev/null | xargs -r basename || true)" + if [ "$current_driver" != "vfio-pci" ]; then + printf '[*] %s: %s not bound to vfio-pci (current: %s); skipping.\n' \ + "$SCRIPT_NAME" "$bdf" "${current_driver:-none}" + continue + fi + # Restore to ena (the kernel ENA driver). On non-AWS hosts the + # original driver may differ; operators can pass --bind= + # explicitly via dpdk-devbind.py if needed. + printf '[*] %s: unbinding %s back to ena...\n' "$SCRIPT_NAME" "$bdf" + if ! "$devbind" --bind=ena "$bdf"; then + printf '[!] %s: failed to unbind %s. Manual recovery: `dpdk-devbind.py --bind=ena %s`.\n' \ + "$SCRIPT_NAME" "$bdf" "$bdf" >&2 + exit 1 + fi + done <<<"$bdfs" + + release_hugepages + printf '[*] %s: unbind complete.\n' "$SCRIPT_NAME" +} + +cmd_status() { + printf '== Hugepages ==\n' + local hp_dir + for hp_dir in /sys/kernel/mm/hugepages/hugepages-*kB; do + [ -e "$hp_dir/nr_hugepages" ] || continue + printf ' %s: nr=%s free=%s\n' \ + "$(basename "$hp_dir")" \ + "$(cat "$hp_dir/nr_hugepages" 2>/dev/null || echo ?)" \ + "$(cat "$hp_dir/free_hugepages" 2>/dev/null || echo ?)" + done + printf '== vfio-pci ==\n' + if lsmod 2>/dev/null | grep -q '^vfio_pci\b'; then + printf ' loaded\n' + else + printf ' NOT loaded\n' + fi + printf '== /dev/vfio ==\n' + if [ -e /dev/vfio/vfio ]; then + printf ' /dev/vfio/vfio present\n' + else + printf ' /dev/vfio/vfio missing (no NIC bound)\n' + fi + printf '== devbind status ==\n' + local devbind + if devbind="$(resolve_devbind)"; then + "$devbind" --status 2>/dev/null || true + else + printf ' dpdk-devbind.py not found\n' + fi +} + +# argv parsing +SUBCMD="${1:-}" +[ -n "$SUBCMD" ] || { usage >&2; exit 1; } +shift || true +while [ $# -gt 0 ]; do + case "$1" in + --bdfs=*) ANYSCAN_DPDK_PCI_BDFS="${1#--bdfs=}" ;; + --hugepages-gb=*) ANYSCAN_DPDK_HUGEPAGES_GB="${1#--hugepages-gb=}" ;; + -h|--help) usage; exit 0 ;; + *) printf '[!] %s: unknown flag %s\n' "$SCRIPT_NAME" "$1" >&2; usage >&2; exit 1 ;; + esac + shift +done + +case "$SUBCMD" in + bind) cmd_bind ;; + unbind) cmd_unbind ;; + status) cmd_status ;; + -h|--help) usage; exit 0 ;; + *) printf '[!] %s: unknown subcommand %s\n' "$SCRIPT_NAME" "$SUBCMD" >&2; usage >&2; exit 1 ;; +esac diff --git a/tools/test-install-external-deps-dpdk.sh b/tools/test-install-external-deps-dpdk.sh new file mode 100755 index 0000000..a26b400 --- /dev/null +++ b/tools/test-install-external-deps-dpdk.sh @@ -0,0 +1,307 @@ +#!/usr/bin/env bash +# Unit test for the DPDK build wire-up in install-external-deps.sh +# (plans/2026-04-28-portscan-dpdk-impl-v1.md §3.10.1). +# +# Asserts that install-external-deps.sh forwards ANYSCAN_USE_DPDK through +# to the engine's `make` invocation, mirroring the AF_XDP shape of +# tools/test-install-external-deps-afxdp.sh: +# +# 1. ANYSCAN_USE_DPDK=0 (default) → `make` is called with NO USE_DPDK=1 +# token. Existing AMIs keep building the legacy AF_PACKET-only binary. +# 2. ANYSCAN_USE_DPDK=1 + missing scanner → `make USE_DPDK=1`. +# 3. ANYSCAN_USE_DPDK=1 + cached non-DPDK binary → cache check detects +# missing librte_eal linkage and force-rebuilds via `make clean` + +# `make USE_DPDK=1`. +# 4. ANYSCAN_USE_DPDK=1 + cached DPDK-linked binary → no rebuild. +# +# Implementation notes: +# - Stubs `make`, `git`, `ldd`, `readelf` on PATH and records every +# invocation. `git fetch/pull` is a no-op so we don't hit the network; +# `make` writes a synthetic scanner binary whose librte_eal linkage is +# controlled by the test (env STUB_MAKE_PRODUCES_DPDK). +# - Disables the DPDK / AF_XDP / PF_RING apt-deps blocks so we don't +# probe sudo on CI hosts. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET_SCRIPT="${SCRIPT_DIR}/../install-external-deps.sh" + +if [ ! -x "$TARGET_SCRIPT" ]; then + printf '[!] %s is not executable\n' "$TARGET_SCRIPT" >&2 + exit 1 +fi + +PASS=0 +FAIL=0 + +note_pass() { + PASS=$(( PASS + 1 )) + printf ' [ok] %s\n' "$1" +} + +note_fail() { + FAIL=$(( FAIL + 1 )) + printf ' [FAIL] %s: %s\n' "$1" "$2" >&2 +} + +assert_contains_line() { + local label="$1" expected="$2" file="$3" + if [ -f "$file" ] && grep -Fxq -- "$expected" "$file"; then + note_pass "$label" + else + note_fail "$label" "expected line $(printf '%q' "$expected") in $file" + if [ -f "$file" ]; then + printf ' file contents:\n' >&2 + sed 's/^/ /' "$file" >&2 + fi + fi +} + +assert_not_contains_substring() { + local label="$1" needle="$2" file="$3" + if [ -f "$file" ] && grep -Fq "$needle" "$file"; then + note_fail "$label" "did not expect substring $(printf '%q' "$needle") in $file" + sed 's/^/ /' "$file" >&2 + else + note_pass "$label" + fi +} + +assert_contains_substring() { + local label="$1" needle="$2" file="$3" + if [ -f "$file" ] && grep -Fq "$needle" "$file"; then + note_pass "$label" + else + note_fail "$label" "expected substring $(printf '%q' "$needle") in $file" + if [ -f "$file" ]; then + sed 's/^/ /' "$file" >&2 + fi + fi +} + +WORK_ROOT="$(mktemp -d)" +trap 'rm -rf "$WORK_ROOT"' EXIT + +prepare_stubs() { + local stub_dir="$1" + local make_log="$2" + local git_log="$3" + local linkage_marker="$4" + + mkdir -p "$stub_dir" + + cat >"$stub_dir/make" <>"$make_log" +target_dir="" +i=0 +while [ \$i -lt \$# ]; do + i=\$(( i + 1 )) + arg="\${!i}" + if [ "\$arg" = "-C" ]; then + i=\$(( i + 1 )) + target_dir="\${!i}" + fi +done +clean_only=0 +for arg in "\$@"; do + if [ "\$arg" = "clean" ]; then + clean_only=1 + break + fi +done +if [ "\$clean_only" = "1" ]; then + if [ -n "\$target_dir" ]; then + rm -f "\$target_dir/scanner" + fi + exit 0 +fi +if [ -z "\$target_dir" ]; then + exit 0 +fi +mkdir -p "\$target_dir" +if [ -f "$linkage_marker" ]; then + # Pretend this build linked librte_eal. + printf '#!/bin/sh\necho stub-scanner-with-dpdk\n' >"\$target_dir/scanner" + printf 'librte_eal-marker\n' >"\$target_dir/scanner.linkage" +else + printf '#!/bin/sh\necho stub-scanner\n' >"\$target_dir/scanner" + : >"\$target_dir/scanner.linkage" +fi +chmod +x "\$target_dir/scanner" +EOF + chmod +x "$stub_dir/make" + + cat >"$stub_dir/git" <>"$git_log" +exit 0 +EOF + chmod +x "$stub_dir/git" + + # `ldd` stub: return a librte_eal.so line iff .linkage contains + # 'librte_eal-marker'. The real ldd walks NEEDED entries; the marker + # is set by the make stub above so the test controls which builds + # appear DPDK-linked. + cat >"$stub_dir/ldd" <<'EOF' +#!/usr/bin/env bash +bin="$1" +if [ -f "${bin}.linkage" ] && grep -q librte_eal-marker "${bin}.linkage"; then + printf '\tlibrte_eal.so.24 => /usr/lib/x86_64-linux-gnu/librte_eal.so.24 (0x00007f00)\n' +fi +exit 0 +EOF + chmod +x "$stub_dir/ldd" + + cat >"$stub_dir/readelf" <<'EOF' +#!/usr/bin/env bash +if [ "$1" = "-d" ]; then + bin="$2" + if [ -f "${bin}.linkage" ] && grep -q librte_eal-marker "${bin}.linkage"; then + printf ' 0x0000000000000001 (NEEDED) Shared library: [librte_eal.so.24]\n' + fi +fi +exit 0 +EOF + chmod +x "$stub_dir/readelf" +} + +run_install_script() { + local case_dir="$1" + local use_dpdk="$2" + local linkage_marker_state="$3" # "dpdk" or "legacy" or "missing" + + local repo_dir="$case_dir/engine" + local runtime_env="$case_dir/runtime.env" + local artifact_dir="$case_dir/artifacts" + local make_log="$case_dir/make.log" + local git_log="$case_dir/git.log" + local stub_dir="$case_dir/stubs" + local linkage_marker="$case_dir/linkage_is_dpdk" + + mkdir -p "$repo_dir" "$artifact_dir" + : >"$make_log" + : >"$git_log" + rm -f "$linkage_marker" + + printf 'all:\n\t@true\n' >"$repo_dir/Makefile" + mkdir -p "$repo_dir/.git" + + case "$linkage_marker_state" in + dpdk) + printf '#!/bin/sh\necho cached-scanner-dpdk\n' >"$repo_dir/scanner" + printf 'librte_eal-marker\n' >"$repo_dir/scanner.linkage" + chmod +x "$repo_dir/scanner" + ;; + legacy) + printf '#!/bin/sh\necho cached-scanner-legacy\n' >"$repo_dir/scanner" + : >"$repo_dir/scanner.linkage" + chmod +x "$repo_dir/scanner" + ;; + missing) + : ;; + *) + printf '[!] unknown linkage marker state %s\n' "$linkage_marker_state" >&2 + exit 1 + ;; + esac + + if [ "$use_dpdk" = "1" ]; then + : >"$linkage_marker" + fi + + prepare_stubs "$stub_dir" "$make_log" "$git_log" "$linkage_marker" + + ( + export PATH="$stub_dir:$PATH" + export ANYSCAN_USE_DPDK="$use_dpdk" + export ANYSCAN_VULNSCANNER_REPO_DIR="$repo_dir" + export ANYSCAN_INSTALL_DPDK_DEPS=false + export ANYSCAN_INSTALL_AFXDP_DEPS=false + export ANYSCAN_INSTALL_PFRING_ZC_DEPS=false + export ANYSCAN_RUNTIME_ENV_FILE="$runtime_env" + export ANYSCAN_LOCAL_BOOTSTRAP_ARTIFACT_DIR="$artifact_dir" + unset SUDO_USER + "$TARGET_SCRIPT" >"$case_dir/stdout.log" 2>"$case_dir/stderr.log" + ) +} + +# --------------------------------------------------------------------------- +# Case 1: ANYSCAN_USE_DPDK unset → no USE_DPDK=1 in make argv. +# --------------------------------------------------------------------------- +case_dir="$WORK_ROOT/case-default" +mkdir -p "$case_dir" +if run_install_script "$case_dir" "0" "missing"; then + note_pass "default build runs successfully" + assert_contains_line \ + "default build calls make with engine repo only" \ + "-C $case_dir/engine" \ + "$case_dir/make.log" + assert_not_contains_substring \ + "default build does NOT pass USE_DPDK=1" \ + "USE_DPDK=1" \ + "$case_dir/make.log" +else + note_fail "default build" "install-external-deps.sh exited non-zero (see $case_dir/stderr.log)" +fi + +# --------------------------------------------------------------------------- +# Case 2: ANYSCAN_USE_DPDK=1 + no cached binary → make USE_DPDK=1. +# --------------------------------------------------------------------------- +case_dir="$WORK_ROOT/case-dpdk-fresh" +mkdir -p "$case_dir" +if run_install_script "$case_dir" "1" "missing"; then + note_pass "dpdk fresh build runs successfully" + assert_contains_substring \ + "dpdk fresh build passes USE_DPDK=1 to make" \ + "USE_DPDK=1" \ + "$case_dir/make.log" +else + note_fail "dpdk fresh build" "install-external-deps.sh exited non-zero (see $case_dir/stderr.log)" +fi + +# --------------------------------------------------------------------------- +# Case 3: ANYSCAN_USE_DPDK=1 + cached AF_PACKET-only binary → force rebuild +# via make clean + make USE_DPDK=1. +# --------------------------------------------------------------------------- +case_dir="$WORK_ROOT/case-dpdk-force" +mkdir -p "$case_dir" +if run_install_script "$case_dir" "1" "legacy"; then + note_pass "dpdk force-rebuild runs successfully" + assert_contains_substring \ + "dpdk force-rebuild invokes make clean" \ + "clean" \ + "$case_dir/make.log" + assert_contains_substring \ + "dpdk force-rebuild passes USE_DPDK=1 to make" \ + "USE_DPDK=1" \ + "$case_dir/make.log" +else + note_fail "dpdk force-rebuild" "install-external-deps.sh exited non-zero (see $case_dir/stderr.log)" +fi + +# --------------------------------------------------------------------------- +# Case 4: ANYSCAN_USE_DPDK=1 + cached DPDK-linked binary → no rebuild. +# --------------------------------------------------------------------------- +case_dir="$WORK_ROOT/case-dpdk-cached" +mkdir -p "$case_dir" +if run_install_script "$case_dir" "1" "dpdk"; then + note_pass "dpdk cached-binary path runs successfully" + if [ -s "$case_dir/make.log" ]; then + note_fail "dpdk cached-binary path skips make" \ + "expected empty make.log but got: $(tr '\n' '|' <"$case_dir/make.log")" + else + note_pass "dpdk cached-binary path skips make" + fi +else + note_fail "dpdk cached-binary path" "install-external-deps.sh exited non-zero (see $case_dir/stderr.log)" +fi + +printf '\n' +printf 'PASS: %d\n' "$PASS" +printf 'FAIL: %d\n' "$FAIL" + +if [ "$FAIL" -gt 0 ]; then + exit 1 +fi diff --git a/vulnscanner-zmap-adapter.py b/vulnscanner-zmap-adapter.py index e85dccd..8d0b6a3 100755 --- a/vulnscanner-zmap-adapter.py +++ b/vulnscanner-zmap-adapter.py @@ -118,23 +118,27 @@ def env_flag(name: str, default: bool = False) -> bool: # Phase 2 PR D of plans/2026-04-27-portscan-afxdp-plan-v1.md §3.7 added -# af_xdp; anygpt-46 added pfring_zc with the same shape. The scanner -# accepts --io-engine={af_packet,af_xdp,pfring_zc}; AF_PACKET stays the -# unconditional default and unconditional fallback. The other engines +# af_xdp; anygpt-46 added pfring_zc with the same shape. Phase 2 of +# plans/2026-04-28-portscan-dpdk-impl-v1.md §3.10.6 adds dpdk. The scanner +# accepts --io-engine={af_packet,af_xdp,pfring_zc,dpdk}; AF_PACKET stays +# the unconditional default and unconditional fallback. The other engines # are opt-in per worker and gated on install-time probes: # - ANYSCAN_AF_XDP_AVAILABLE — kernel >=5.10 + libxdp.so loadable # - ANYSCAN_PFRING_ZC_AVAILABLE — pfring kmod loaded + libpfring.so loadable +# - ANYSCAN_DPDK_AVAILABLE — librte_eal.so loadable + vfio_pci loaded + +# hugepages reserved + scanner USE_DPDK-built # We refuse to forward an engine when its probe failed because the -# scanner would error at startup with a dlopen / cluster-init error and -# the worker has no way to recover. The fall-back warning is loud on -# purpose: silently dropping back to AF_PACKET would let an operator who -# flipped the knob keep believing they were running on the fast path. -SUPPORTED_IO_ENGINES = ("af_packet", "af_xdp", "pfring_zc") +# scanner would error at startup with a dlopen / EAL-init error and the +# worker has no way to recover. The fall-back warning is loud on purpose: +# silently dropping back to AF_PACKET would let an operator who flipped +# the knob keep believing they were running on the fast path. +SUPPORTED_IO_ENGINES = ("af_packet", "af_xdp", "pfring_zc", "dpdk") DEFAULT_IO_ENGINE = "af_packet" _IO_ENGINE_AVAILABILITY_KEYS = { "af_xdp": "ANYSCAN_AF_XDP_AVAILABLE", "pfring_zc": "ANYSCAN_PFRING_ZC_AVAILABLE", + "dpdk": "ANYSCAN_DPDK_AVAILABLE", }