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:
parent
d6bcae54c8
commit
aa615a1141
64 changed files with 2052 additions and 1408 deletions
|
|
@ -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
58
src/xdp/filter/attach.rs
Normal 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
43
src/xdp/filter/maps.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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()?)
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
13
src/xdp/probe/diagnostics/mod.rs
Normal file
13
src/xdp/probe/diagnostics/mod.rs
Normal 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};
|
||||
130
src/xdp/probe/diagnostics/report.rs
Normal file
130
src/xdp/probe/diagnostics/report.rs
Normal 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()
|
||||
}
|
||||
154
src/xdp/probe/diagnostics/types.rs
Normal file
154
src/xdp/probe/diagnostics/types.rs
Normal 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
16
src/xdp/probe/mod.rs
Normal 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue