From a6a6fc7348ed7cb59e2e01f4b69ad216a5ee93d8 Mon Sep 17 00:00:00 2001 From: loki5512344 Date: Sun, 4 Oct 2026 12:47:19 +0200 Subject: [PATCH] smoke: add xdp netns test script; fix XDP filter attach/globals --- scripts/xdp_netns_smoke.sh | 188 +++++++++++++++++++++++++++++++++++++ src/xdp/filter/attach.rs | 55 ++++++++++- src/xdp/filter/mod.rs | 39 ++++++-- src/xdp/globals.rs | 6 +- 4 files changed, 276 insertions(+), 12 deletions(-) create mode 100755 scripts/xdp_netns_smoke.sh diff --git a/scripts/xdp_netns_smoke.sh b/scripts/xdp_netns_smoke.sh new file mode 100755 index 0000000..557aa10 --- /dev/null +++ b/scripts/xdp_netns_smoke.sh @@ -0,0 +1,188 @@ +#!/usr/bin/env bash +# netns+veth smoke-тест XDP-пути на РЕАЛЬНОМ ядре (вывод №7 конкурентного анализа). +# +# Проверяет то, что невозможно проверить в контейнере CI без CAP_BPF: +# 1. verifier принимает universal_filter.o (загрузка через libbpf); +# 2. attach на живом интерфейсе в netns, native-режим; +# 3. легитимный HTTP проксируется; +# 4. SYN-флуд порождает ringbuf-события -> opt-in auto-ban работает; +# 5. по завершении бана трафик восстанавливается (expiry); +# 6. после SIGTERM XDP-ПРОГРАММА РЕАЛЬНО ОТВЯЗАНА (регрессия на bug fd=-1). +# +# Запуск: на хосте с root/CAP_BPF (VDS или VM-раннер CI), не в контейнере: +# BIN=./target/release/rampart bash scripts/xdp_netns_smoke.sh +# +# Требования: iproute2, bpftool, hping3, python3 (http.server), curl. + +set -u + +NS=rampart_smoke +SUBNET=10.244.251 +HOST_IP=$SUBNET.1 +EDGE_IP=$SUBNET.2 +EDGE_PORT=9000 +ORIGIN_PORT=9100 +METRICS_PORT=9595 +BIN=${BIN:-./target/release/rampart} +WORK=$(mktemp -d) +RAMPID="" +SRVPID="" +PASS=1 + +fail() { echo "FAIL: $1"; PASS=0; } +ok() { echo "ok: $1"; } + +cleanup() { + [ -n "$RAMPID" ] && kill -9 "$RAMPID" 2>/dev/null + [ -n "$SRVPID" ] && kill -9 "$SRVPID" 2>/dev/null + ip netns del "$NS" 2>/dev/null + rm -rf "$WORK" +} +trap cleanup EXIT + +[ "$(id -u)" = 0 ] || { echo "нужен root (CAP_BPF+CAP_NET_ADMIN)"; exit 2; } +[ -x "$BIN" ] || { echo "бинарь не найден: $BIN"; exit 2; } + +# --- 1. netns + veth: весь тест изолирован, прод-трафик не затрагивается --- +ip netns del "$NS" 2>/dev/null +ip netns add "$NS" || { echo "не удалось создать netns"; exit 2; } +ip link add rth0 type veth peer name rtap +ip link set rtap netns "$NS" +ip addr add "$HOST_IP/24" dev rth0; ip link set rth0 up +ip -n "$NS" addr add "$EDGE_IP/24" dev rtap +ip -n "$NS" link set rtap up +ip -n "$NS" link set lo up +ok "netns $NS + veth подняты" + +# --- 2. конфиг edge: узкий защищённый диапазон, автобан включён (иначе нечего тестить) --- +cat > "$WORK/edge.toml" <rampart-smoke-origin" > "$WORK/index.html" +python3 -m http.server "$ORIGIN_PORT" --bind "$HOST_IP" -d "$WORK" >/dev/null 2>&1 & +SRVPID=$! +sleep 0.5 + +# --- 4. старт edge внутри netns, ожидание attach --- +RUST_LOG=rampart=debug RAMPART_CONFIG="$WORK/edge.toml" \ + ip netns exec "$NS" "$BIN" > "$WORK/rampart.log" 2>&1 & +RAMPID=$! +ATTACHED=no +for _ in $(seq 60); do + grep -q "XDP filter attached" "$WORK/rampart.log" && { ATTACHED=yes; break; } + kill -0 "$RAMPID" 2>/dev/null || break + sleep 0.5 +done +if [ "$ATTACHED" = yes ]; then + ok "XDP attached (verifier принят ядром $(uname -r))" +else + fail "XDP не аттачнулся; лог:"; sed -n '1,30p' "$WORK/rampart.log"; exit 1 +fi + +# Режим attach: native ожидаем на veth, но fallback на generic — штатный +# путь лестницы в load(); сверяем с честным логом режима от rampart. +if ip -n "$NS" link show rtap | grep -qE "xdp( |$)"; then + ok "attach native (ip link: xdp)" +elif ip -n "$NS" link show rtap | grep -q "xdpgeneric"; then + echo "warn: attach в generic (skb) — veth обычно тянет native, проверь лог" +else + fail "на rtap нет XDP-программы после 'attached'" +fi +grep -oE "mode: [a-z ()]+" "$WORK/rampart.log" | tail -1 + +# --- 5. легитимный HTTP проходит прокси --- +BODY=$(curl -s --max-time 5 "http://$EDGE_IP:$EDGE_PORT/" || true) +echo "$BODY" | grep -q "rampart-smoke-origin" \ + && ok "легитимный HTTP проксируется" \ + || fail "легитимный HTTP не дошёл до origin" + +# --- 6. SYN-флуд -> throttle -> ringbuf events -> auto-ban --- +hping3 -S -p "$EDGE_PORT" -c 4000 -i u200 "$EDGE_IP" >/dev/null 2>&1 || true +sleep 3 +grep -q "xdp event" "$WORK/rampart.log" \ + && ok "ringbuf-события долетают до userspace" \ + || fail "ни одного 'xdp event' в логе" +if grep -q "xdp auto-ban issued" "$WORK/rampart.log"; then + ok "auto-ban выдан по событию" +else + fail "auto-ban не сработал" +fi +BANS=$(ip netns exec "$NS" curl -s --max-time 2 "http://127.0.0.1:$METRICS_PORT/metrics" \ + | awk '/^rampart_xdp_autobans_total /{print $2}') +[ "${BANS:-0}" != "0" ] && ok "метрика rampart_xdp_autobans_total=$BANS" \ + || fail "метрика autobans пуста" + +# забаненный источник отклоняется до истечения 5с +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "http://$EDGE_IP:$EDGE_PORT/" || echo "err") +[ "$HTTP_CODE" = "err" ] || [ "$HTTP_CODE" = "000" ] \ + && ok "забаненный клиент отклонён (http=$HTTP_CODE)" \ + || fail "забаненный клиент получил ответ ($HTTP_CODE)" + +# --- 7. expiry: через 7с (> ban 5s) трафик возвращается --- +sleep 7 +BODY=$(curl -s --max-time 5 "http://$EDGE_IP:$EDGE_PORT/" || true) +echo "$BODY" | grep -q "rampart-smoke-origin" \ + && ok "бан истёк, легитимный трафик восстановлен" \ + || fail "после expiry трафик не восстановился" + +# --- 8. ГРЕЙСФУЛ СТОП -> ПРОГРАММА ОТВЯЗАНА (регрессия на borrow_raw(-1)) --- +kill -TERM "$RAMPID" +wait "$RAMPID"; RC=$? +RAMPID="" +[ "$RC" = 0 ] && ok "rampart завершился с кодом 0" || fail "код выхода rampart: $RC" +grep -q "XDP filter detached" "$WORK/rampart.log" \ + && ok "лог: detach выполнен" || fail "в логе нет строки detach" +sleep 1 +if ip -n "$NS" link show rtap | grep -q "xdp"; then + fail "XDP ПРОГРАММА ОСТАЛАСЬ НА ИНТЕРФЕЙСЕ ПОСЛЕ ВЫХОДА ПРОЦЕССА (detach сломан)" +else + ok "XDP-программа реально отвязана после SIGTERM" +fi + +# --- итог --- +echo +if [ "$PASS" = 1 ]; then + echo "PASS — xdp netns smoke ($(uname -r))" + exit 0 +else + echo "FAIL — см. вывод выше; полный лог edge:" + tail -40 "$WORK/rampart.log" + exit 1 +fi diff --git a/src/xdp/filter/attach.rs b/src/xdp/filter/attach.rs index 2bf98cb..fe8bd5d 100644 --- a/src/xdp/filter/attach.rs +++ b/src/xdp/filter/attach.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, Result}; +use anyhow::{Context, Result, bail}; use libbpf_rs::{MapCore, Object, OpenObject, RingBuffer, RingBufferBuilder}; use std::ffi::CString; use std::net::IpAddr; @@ -18,17 +18,45 @@ pub(super) fn interface_cstr(name: &str) -> Result { CString::new(name).with_context(|| format!("interface name {name:?} contains an interior NUL byte")) } -/// Патчит карту `rampart_.rodata` (volatile const глобалы) ДО загрузки объекта. +/// Внутренний data-map `.rodata` ищется по суффиксу: libbpf именует его +/// `.rodata`, а имя объекта при `open_memory` без явного `.name()` +/// вообще `memory` — точное сравнение ломалось на живом ядре (smoke-тест). +pub(super) fn is_rodata_map(name: &str) -> bool { + name.ends_with(".rodata") +} + +/// Патчит карту `*.rodata` (volatile const глобалы) ДО загрузки объекта. pub(super) fn patch_rodata(open_obj: &mut OpenObject, globals: &XdpGlobals) -> Result<()> { let image = globals.build_rodata_image(); + if !open_obj + .maps_mut() + .any(|m| m.name().to_str().is_some_and(is_rodata_map)) + { + let names: Vec = open_obj + .maps() + .map(|m| m.name().to_string_lossy().into_owned()) + .collect(); + bail!("no .rodata map ('{RODATA_MAP_NAME}') in XDP object, maps: {names:?}"); + } let mut map = open_obj .maps_mut() - .find(|m| m.name() == RODATA_MAP_NAME) - .with_context(|| format!("map '{RODATA_MAP_NAME}' not found in XDP object"))?; + .find(|m| m.name().to_str().is_some_and(is_rodata_map)) + .context("rodata map vanished between passes")?; map.set_initial_value(&image) .with_context(|| format!("failed to set initial value of '{RODATA_MAP_NAME}'")) } +/// Честное имя режима attach по `attach_mode` из `bpf_xdp_query_opts` +/// (kernel `enum xdp_attached_mode`: NONE=0, DRV=1, OFFLOAD=2, SKB=3). +pub(super) fn xdp_attach_mode_name(mode: u8) -> &'static str { + match mode { + 1 => "native (drv)", + 2 => "hardware offload", + 3 => "generic (skb)", + _ => "none", + } +} + /// Собирает ringbuf-колбэк для `events_map`. /// /// Колбэк обязан быть `'static`, поэтому всё разделяемое состояние (`Arc`, @@ -103,4 +131,23 @@ mod tests { fn interface_cstr_rejects_interior_nul() { assert!(interface_cstr("et\0h0").is_err()); } + + #[test] + fn rodata_map_matches_libbpf_naming() { + // libbpf строит имя `
` без замены точки (libbpf.c ~1911), + // при open_memory без name объект называется "memory": + assert!(is_rodata_map("rampart.rodata")); + assert!(is_rodata_map("memory.rodata")); + assert!(is_rodata_map(".rodata")); + assert!(!is_rodata_map("rampart.bss")); + assert!(!is_rodata_map("stats_map")); + } + + #[test] + fn attach_mode_names_are_honest() { + assert_eq!(xdp_attach_mode_name(1), "native (drv)"); + assert_eq!(xdp_attach_mode_name(3), "generic (skb)"); + assert_eq!(xdp_attach_mode_name(2), "hardware offload"); + assert_eq!(xdp_attach_mode_name(0), "none"); + } } diff --git a/src/xdp/filter/mod.rs b/src/xdp/filter/mod.rs index 0d8b4af..6d7923f 100644 --- a/src/xdp/filter/mod.rs +++ b/src/xdp/filter/mod.rs @@ -11,7 +11,7 @@ use crate::config::XdpConfig; use crate::filter::blacklist::Blacklist; use crate::traffic::prefix::{PrefixKey, PrefixStatsVal}; -use attach::{build_ringbuf, interface_cstr, patch_rodata}; +use attach::{build_ringbuf, interface_cstr, patch_rodata, xdp_attach_mode_name}; use events::AutoBanConfig; use maps::{blacklist_expiry_ns, blacklist_key, unix_ns}; @@ -62,7 +62,11 @@ impl XdpFilter { super::probe::preflight(&self.interface)?; let bpf_obj = include_bytes!(concat!(env!("OUT_DIR"), "/universal_filter.o")); + // Имя объекта задаётся ЯВНО: при open_memory libbpf не знает имени файла, + // а по нему именуются внутренние maps ("rampart_.rodata" в patch_rodata). let mut open_obj = ObjectBuilder::default() + .name("rampart") + .context("failed to set BPF object name")? .open_memory(bpf_obj) .context("Failed to open XDP object")?; patch_rodata(&mut open_obj, &self.globals)?; @@ -83,27 +87,48 @@ impl XdpFilter { .progs() .find(|p| p.name() == "rampart_universal_filter") .context("XDP program 'rampart_universal_filter' not found")?; - Xdp::new(prog.as_fd()).attach(ifindex as i32, XdpFlags::NONE)?; + let xdp = Xdp::new(prog.as_fd()); + // Лестница режимов: пробуем native (drv); если драйвер не тянет — + // честный fallback в generic (skb) с предупреждением (вывод №1 из + // конкурентного анализа: печатать режим как есть, без вранья). + let ifindex = ifindex as i32; + if let Err(e) = xdp.attach(ifindex, XdpFlags::NONE) { + tracing::warn!( + interface = %self.interface, + error = %e, + "native XDP attach failed, falling back to generic (skb) mode" + ); + xdp.attach(ifindex, XdpFlags::SKB_MODE) + .context("XDP attach failed in both native and generic modes")?; + } + let mode = xdp + .query(ifindex, XdpFlags::NONE) + .map(|q| xdp_attach_mode_name(q.attach_mode)) + .unwrap_or("unknown"); super::metrics::init(); let rbuf = build_ringbuf(&obj, self.blacklist.clone(), self.autoban)?; self.obj = Some(obj); self.ringbuf = Some(rbuf); - self.ifindex = ifindex as i32; - tracing::info!("XDP filter attached to {}", self.interface); + self.ifindex = ifindex; + tracing::info!("XDP filter attached to {} (mode: {mode})", self.interface); Ok(()) } pub fn unload(&mut self) -> Result<()> { + let was_attached = self.obj.is_some(); + let mut result = Ok(()); if let Some(obj) = self.obj.as_ref() { - self.detach_program(obj)?; + result = self.detach_program(obj); } self.ringbuf = None; self.obj = None; self.ifindex = 0; - tracing::info!("XDP filter detached from {}", self.interface); - Ok(()) + if was_attached { + tracing::info!("XDP filter detached from {}", self.interface); + } + result } /// Detaches the XDP program using the real program fd owned by the loaded diff --git a/src/xdp/globals.rs b/src/xdp/globals.rs index d1db0d9..1ed6521 100644 --- a/src/xdp/globals.rs +++ b/src/xdp/globals.rs @@ -28,7 +28,11 @@ use anyhow::{Context, Result, bail}; use crate::config::XdpConfig; /// Имя карты с образом `.rodata` загруженного BPF-объекта. -pub(crate) const RODATA_MAP_NAME: &str = "rampart_.rodata"; +/// Имя внутренней `.rodata`-карты при `ObjectBuilder::name("rampart")`: +/// libbpf конструирует `
` без замены точки (sanitizer +/// пропускаёт `.`; libbpf.c, map name logic) → `rampart.rodata`. +/// Поиск в `patch_rodata` всё равно по суффиксу — имя здесь для диагностики. +pub(crate) const RODATA_MAP_NAME: &str = "rampart.rodata"; /// Размер образа `.rodata` в байтах (см. `build_rodata_image`). pub const RODATA_SIZE: usize = 96;