fix: XDP unsafe loader + Redis sync; enforce 250-line/4-file layout limits

- xdp: CString for if_nametoindex (was UB), real detach via prog fd
  (fabricated borrow_raw(-1) silently never detached), SAFETY comments,
  saturating expiry math; +5 unit tests
- redis: real pubsub reconnect with exponential backoff (was sleep+return);
  KEYS -> SCAN in heartbeat sweep
- ci: cargo test --all-features, repo-gates job — module size gate
  (scripts/check_module_size.sh, ratchet baseline) + default-secrets grep
- refactor src/ to <=250 LOC/file, <=4 .rs/dir without behavior change;
  thin bins (rampart.rs 330 -> 6 LOC), new app/, subnet/, intel/,
  profile/, prefix/, challenge/, filter/, probe/, inventory/, metrics/, node/
- docs: TODO v5.0 (status refresh, new rules, findings backlog),
  README quickstart now matches real binaries
- verify: fmt/clippy -D warnings/test --all-features (164 tests)/clang XDP green
This commit is contained in:
loki5512344 2026-09-15 23:55:17 +02:00
parent d6bcae54c8
commit aa615a1141
Signed by: boba
GPG key ID: 253067914055423B
64 changed files with 2052 additions and 1408 deletions

View file

@ -1,285 +0,0 @@
//! Диагностика окружения перед XDP-attach: ядро, BTF, драйвер NIC, привилегии.
//!
//! Основной источник боли при XDP — непонятные ошибки attach на неподдерживаемых
//! ядрах и драйверах. Модуль собирает [`EnvironmentReport`] ДО загрузки BPF-программы
//! и даёт человекочитаемый вердикт с предупреждениями.
use anyhow::{Result, bail};
use std::fmt;
/// Минимально поддерживаемая версия ядра: 5.15 LTS.
pub const MIN_KERNEL: KernelVersion = KernelVersion::new(5, 15, 0);
const OSRELEASE_PATH: &str = "/proc/sys/kernel/osrelease";
const BTF_PATH: &str = "/sys/kernel/btf/vmlinux";
const STATUS_PATH: &str = "/proc/self/status";
/// Драйверы с известной поддержкой native XDP (по данным xdpgeneric/native
/// матриц upstream-ядра). Отсутствие в списке не означает отсутствие поддержки,
/// но для таких драйверов предсказываем generic mode.
const NATIVE_XDP_DRIVERS: &[&str] = &[
"virtio_net",
"ixgbe",
"ixgbevf",
"i40e",
"iavf",
"ice",
"mlx4_core",
"mlx5_core",
"igb",
"igc",
"e1000e",
"vmxnet3",
"bnxt_en",
"nfp",
"sfc",
"ena",
"hv_netvsc",
"macvlan",
];
/// Версия ядра Linux, пригодная для сравнения (`major.minor.patch`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct KernelVersion {
pub major: u16,
pub minor: u16,
pub patch: u16,
}
impl KernelVersion {
#[must_use]
pub const fn new(major: u16, minor: u16, patch: u16) -> Self {
Self { major, minor, patch }
}
/// Парсит строку формата `uname -r`. Берёт первые три числовых компонента,
/// дистрибутивные суффиксы (`6.8.0-45-generic`, `5.15.0-rc2`) отбрасываются.
/// Строка без цифр не парсится.
#[must_use]
pub fn parse(release: &str) -> Option<Self> {
let mut nums = release
.split(|c: char| !c.is_ascii_digit())
.filter_map(|part| part.parse::<u16>().ok());
let major = nums.next()?;
let minor = nums.next().unwrap_or(0);
let patch = nums.next().unwrap_or(0);
Some(Self::new(major, minor, patch))
}
}
impl fmt::Display for KernelVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
/// Режим присоединения XDP-программы к интерфейсу.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttachMode {
/// Программа исполняется в контексте драйвера NIC — минимальные накладные расходы.
Native,
/// Программа вызывается из сетевого стека после ingress — работает везде, дороже по CPU.
Generic,
/// Аппаратная разгрузка в NIC — требует явного включения и поддержки железа,
/// автоматически не выбирается.
Offload,
}
impl fmt::Display for AttachMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Native => "native",
Self::Generic => "generic",
Self::Offload => "offload",
})
}
}
/// Абстракция над procfs/sysfs: реальная реализация — [`FilesystemProbe`],
/// в тестах — мок. Изолирует диагностику от реального ядра.
pub trait SystemProbe {
/// Читает файл целиком.
///
/// # Errors
/// Проксирует ошибку чтения файла.
fn read_file(&self, path: &str) -> std::io::Result<String>;
#[must_use]
fn path_exists(&self, path: &str) -> bool;
#[must_use]
fn symlink_target(&self, path: &str) -> Option<String>;
#[must_use]
fn effective_uid(&self) -> u32;
}
/// Реальная реализация [`SystemProbe`] поверх `std::fs`.
pub struct FilesystemProbe;
impl SystemProbe for FilesystemProbe {
fn read_file(&self, path: &str) -> std::io::Result<String> {
std::fs::read_to_string(path)
}
fn path_exists(&self, path: &str) -> bool {
std::path::Path::new(path).exists()
}
fn symlink_target(&self, path: &str) -> Option<String> {
Some(std::fs::read_link(path).ok()?.to_string_lossy().into_owned())
}
/// Эвристика: эффективный uid берётся из `/proc/self/status`.
/// root почти всегда имеет CAP_BPF/CAP_NET_ADMIN (если явно не урезаны);
/// для не-root подтверждение возможно только попыткой attach.
/// Нечитаемый статус трактуем как непривилегированный процесс.
fn effective_uid(&self) -> u32 {
std::fs::read_to_string(STATUS_PATH)
.ok()
.and_then(|status| parse_euid(&status))
.unwrap_or(1)
}
}
fn parse_euid(status: &str) -> Option<u32> {
status.lines().find_map(|line| {
let mut fields = line.strip_prefix("Uid:")?.split_whitespace();
fields.nth(1)?.parse().ok()
})
}
fn driver_from_link(target: &str) -> Option<String> {
let name = target.rsplit('/').next()?;
(!name.is_empty()).then(|| name.to_owned())
}
/// Поддерживает ли драйвер native XDP (по таблице известных драйверов).
#[must_use]
pub fn driver_supports_native_xdp(driver: &str) -> bool {
NATIVE_XDP_DRIVERS.contains(&driver)
}
/// Структурированный отчёт о пригодности окружения для XDP.
pub struct EnvironmentReport {
pub kernel_version: Option<KernelVersion>,
pub btf_available: bool,
pub privileged: bool,
pub driver_name: Option<String>,
pub warnings: Vec<String>,
}
impl EnvironmentReport {
/// Собирает отчёт через произвольную реализацию [`SystemProbe`].
#[must_use]
pub fn collect(probe: &dyn SystemProbe, interface: &str) -> Self {
let kernel_version = probe
.read_file(OSRELEASE_PATH)
.ok()
.and_then(|r| KernelVersion::parse(r.trim()));
let btf_available = probe.path_exists(BTF_PATH);
let privileged = probe.effective_uid() == 0;
let driver_link = probe.symlink_target(&format!("/sys/class/net/{interface}/device/driver"));
let driver_name = driver_link.as_deref().and_then(driver_from_link);
let mut warnings = Vec::new();
if !privileged {
warnings.push(
"процесс не от root: CAP_BPF/CAP_NET_ADMIN не подтверждены, \
attach скорее всего завершится EPERM"
.to_owned(),
);
}
if !btf_available {
warnings.push(format!(
"{BTF_PATH} недоступен: CO-RE релокации невозможны, переносимость BPF-программы ограничена"
));
}
if kernel_version.is_some_and(|v| v < KernelVersion::new(5, 11, 0)) {
warnings.push(
"ядро < 5.11: память BPF-карт ограничена RLIMIT_MEMLOCK — увеличьте `ulimit -l` или обновите ядро"
.to_owned(),
);
}
if driver_name.is_none() {
warnings.push(format!(
"драйвер интерфейса '{interface}' не определён (нет device/driver symlink): ожидается generic mode"
));
}
Self {
kernel_version,
btf_available,
privileged,
driver_name,
warnings,
}
}
/// Ожидаемый режим attach по таблице драйверов.
#[must_use]
pub fn attach_mode(&self) -> AttachMode {
match self.driver_name.as_deref() {
Some(driver) if driver_supports_native_xdp(driver) => AttachMode::Native,
_ => AttachMode::Generic,
}
}
/// Человекочитаемое объяснение вердикта.
#[must_use]
pub fn verdict(&self) -> String {
let kernel = self
.kernel_version
.map_or_else(|| "неизвестна".to_owned(), |v| v.to_string());
match (self.attach_mode(), self.driver_name.as_deref()) {
(AttachMode::Native, Some(driver)) => format!(
"ядро {kernel}: драйвер '{driver}' поддерживает native XDP — программа работает в драйвере, минимальные накладные расходы"
),
(_, driver) => format!(
"ядро {kernel}: драйвер '{}' не поддерживает native XDP → будет generic mode, CPU дороже",
driver.unwrap_or("неизвестный")
),
}
}
/// Fail-fast проверка минимальной версии ядра.
///
/// # Errors
/// Версия ядра не определена или ниже [`MIN_KERNEL`].
pub fn validate(&self) -> Result<()> {
let Some(version) = self.kernel_version else {
bail!("не удалось определить версию ядра ({OSRELEASE_PATH}) — XDP attach отклонён");
};
if version < MIN_KERNEL {
bail!("ядро {version} ниже минимально поддерживаемой {MIN_KERNEL}: XDP attach отклонён, обновите ядро");
}
Ok(())
}
}
/// Предстартовая диагностика перед загрузкой XDP-программы:
/// структурный отчёт в лог, предупреждения, fail-fast на старом ядре.
///
/// # Errors
/// См. [`EnvironmentReport::validate`].
#[cfg(feature = "xdp")]
pub(crate) fn preflight(interface: &str) -> Result<()> {
let report = EnvironmentReport::collect(&FilesystemProbe, interface);
let kernel = report
.kernel_version
.map_or_else(|| "unknown".to_owned(), |v| v.to_string());
tracing::info!(
interface,
kernel = %kernel,
btf = report.btf_available,
privileged = report.privileged,
driver = report.driver_name.as_deref().unwrap_or("unknown"),
mode = %report.attach_mode(),
"XDP environment: {}",
report.verdict()
);
for warning in &report.warnings {
tracing::warn!(interface, "{warning}");
}
report.validate()
}

58
src/xdp/filter/attach.rs Normal file
View file

@ -0,0 +1,58 @@
use anyhow::{Context, Result};
use libbpf_rs::{MapCore, Object, OpenObject, RingBuffer, RingBufferBuilder};
use std::ffi::CString;
use crate::xdp::globals::{RODATA_MAP_NAME, XdpGlobals};
/// Builds the NUL-terminated C string libc's `if_nametoindex` requires.
/// Rejects interface names containing an interior NUL byte instead of
/// silently truncating at it (the `&str`-as-`*const c_char` UB we removed).
pub(super) fn interface_cstr(name: &str) -> Result<CString> {
CString::new(name).with_context(|| format!("interface name {name:?} contains an interior NUL byte"))
}
/// Патчит карту `rampart_.rodata` (volatile const глобалы) ДО загрузки объекта.
pub(super) fn patch_rodata(open_obj: &mut OpenObject, globals: &XdpGlobals) -> Result<()> {
let image = globals.build_rodata_image();
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"))?;
map.set_initial_value(&image)
.with_context(|| format!("failed to set initial value of '{RODATA_MAP_NAME}'"))
}
pub(super) fn build_ringbuf(obj: &Object) -> Result<RingBuffer<'static>> {
let map = obj
.maps()
.find(|m| m.name() == "events_map")
.context("events_map not found")?;
let mut builder = RingBufferBuilder::new();
builder.add(&map, |data: &[u8]| {
if data.len() >= 16 {
let ty = u32::from_ne_bytes(data[0..4].try_into().expect("4 bytes for type"));
let ip4 = u32::from_ne_bytes(data[4..8].try_into().expect("4 bytes for ip"));
let val = u64::from_ne_bytes(data[8..16].try_into().expect("8 bytes for val"));
tracing::debug!(event = ty, src_ip = ip4, data = val, "xdp event");
}
0
})?;
Ok(builder.build()?)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn interface_cstr_accepts_plain_names() {
let c = interface_cstr("eth0").expect("valid interface name");
assert_eq!(c.as_bytes(), b"eth0");
assert_eq!(c.as_bytes_with_nul(), b"eth0\0");
}
#[test]
fn interface_cstr_rejects_interior_nul() {
assert!(interface_cstr("et\0h0").is_err());
}
}

43
src/xdp/filter/maps.rs Normal file
View file

@ -0,0 +1,43 @@
pub(super) fn unix_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64
}
/// Encodes an LPM-trie key for `blacklist_map`: prefix length in byte 0 and the
/// four IPv4 octets in bytes 4..8 (bytes 1..3 are padding the kernel ignores).
pub(super) fn blacklist_key(prefix_len: u8, v4: &[u8; 4]) -> [u8; 8] {
let mut key = [0u8; 8];
key[0] = prefix_len;
key[4..8].copy_from_slice(v4);
key
}
/// Absolute ban expiry in ns-since-epoch, saturating so an absurd duration
/// clamps to `u64::MAX` rather than wrapping into the past.
pub(super) fn blacklist_expiry_ns(now_ns: u64, duration_secs: u64) -> u64 {
now_ns.saturating_add(duration_secs.saturating_mul(1_000_000_000))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn blacklist_key_puts_prefix_len_first_and_octets_last() {
assert_eq!(blacklist_key(24, &[10, 0, 1, 200]), [24, 0, 0, 0, 10, 0, 1, 200]);
assert_eq!(blacklist_key(32, &[127, 0, 0, 1]), [32, 0, 0, 0, 127, 0, 0, 1]);
}
#[test]
fn blacklist_expiry_converts_secs_to_ns() {
assert_eq!(blacklist_expiry_ns(1_000, 2), 1_000 + 2_000_000_000);
}
#[test]
fn blacklist_expiry_saturates_instead_of_wrapping() {
assert_eq!(blacklist_expiry_ns(u64::MAX, 1), u64::MAX);
assert_eq!(blacklist_expiry_ns(0, u64::MAX), u64::MAX);
}
}

View file

@ -1,13 +1,19 @@
use anyhow::{Context, Result, bail};
use libbpf_rs::{MapCore, MapFlags, Object, ObjectBuilder, OpenObject, RingBuffer, RingBufferBuilder, Xdp, XdpFlags};
use libbpf_rs::{MapCore, MapFlags, Object, ObjectBuilder, RingBuffer, Xdp, XdpFlags};
use std::net::IpAddr;
use std::net::Ipv4Addr;
use std::os::unix::io::AsFd;
use super::XdpStats;
use super::globals::{RODATA_MAP_NAME, XdpGlobals};
use super::globals::XdpGlobals;
use crate::traffic::prefix::{PrefixKey, PrefixStatsVal};
use attach::{build_ringbuf, interface_cstr, patch_rodata};
use maps::{blacklist_expiry_ns, blacklist_key, unix_ns};
mod attach;
mod maps;
pub struct XdpFilter {
obj: Option<Object>,
ringbuf: Option<RingBuffer<'static>>,
@ -34,7 +40,7 @@ impl XdpFilter {
pub fn load(&mut self) -> Result<()> {
// Диагностика ДО загрузки: fail-fast на неподдерживаемом ядре/драйвере.
super::diagnostics::preflight(&self.interface)?;
super::probe::preflight(&self.interface)?;
let bpf_obj = include_bytes!(concat!(env!("OUT_DIR"), "/universal_filter.o"));
let mut open_obj = ObjectBuilder::default()
@ -43,9 +49,15 @@ impl XdpFilter {
patch_rodata(&mut open_obj, &self.globals)?;
let obj = open_obj.load().context("Failed to load XDP object (verifier error?)")?;
let ifindex = unsafe { libc::if_nametoindex(self.interface.as_ptr() as *const libc::c_char) };
let c_interface = interface_cstr(&self.interface)?;
// SAFETY: `c_interface` is a valid, NUL-terminated C string and is kept
// alive until the end of this scope (past the `if_nametoindex` call), so
// the raw pointer satisfies the `const char *` contract of the libc FFI.
// Rust `&str` is NOT NUL-terminated, hence the `CString` conversion above.
let ifindex = unsafe { libc::if_nametoindex(c_interface.as_ptr()) };
if ifindex == 0 {
bail!("interface '{}' not found", self.interface);
let err = std::io::Error::last_os_error();
bail!("interface '{}' not found: {err}", self.interface);
}
let prog = obj
@ -64,9 +76,8 @@ impl XdpFilter {
}
pub fn unload(&mut self) -> Result<()> {
if self.ifindex != 0 {
let fd = unsafe { std::os::unix::io::BorrowedFd::borrow_raw(std::os::unix::io::RawFd::from(-1)) };
let _ = Xdp::new(fd).detach(self.ifindex, XdpFlags::NONE);
if let Some(obj) = self.obj.as_ref() {
self.detach_program(obj)?;
}
self.ringbuf = None;
self.obj = None;
@ -75,6 +86,28 @@ impl XdpFilter {
Ok(())
}
/// Detaches the XDP program using the real program fd owned by the loaded
/// `Object` (mirroring `load`'s `Xdp::new(prog.as_fd()).attach(..)`). A
/// detach failure while a filter is active is surfaced loudly and returned,
/// never swallowed — otherwise the kernel keeps dropping packets we no
/// longer intend to filter.
fn detach_program(&self, obj: &Object) -> Result<()> {
let prog = obj
.progs()
.find(|p| p.name() == "rampart_universal_filter")
.context("XDP program 'rampart_universal_filter' not found while detaching")?;
if let Err(e) = Xdp::new(prog.as_fd()).detach(self.ifindex, XdpFlags::NONE) {
tracing::error!(
interface = %self.interface,
ifindex = self.ifindex,
error = %e,
"XDP detach failed while filter was active"
);
return Err(e).with_context(|| format!("failed to detach XDP from '{}'", self.interface));
}
Ok(())
}
pub fn drain_events(&self) {
if let Some(rb) = &self.ringbuf {
let _ = rb.consume();
@ -107,22 +140,15 @@ impl XdpFilter {
fn blacklist_update(&self, prefix_len: u8, v4: &[u8; 4], duration_secs: u64) -> Result<()> {
let map = self.find_map("blacklist_map")?;
let mut key = [0u8; 8];
key[0] = prefix_len;
key[4..8].copy_from_slice(v4);
map.update(
&key,
&(unix_ns() + duration_secs * 1_000_000_000).to_le_bytes(),
MapFlags::ANY,
)?;
let key = blacklist_key(prefix_len, v4);
let expiry_ns = blacklist_expiry_ns(unix_ns(), duration_secs);
map.update(&key, &expiry_ns.to_le_bytes(), MapFlags::ANY)?;
Ok(())
}
pub fn unban_ip(&self, ip: Ipv4Addr) -> Result<()> {
let map = self.find_map("blacklist_map")?;
let mut key = [0u8; 8];
key[0] = 32;
key[4..8].copy_from_slice(&ip.octets());
let key = blacklist_key(32, &ip.octets());
map.delete(&key)?;
Ok(())
}
@ -175,6 +201,11 @@ impl XdpFilter {
}
}
// SAFETY: `XdpFilter` is only ever accessed from the single thread that owns it,
// held behind `Arc<Mutex<XdpFilter>>`; that Mutex serialization is the invariant
// that makes moving it across threads sound. Auto-`Send` is not derived because
// `libbpf_rs::Object` and the `RingBuffer<'static>` wrap non-`Send` libbpf
// handles, so the marker is asserted manually under the ownership discipline above.
unsafe impl Send for XdpFilter {}
impl Drop for XdpFilter {
@ -182,39 +213,3 @@ impl Drop for XdpFilter {
let _ = self.unload();
}
}
fn unix_ns() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos() as u64
}
/// Патчит карту `rampart_.rodata` (volatile const глобалы) ДО загрузки объекта.
fn patch_rodata(open_obj: &mut OpenObject, globals: &XdpGlobals) -> Result<()> {
let image = globals.build_rodata_image();
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"))?;
map.set_initial_value(&image)
.with_context(|| format!("failed to set initial value of '{RODATA_MAP_NAME}'"))
}
fn build_ringbuf(obj: &Object) -> Result<RingBuffer<'static>> {
let map = obj
.maps()
.find(|m| m.name() == "events_map")
.context("events_map not found")?;
let mut builder = RingBufferBuilder::new();
builder.add(&map, |data: &[u8]| {
if data.len() >= 16 {
let ty = u32::from_ne_bytes(data[0..4].try_into().expect("4 bytes for type"));
let ip4 = u32::from_ne_bytes(data[4..8].try_into().expect("4 bytes for ip"));
let val = u64::from_ne_bytes(data[8..16].try_into().expect("8 bytes for val"));
tracing::debug!(event = ty, src_ip = ip4, data = val, "xdp event");
}
0
})?;
Ok(builder.build()?)
}

View file

@ -1,10 +1,9 @@
//! Загрузчик XDP-программы (L3/L4-уровень защиты).
mod stats;
pub use stats::XdpStats;
mod diagnostics;
pub use diagnostics::{
AttachMode, EnvironmentReport, FilesystemProbe, KernelVersion, MIN_KERNEL, SystemProbe, driver_supports_native_xdp,
mod probe;
pub use probe::{
AttachMode, EnvironmentReport, FilesystemProbe, KernelVersion, MIN_KERNEL, SystemProbe, XdpStats,
driver_supports_native_xdp,
};
#[cfg(feature = "xdp")]
@ -18,9 +17,7 @@ mod filter;
pub use filter::XdpFilter;
#[cfg(feature = "xdp")]
mod metrics;
#[cfg(feature = "xdp")]
pub use metrics::XdpMetrics;
pub use probe::XdpMetrics;
#[cfg(not(feature = "xdp"))]
mod noop;

View file

@ -0,0 +1,13 @@
//! Диагностика окружения перед XDP-attach: ядро, BTF, драйвер NIC, привилегии.
//!
//! Основной источник боли при XDP — непонятные ошибки attach на неподдерживаемых
//! ядрах и драйверах. Модуль собирает [`EnvironmentReport`] ДО загрузки BPF-программы
//! и даёт человекочитаемый вердикт с предупреждениями.
mod report;
mod types;
pub use report::EnvironmentReport;
#[cfg(feature = "xdp")]
pub(crate) use report::preflight;
pub use types::{AttachMode, FilesystemProbe, KernelVersion, MIN_KERNEL, SystemProbe, driver_supports_native_xdp};

View file

@ -0,0 +1,130 @@
use anyhow::{Result, bail};
use super::types::{
AttachMode, BTF_PATH, FilesystemProbe, KernelVersion, MIN_KERNEL, OSRELEASE_PATH, SystemProbe, driver_from_link,
driver_supports_native_xdp,
};
/// Структурированный отчёт о пригодности окружения для XDP.
pub struct EnvironmentReport {
pub kernel_version: Option<KernelVersion>,
pub btf_available: bool,
pub privileged: bool,
pub driver_name: Option<String>,
pub warnings: Vec<String>,
}
impl EnvironmentReport {
/// Собирает отчёт через произвольную реализацию [`SystemProbe`].
#[must_use]
pub fn collect(probe: &dyn SystemProbe, interface: &str) -> Self {
let kernel_version = probe
.read_file(OSRELEASE_PATH)
.ok()
.and_then(|r| KernelVersion::parse(r.trim()));
let btf_available = probe.path_exists(BTF_PATH);
let privileged = probe.effective_uid() == 0;
let driver_link = probe.symlink_target(&format!("/sys/class/net/{interface}/device/driver"));
let driver_name = driver_link.as_deref().and_then(driver_from_link);
let mut warnings = Vec::new();
if !privileged {
warnings.push(
"процесс не от root: CAP_BPF/CAP_NET_ADMIN не подтверждены, \
attach скорее всего завершится EPERM"
.to_owned(),
);
}
if !btf_available {
warnings.push(format!(
"{BTF_PATH} недоступен: CO-RE релокации невозможны, переносимость BPF-программы ограничена"
));
}
if kernel_version.is_some_and(|v| v < KernelVersion::new(5, 11, 0)) {
warnings.push(
"ядро < 5.11: память BPF-карт ограничена RLIMIT_MEMLOCK — увеличьте `ulimit -l` или обновите ядро"
.to_owned(),
);
}
if driver_name.is_none() {
warnings.push(format!(
"драйвер интерфейса '{interface}' не определён (нет device/driver symlink): ожидается generic mode"
));
}
Self {
kernel_version,
btf_available,
privileged,
driver_name,
warnings,
}
}
/// Ожидаемый режим attach по таблице драйверов.
#[must_use]
pub fn attach_mode(&self) -> AttachMode {
match self.driver_name.as_deref() {
Some(driver) if driver_supports_native_xdp(driver) => AttachMode::Native,
_ => AttachMode::Generic,
}
}
/// Человекочитаемое объяснение вердикта.
#[must_use]
pub fn verdict(&self) -> String {
let kernel = self
.kernel_version
.map_or_else(|| "неизвестна".to_owned(), |v| v.to_string());
match (self.attach_mode(), self.driver_name.as_deref()) {
(AttachMode::Native, Some(driver)) => format!(
"ядро {kernel}: драйвер '{driver}' поддерживает native XDP — программа работает в драйвере, минимальные накладные расходы"
),
(_, driver) => format!(
"ядро {kernel}: драйвер '{}' не поддерживает native XDP → будет generic mode, CPU дороже",
driver.unwrap_or("неизвестный")
),
}
}
/// Fail-fast проверка минимальной версии ядра.
///
/// # Errors
/// Версия ядра не определена или ниже [`MIN_KERNEL`].
pub fn validate(&self) -> Result<()> {
let Some(version) = self.kernel_version else {
bail!("не удалось определить версию ядра ({OSRELEASE_PATH}) — XDP attach отклонён");
};
if version < MIN_KERNEL {
bail!("ядро {version} ниже минимально поддерживаемой {MIN_KERNEL}: XDP attach отклонён, обновите ядро");
}
Ok(())
}
}
/// Предстартовая диагностика перед загрузкой XDP-программы:
/// структурный отчёт в лог, предупреждения, fail-fast на старом ядре.
///
/// # Errors
/// См. [`EnvironmentReport::validate`].
#[cfg(feature = "xdp")]
pub(crate) fn preflight(interface: &str) -> Result<()> {
let report = EnvironmentReport::collect(&FilesystemProbe, interface);
let kernel = report
.kernel_version
.map_or_else(|| "unknown".to_owned(), |v| v.to_string());
tracing::info!(
interface,
kernel = %kernel,
btf = report.btf_available,
privileged = report.privileged,
driver = report.driver_name.as_deref().unwrap_or("unknown"),
mode = %report.attach_mode(),
"XDP environment: {}",
report.verdict()
);
for warning in &report.warnings {
tracing::warn!(interface, "{warning}");
}
report.validate()
}

View file

@ -0,0 +1,154 @@
use std::fmt;
/// Минимально поддерживаемая версия ядра: 5.15 LTS.
pub const MIN_KERNEL: KernelVersion = KernelVersion::new(5, 15, 0);
pub(super) const OSRELEASE_PATH: &str = "/proc/sys/kernel/osrelease";
pub(super) const BTF_PATH: &str = "/sys/kernel/btf/vmlinux";
const STATUS_PATH: &str = "/proc/self/status";
/// Драйверы с известной поддержкой native XDP (по данным xdpgeneric/native
/// матриц upstream-ядра). Отсутствие в списке не означает отсутствие поддержки,
/// но для таких драйверов предсказываем generic mode.
const NATIVE_XDP_DRIVERS: &[&str] = &[
"virtio_net",
"ixgbe",
"ixgbevf",
"i40e",
"iavf",
"ice",
"mlx4_core",
"mlx5_core",
"igb",
"igc",
"e1000e",
"vmxnet3",
"bnxt_en",
"nfp",
"sfc",
"ena",
"hv_netvsc",
"macvlan",
];
/// Версия ядра Linux, пригодная для сравнения (`major.minor.patch`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct KernelVersion {
pub major: u16,
pub minor: u16,
pub patch: u16,
}
impl KernelVersion {
#[must_use]
pub const fn new(major: u16, minor: u16, patch: u16) -> Self {
Self { major, minor, patch }
}
/// Парсит строку формата `uname -r`. Берёт первые три числовых компонента,
/// дистрибутивные суффиксы (`6.8.0-45-generic`, `5.15.0-rc2`) отбрасываются.
/// Строка без цифр не парсится.
#[must_use]
pub fn parse(release: &str) -> Option<Self> {
let mut nums = release
.split(|c: char| !c.is_ascii_digit())
.filter_map(|part| part.parse::<u16>().ok());
let major = nums.next()?;
let minor = nums.next().unwrap_or(0);
let patch = nums.next().unwrap_or(0);
Some(Self::new(major, minor, patch))
}
}
impl fmt::Display for KernelVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
/// Режим присоединения XDP-программы к интерфейсу.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttachMode {
/// Программа исполняется в контексте драйвера NIC — минимальные накладные расходы.
Native,
/// Программа вызывается из сетевого стека после ingress — работает везде, дороже по CPU.
Generic,
/// Аппаратная разгрузка в NIC — требует явного включения и поддержки железа,
/// автоматически не выбирается.
Offload,
}
impl fmt::Display for AttachMode {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Native => "native",
Self::Generic => "generic",
Self::Offload => "offload",
})
}
}
/// Абстракция над procfs/sysfs: реальная реализация — [`FilesystemProbe`],
/// в тестах — мок. Изолирует диагностику от реального ядра.
pub trait SystemProbe {
/// Читает файл целиком.
///
/// # Errors
/// Проксирует ошибку чтения файла.
fn read_file(&self, path: &str) -> std::io::Result<String>;
#[must_use]
fn path_exists(&self, path: &str) -> bool;
#[must_use]
fn symlink_target(&self, path: &str) -> Option<String>;
#[must_use]
fn effective_uid(&self) -> u32;
}
/// Реальная реализация [`SystemProbe`] поверх `std::fs`.
pub struct FilesystemProbe;
impl SystemProbe for FilesystemProbe {
fn read_file(&self, path: &str) -> std::io::Result<String> {
std::fs::read_to_string(path)
}
fn path_exists(&self, path: &str) -> bool {
std::path::Path::new(path).exists()
}
fn symlink_target(&self, path: &str) -> Option<String> {
Some(std::fs::read_link(path).ok()?.to_string_lossy().into_owned())
}
/// Эвристика: эффективный uid берётся из `/proc/self/status`.
/// root почти всегда имеет CAP_BPF/CAP_NET_ADMIN (если явно не урезаны);
/// для не-root подтверждение возможно только попыткой attach.
/// Нечитаемый статус трактуем как непривилегированный процесс.
fn effective_uid(&self) -> u32 {
std::fs::read_to_string(STATUS_PATH)
.ok()
.and_then(|status| parse_euid(&status))
.unwrap_or(1)
}
}
fn parse_euid(status: &str) -> Option<u32> {
status.lines().find_map(|line| {
let mut fields = line.strip_prefix("Uid:")?.split_whitespace();
fields.nth(1)?.parse().ok()
})
}
pub(super) fn driver_from_link(target: &str) -> Option<String> {
let name = target.rsplit('/').next()?;
(!name.is_empty()).then(|| name.to_owned())
}
/// Поддерживает ли драйвер native XDP (по таблице известных драйверов).
#[must_use]
pub fn driver_supports_native_xdp(driver: &str) -> bool {
NATIVE_XDP_DRIVERS.contains(&driver)
}

16
src/xdp/probe/mod.rs Normal file
View file

@ -0,0 +1,16 @@
//! Зондирование XDP-окружения: диагностика, статистика и метрики.
mod diagnostics;
#[cfg(feature = "xdp")]
pub(crate) use diagnostics::preflight;
pub use diagnostics::{
AttachMode, EnvironmentReport, FilesystemProbe, KernelVersion, MIN_KERNEL, SystemProbe, driver_supports_native_xdp,
};
mod stats;
pub use stats::XdpStats;
#[cfg(feature = "xdp")]
mod metrics;
#[cfg(feature = "xdp")]
pub use metrics::XdpMetrics;