Rust 因其安全特性和优秀的 C 互操作性(Interoperability),成为流行的系统编程语言。在 eBPF 的上下文中,安全特性不重要。因为程序通常需要读取内核内存,该操作被认为是不安全的。Rust 结合 Aya 所提供的是快速、高效的开发体验:
用于项目脚手架、构建、测试和调试的 Cargo
带 CO-RE(Compile-Once,Run-Everywhere)支持的 Rust 内核头文件绑定的生成
在用户空间和 eBPF 程序间共享代码
快速的编译时间
没有对 LLVM、BCC 或 libbpf 的运行时依赖
运行 eBPF 程序的 eBPF 虚拟机是受限制的运行时环境:
栈空间仅为 512 字节(如果使用尾调用,那么为 256 字节)。
没有访问堆空间的权限,数据必须被写到映射(maps)中。
即便使用 C 编写的应用程序也被限制为语言功能的子集,在 Rust 中,Aya 也有类似的限制:
不使用标准库,使用 core
代替。
不能使用 core::fmt
以及依赖它的特性,例如 Display
和 Debug
。
因为没有堆,所以不能使用 alloc
或 collections
。
不能 panic
,因为 eBPF VM 不支持栈展开(stack unwinding),或 abort
指令。
没有 main
函数。
除此之外,我们编写的很多代码都是 unsafe
,因为我们直接从内核内存读取。
在开始前,需要在系统上安装 Rust stable 和 nightly toolchain。通过 rustup
实现:
rustup install stable
rustup toolchain install nightly --component rust-src
安装 Rust toolchain 后,必须安装 bpf-linker
。该链接器依赖 LLVM,如果运行在 Linux x86_64 系统上,那么可以根据 Rust toolchain 附带的版本构建:
cargo install bpf-linker
补充:
在 Ubuntu 上,需要安装依赖:
apt install -y build-essential libssl-dev
如果运行在 macos 或其它架构的 Linux 上,那么需要先安装 LLVM 16,然后安装链接器:
cargo install --no-default-features bpf-linker
为生成脚手架,需要安装 cargo-generate
:
cargo install cargo-generate
补充:
在 Ubuntu 上,需要安装依赖:
apt install pkgconf
最后,为生成内核数据结构的绑定,必须安装 bpftool
(从发行版或从 source 构建)。
运行在 Ubuntu 20.04 LTS (Focal) 上?
如果运行在 Ubuntu 20.04 上,发行版安装的 bpftool 和默认内核存在 Bug。为避免遇到该问题,可以安装更新的、不包含该 Bug 的 bpftool 版本:
sudo apt install linux-tools-5.8.0-63-generic
export PATH=/usr/lib/linux-tools/5.8.0-63-generic:$PATH
使用 cargo-generate
开始新项目:
cargo generate https://github.com/aya-rs/aya-template
该命令将提示输入项目名称 - 在本例中使用 myapp
。也将提示输入程序类型,并且可能根据所选类型,提示输入其它选项(比如,网络分类器的附加方向)。
可以直接从命令行设置模版选项,比如:
x
cargo generate --name myapp -d program_type=xdp https://github.com/aya-rs/aya-template
查看 the cargo-generate.toml file (in the aya-template repository) 获取可用选项的完整列表。
在本节中,将编写、构建以及运行简单的 eBPF/XDP 程序和用户空间应用程序。
源码
本章中的示例的完整代码在这里。
XDP(eXpress Data Path)程序允许 eBPF 程序对其被附加到的的接口上接收到的数据包做出决策。为简单起见,本示例构建非常简单的防火墙,以允许或拒绝流量。
首先编写 eBPF 组件。下面的最小化 XDP 程序允许全部流量。该程序的逻辑在 xdp-hello-ebpf/src/main.rs
,目前是这样的:
x
是必须的,因为我们不能使用标准库。
是必须的,因为我们没有主函数。
use aya_bpf::{bindings::xdp_action, macros::xdp, programs::XdpContext};
use aya_log_ebpf::info;
// 表示该函数是 XDP 程序。
pub fn xdp_hello(ctx: XdpContext) -> u32 {
// 主入口点委托给另一个函数,并且执行错误处理,返回 XDP_ABORTED 将丢弃数据包。
match unsafe { try_xdp_hello(ctx) } {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
unsafe fn try_xdp_hello(ctx: XdpContext) -> Result<u32, u32> {
// 收到数据包时,写日志条目
info!(&ctx, "received a packet");
// 该函数返回允许全部流量的 Result
Ok(xdp_action::XDP_PASS)
}
panic。 是必须的,以保持编译器正常工作,尽管从未使用,因为不能
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
现在使用 cargo xtask build-ebpf
进行编译。
接下来查看编译的 eBPF 程序:
x
$ llvm-objdump -S target/bpfel-unknown-none/debug/xdp-hello
target/bpfel-unknown-none/debug/xdp-hello: file format elf64-bpf
Disassembly of section .text:
...
00000000000007d0 <LBB0_2>:
250: b7 00 00 00 02 00 00 00 r0 = 2
251: 95 00 00 00 00 00 00 00 exit
为简洁起见,已删减输出。可以看到 xdp/xdp_hello
部分。在 <LBB0_2>
中,r0 = 2
将寄存器 0
设置为 2
(XDP_PASS
动作的值)。exit
结束程序。
编译 eBPF 程序后,需要用户空间程序加载它,并且将其附加到追踪点。该逻辑在 xdp-hello/src/main.rs
。
接下来查看用户空间应用程序的细节:
xxxxxxxxxx
use anyhow::Context;
use aya::{
include_bytes_aligned,
programs::{Xdp, XdpFlags},
Bpf,
};
use aya_log::BpfLogger;
use clap::Parser;
use log::info;
use tokio::signal; // tokio 是异步库,它提供 Ctrl-C 处理程序。随着扩展初始程序的功能,tokio 变得非常有用。
struct Opt {
iface: String, // 在此处声明 CLI 标记。现在只有 --iface,用于传递接口名称。
}
// 主入口点
async fn main() -> Result<(), anyhow::Error> {
let opt = Opt::parse();
env_logger::init();
// This will include your eBPF object file as raw bytes at compile-time and load it at
// runtime. This approach is recommended for most real-world use cases. If you would
// like to specify the eBPF program at runtime rather than at compile-time, you can
// reach for `Bpf::load_file` instead.
//
// include_bytes_aligned!() 在编译时拷贝 BPF ELF 目标文件的内容。
// Bpf::load() 从前一个命令的输出读取 BPF ELF 目标文件的内容,创建映射,执行 BTF 重定位。
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/debug/xdp-hello"
))?;
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/release/xdp-hello"
))?;
BpfLogger::init(&mut bpf)?;
// 抽取 XDP 程序。
let program: &mut Xdp = bpf.program_mut("xdp_hello").unwrap().try_into()?;
program.load()?; // 然后将其加载进内核。
// 最后将其附加到接口。
program.attach(&opt.iface, XdpFlags::default())
.context("failed to attach the XDP program with default flags - try changing XdpFlags::default() to XdpFlags::SKB_MODE")?;
info!("Waiting for Ctrl-C...");
signal::ctrl_c().await?;
info!("Exiting...");
Ok(())
}
尝试一下!
xxxxxxxxxx
$ cargo xtask run -- -h
Finished dev [unoptimized + debuginfo] target(s) in 0.05s
Running `target/debug/xtask run -- -h`
:
Finished dev [optimized] target(s) in 0.90s
Finished dev [unoptimized + debuginfo] target(s) in 0.60s
xdp-hello
USAGE:
xdp-hello [OPTIONS]
OPTIONS:
-h, --help Print help information
-i, --iface <IFACE> [default: eth0]
接口名称
该命令假定接口默认为
eth0
。如果希望附加到其它接口,使用RUST_LOG=info cargo xtask run -- --iface wlp2s0
,其中wlp2s0
是接口。
x
$ RUST_LOG=info cargo xtask run
[2022-12-21T18:03:09Z INFO xdp_hello] Waiting for Ctrl-C...
[2022-12-21T18:03:11Z INFO xdp_hello] received a packet
[2022-12-21T18:03:11Z INFO xdp_hello] received a packet
[2022-12-21T18:03:11Z INFO xdp_hello] received a packet
[2022-12-21T18:03:11Z INFO xdp_hello] received a packet
^C[2022-12-21T18:03:11Z INFO xdp_hello] Exiting...
每次在接口上收到数据包时,都打印一条日志。
加载程序出错?
如果加载程序出错,尝试将
XdpFlags::default()
更改为XdpFlags::SKB_MODE
。
程序一直运行,直到按下 CTRL + C。退出时,Aya 负责卸载程序。
当 xdp_hello
运行时,如果发出 sudo bpftool prog list
命令,可以验证 xdp_hello
已被加载:
xxxxxxxxxx
958: xdp name xdp_hello tag 0137ce4fce70b467 gpl
loaded_at 2022-06-23T13:55:28-0400 uid 0
xlated 2016B jited 1138B memlock 4096B map_ids 275,274,273
pids xdp-hello(131677)
当 xdp_hello
退出时,运行该命令将显示该程序不再运行。
前一章的 XDP 程序在按下 CTRL-C 之前,一直运行,并且允许全部流量。每次收到数据包时,该 eBPF 程序记录字符串“received a packet”。本章将展示如何解析数据包。
虽然可以将数据解析到 L7(应用层),但在示例中限制为 L3(网络层),并且为简化起见,仅限于 IPv4。
源代码
本章节中的示例的完整代码在这里。
我们将记录流入数据包的源 IP 地址,因此需要:
读取以太网头部,以确定是否正在处理 IPv4 数据包,否则终止解析。
从 IPv4 头部,读取源 IP 地址。
可以阅读这些协议的规范,手动解析,但是将使用 network-types 库,该库为许多常见的因特网协议提供类型定义。
通过在 xdp-log-ebpf/Cargo.toml
中添加 network-types
依赖的方式,将其添加到 eBPF 库:
xdp-log-ebpf/Cargo.toml:
xxxxxxxxxx
[package]
name = "xdp-log-ebpf"
version = "0.1.0"
edition = "2021"
[dependencies]
aya-bpf = { git = "https://github.com/aya-rs/aya" }
aya-log-ebpf = { git = "https://github.com/aya-rs/aya" }
xdp-log-common = { path = "../xdp-log-common" }
network-types = "0.0.4"
[[bin]]
name = "xdp-log"
path = "src/main.rs"
[profile.dev]
opt-level = 3
debug = false
debug-assertions = false
overflow-checks = false
lto = true
panic = "abort"
incremental = false
codegen-units = 1
rpath = false
[profile.release]
lto = true
panic = "abort"
codegen-units = 1
[workspace]
members = []
XdpContext
包含将要使用的两个字段:data
和 data_end
,它们分别是指向数据包开头和结尾的指针。
为访问数据包中的数据,并且确保以使 eBPF 验证器满意的方式进行访问,引入名为 ptr_at
的辅助函数。该函数保证在访问数据包数据之前,插入验证器所需的边界检查。
代码如下:
xdp-log-ebpf/src/main.rs:
xxxxxxxxxx
use aya_bpf::{bindings::xdp_action, macros::xdp, programs::XdpContext};
use aya_log_ebpf::info;
use core::mem;
use network_types::{
eth::{EthHdr, EtherType},
ip::{IpProto, Ipv4Hdr},
tcp::TcpHdr,
udp::UdpHdr,
};
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
pub fn xdp_firewall(ctx: XdpContext) -> u32 {
match try_xdp_firewall(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
// ptr_at 保证数据包访问始终经过边界检查
fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();
if start + offset + len > end {
return Err(());
}
Ok((start + offset) as *const T)
}
fn try_xdp_firewall(ctx: XdpContext) -> Result<u32, ()> {
let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?; // 使用 ptr_at 读取以太网头部
match unsafe { (*ethhdr).ether_type } {
EtherType::Ipv4 => {}
_ => return Ok(xdp_action::XDP_PASS),
}
let ipv4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN)?;
let source_addr = u32::from_be(unsafe { (*ipv4hdr).src_addr });
let source_port = match unsafe { (*ipv4hdr).proto } {
IpProto::Tcp => {
let tcphdr: *const TcpHdr =
ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
u16::from_be(unsafe { (*tcphdr).source })
}
IpProto::Udp => {
let udphdr: *const UdpHdr =
ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
u16::from_be(unsafe { (*udphdr).source })
}
_ => return Err(()),
};
// 记录 IP 和端口
info!(&ctx, "SRC IP: {:i}, SRC PORT: {}", source_addr, source_port);
Ok(xdp_action::XDP_PASS)
}
不要忘记重新构建 eBPF 程序。
用户空间代码如下:
xdp-log/src/main.rs:
xxxxxxxxxx
use anyhow::Context;
use aya::{
include_bytes_aligned,
programs::{Xdp, XdpFlags},
Bpf,
};
use aya_log::BpfLogger;
use clap::Parser;
use log::{info, warn};
use tokio::signal;
struct Opt {
iface: String,
}
async fn main() -> Result<(), anyhow::Error> {
let opt = Opt::parse();
env_logger::init();
// This will include your eBPF object file as raw bytes at compile-time and load it at
// runtime. This approach is recommended for most real-world use cases. If you would
// like to specify the eBPF program at runtime rather than at compile-time, you can
// reach for `Bpf::load_file` instead.
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/debug/xdp-log"
))?;
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/release/xdp-log"
))?;
if let Err(e) = BpfLogger::init(&mut bpf) {
// This can happen if you remove all log statements from your eBPF program.
warn!("failed to initialize eBPF logger: {}", e);
}
let program: &mut Xdp =
bpf.program_mut("xdp_firewall").unwrap().try_into()?;
program.load()?;
program.attach(&opt.iface, XdpFlags::default())
.context("failed to attach the XDP program with default flags - try changing XdpFlags::default() to XdpFlags::SKB_MODE")?;
info!("Waiting for Ctrl-C...");
signal::ctrl_c().await?;
info!("Exiting...");
Ok(())
}
与之前一样,可以通过将接口名作为参数的方式,覆盖接口,比如 RUST_LOG=info cargo xtask run -- --iface wlp2s0
。
xxxxxxxxxx
$ RUST_LOG=info cargo xtask run
[2022-12-22T11:32:21Z INFO xdp_log] SRC IP: 172.52.22.104, SRC PORT: 443
[2022-12-22T11:32:21Z INFO xdp_log] SRC IP: 172.52.22.104, SRC PORT: 443
[2022-12-22T11:32:21Z INFO xdp_log] SRC IP: 172.52.22.104, SRC PORT: 443
[2022-12-22T11:32:21Z INFO xdp_log] SRC IP: 172.52.22.104, SRC PORT: 443
[2022-12-22T11:32:21Z INFO xdp_log] SRC IP: 234.130.159.162, SRC PORT: 443
前面章节中的 XDP 程序只记录流量。在本节中,将扩展它,支持丢弃流量。
源代码
本章节中的示例的完整代码在这里。
为丢弃数据包,需要将要丢弃的 IP 地址的列表。为高效地查找它们,将使用 HashMap
持有它们。
我们将:
在 eBPF 程序中,创建充当阻止列表的 HashMap
通过 HashMap
检查数据包中的 IP 地址,以做出策略决策(通过或丢弃)
从用户空间向阻止列表添加条目
在 eBPF 代码中,创建名为 BLOCKLIST
的新 Map。为做出策略决策,需要在 HashMap
中查找源 IP 地址。如果存在,那么丢弃数据包,否则允许。函数 block_ip
实现该逻辑。
代码如下:
xdp-drop-ebpf/src/main.rs:
xxxxxxxxxx
use aya_bpf::{
bindings::xdp_action,
macros::{map, xdp},
maps::HashMap,
programs::XdpContext,
};
use aya_log_ebpf::info;
use core::mem;
use network_types::{
eth::{EthHdr, EtherType},
ip::Ipv4Hdr,
};
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
// 创建 Map
static BLOCKLIST: HashMap<u32, u32> =
HashMap::<u32, u32>::with_max_entries(1024, 0);
pub fn xdp_firewall(ctx: XdpContext) -> u32 {
match try_xdp_firewall(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();
if start + offset + len > end {
return Err(());
}
let ptr = (start + offset) as *const T;
Ok(&*ptr)
}
// 检查应该允许还是拒绝数据包
fn block_ip(address: u32) -> bool {
unsafe { BLOCKLIST.get(&address).is_some() }
}
fn try_xdp_firewall(ctx: XdpContext) -> Result<u32, ()> {
let ethhdr: *const EthHdr = unsafe { ptr_at(&ctx, 0)? };
match unsafe { (*ethhdr).ether_type } {
EtherType::Ipv4 => {}
_ => return Ok(xdp_action::XDP_PASS),
}
let ipv4hdr: *const Ipv4Hdr = unsafe { ptr_at(&ctx, EthHdr::LEN)? };
let source = u32::from_be(unsafe { (*ipv4hdr).src_addr });
// 返回正确的行为
let action = if block_ip(source) {
xdp_action::XDP_DROP
} else {
xdp_action::XDP_PASS
};
info!(&ctx, "SRC: {:i}, ACTION: {}", source, action);
Ok(action)
}
为添加要阻止的地址,需要先获取 BLOCKLIST
Map 的引用。拥有引用后,只需要调用 blocklist.insert()
。使用 IPv4Addr
类型表示 IP 地址,因为它易读,并且容易转换为 u32
。在本例中,将阻止来源于 1.1.1.1
的所有流量。
字节序
IP 地址在数据包中始终以网络字节序(大端序)进行编码。在本例中,在检查阻止列表之前,使用
u32::from_be
将其转换成主机字节序。因此,在用户空间中以主机字节序格式写 IP 地址是正确的。另外一种方式也可以生效:当从用户空间插入时,将 IP 转换为网络字节序,当从 eBPF 程序进行索引时,无需要转换。
用户空间代码如下:
xdp-drop/src/main.rs:
xxxxxxxxxx
use anyhow::Context;
use aya::{
include_bytes_aligned,
maps::HashMap,
programs::{Xdp, XdpFlags},
Bpf,
};
use aya_log::BpfLogger;
use clap::Parser;
use log::{info, warn};
use std::net::Ipv4Addr;
use tokio::signal;
struct Opt {
iface: String,
}
async fn main() -> Result<(), anyhow::Error> {
let opt = Opt::parse();
env_logger::init();
// This will include your eBPF object file as raw bytes at compile-time and load it at
// runtime. This approach is recommended for most real-world use cases. If you would
// like to specify the eBPF program at runtime rather than at compile-time, you can
// reach for `Bpf::load_file` instead.
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/debug/xdp-drop"
))?;
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/release/xdp-drop"
))?;
if let Err(e) = BpfLogger::init(&mut bpf) {
// This can happen if you remove all log statements from your eBPF program.
warn!("failed to initialize eBPF logger: {}", e);
}
let program: &mut Xdp =
bpf.program_mut("xdp_firewall").unwrap().try_into()?;
program.load()?;
program.attach(&opt.iface, XdpFlags::default())
.context("failed to attach the XDP program with default flags - try changing XdpFlags::default() to XdpFlags::SKB_MODE")?;
// 获取 Map 的引用
let mut blocklist: HashMap<_, u32, u32> =
HashMap::try_from(bpf.map_mut("BLOCKLIST").unwrap())?;
// 创建 IPv4Addr
let block_addr: u32 = Ipv4Addr::new(1, 1, 1, 1).try_into()?;
// 将其写到 Map
blocklist.insert(block_addr, 0, 0)?;
info!("Waiting for Ctrl-C...");
signal::ctrl_c().await?;
info!("Exiting...");
Ok(())
}
xxxxxxxxxx
$ RUST_LOG=info cargo xtask run
[2022-10-04T12:46:05Z INFO xdp_drop] SRC: 1.1.1.1, ACTION: 1
[2022-10-04T12:46:05Z INFO xdp_drop] SRC: 192.168.1.21, ACTION: 2
[2022-10-04T12:46:05Z INFO xdp_drop] SRC: 192.168.1.21, ACTION: 2
[2022-10-04T12:46:05Z INFO xdp_drop] SRC: 18.168.253.132, ACTION: 2
[2022-10-04T12:46:05Z INFO xdp_drop] SRC: 1.1.1.1, ACTION: 1
[2022-10-04T12:46:05Z INFO xdp_drop] SRC: 18.168.253.132, ACTION: 2
[2022-10-04T12:46:05Z INFO xdp_drop] SRC: 18.168.253.132, ACTION: 2
[2022-10-04T12:46:05Z INFO xdp_drop] SRC: 1.1.1.1, ACTION: 1
[2022-10-04T12:46:05Z INFO xdp_drop] SRC: 140.82.121.6, ACTION: 2
Linux XDP(eXpress Data Path)是一种高性能的数据包处理技术,它在 Linux 内核中引入新数据包处理路径。XDP 允许用户空间程序在数据包到达网络设备驱动程序之前拦截和处理数据包,从而实现低延迟、高吞吐量的数据包处理。
XDP 的主要特点和优势包括:
低延迟和高吞吐量:XDP 在内核中使用高效的数据结构和处理方式,使数据包处理能够以非常低的延迟和高吞吐量进行,适用于对网络性能要求较高的场景。
内核级别处理:XDP 提供在内核空间中进行数据包处理的机制,避免从用户空间到内核空间的频繁上下文切换,提高处理效率。
灵活的数据包处理:使用 XDP,可以编写自定义的数据包处理程序,对数据包进行过滤、修改、丢弃等操作,实现各种网络功能,比如防火墙、负载均衡、数据包采集等。
与网络设备驱动程序紧密集成:XDP 与网络设备驱动程序直接集成,可以在设备驱动程序中执行数据包处理逻辑,避免额外的数据包拷贝和处理开销。
支持多种程序语言和工具:XDP 支持使用多种编程语言编写数据包处理程序,如 C、C++、Rust,同时提供辅助工具和库,如 libbpf 和 bpftool,简化开发和调试过程。
XDP 技术的应用场景包括数据中心网络加速、DDoS 防护、高性能网络函数、智能网卡等。通过利用 XDP 技术,可以在 Linux 网络栈中实现更高效、更灵活的数据包处理,满足高性能网络应用的需求。