[ebpf] aya rust dropping packets 실습 및 설명
시간이 생겨서 오랜만에 글을 쓰고 싶어서 이렇게 주제를 잡고 글을 써보려고 합니다.
https://aya-rs.dev/book/start/dropping-packets/
우선 이번 글은 aya rust 를 활용한 ebpf 실습 예제 코드들을 간단하게 따라 해보고 그 코드들에 대해서 간단하게 설명을 작성 해보려고 합니다. 틀린 부분이 있으면 언제든지 편하게 알려 주시면 감사하겠습니다.
* 이 글은 현재 rust 가 설치되어 있는 것을 가정하고 작성하고 있습니다.
우선 aya 에 대한 설명은
위 링크에 자세하게 작성되어 있지만 간단하게 말하자면 ebpf 코드를 간단하게 build, attach 하기 위해서 사용하는 rust 기반의 오픈 소스 입니다. rust 를 사용해야 하기 때문에 이에 대해서 rust 에 대해서 잘 모르시는 경우에는 다른 툴을 사용하는것을 권유 해드립니다.
하지만 제가 사용해본 결과 매우 매력적인 툴이어서 소개합니다.
만드려는 프로젝트는 xdp 로 들어오는 패킷들의 아이피를 확인해서 블락하는 프로젝트 입니다.
* ebpf 코드 작성
* 네트워크 eth, ip header 에 대한 이해
* rust 코드 작성
이렇게 3가지에 대해서 말해보고자 합니다.
그러면 우선 프로젝트를 설정 해보겠습니다.
1. Prerequisite ( 선행 되야 하는 사항 )
rust stable , nigthly toolchain 이 필요하다고 합니다.
rustup install stable
rustup toolchain install nightly --component rust-src
bpf-linker 또한 설치 해줍니다. linux x86_64 시스템에서는 아래와 같이
cargo install bpf-linker
linux 가 아닌 다른 시스템에서는
cargo install --no-default-features bpf-linker
참고로 저는 mac 에서 테스트 했을때 정상적으로 작동했습니다.
그리고 template 을 만들기 위해서 cargo-generate 를 설치 해줍니다.
cargo install cargo-generate
이로써 모든 준비는 마쳤습니다.
2. 프로젝트 생성
aya 에 대해서 매력을 느낀 부분이 바로 이 template 을 통한 프로젝트 생성 이었습니다.
기본적으로 프로젝트 생성은
cargo generate https://github.com/aya-rs/aya-template
위 명령어를 통해서 생성 되는데 ebpf 프로그램의 특성상 xdp, kprobe 와 같은 프로그램들에 코드를 attach 해서 event 를 기반으로 해서 프로그램이 동작되어야 하는데 해당 템플릿을 통해서 간단하게 작성하고 만들 수 있습니다.
또한 옵션을 줘서 바로 작성하는 방안도 있습니다.
cargo generate --name myapp -d program_type=xdp https://github.com/aya-rs/aya-template
위와 같이 옵션을 주면 myapp 이라는 이름의 xdp 프로그램이 완성 됩니다.
* myapp = user space 에서 사용하게 될 프로그램
* myapp-ebpf = ebpf 코드가 작성 되어 있는 곳
* xtask = 프로그램 실행 cmd tool
이렇게만 알아도 오늘의 예시를 돌리는데는 문제가 없어서 우선 이렇게 정리 해두었습니다. 추가적으로 이해를 하면 나머지 것들도 작성해서 올리겠습니다.
우선 이번 실습은 xdp 에서 발생하는 이벤트를 확인하는 것이 목적입니다.
만들어진 myapp/src/main.rs 파일에 들어가면 현재 어떠한 인터페이스에 들어오는 패킷들을 확인할지 정하는 코드가 있습니다.
line 9 ~ 13
#[derive(Debug, Parser)]
struct Opt {
#[clap(short, long, default_value = "eth0")]
iface: String,
}
위와 같이 default 값은 eth0 로 지정되어 있습니다. 저의 경우 가상 머신의 인터페이스를 사용하기 때문에 저는 "enp0s1" 으로 지정 해주었습니다.
이후 부터는 myapp-ebpf/src/main.rs 을 중점적으로 변경하게 됩니다.
코드를 보면 간단 합니다.
#![no_std]
#![no_main]
use aya_ebpf::{bindings::xdp_action, macros::xdp, programs::XdpContext};
use aya_log_ebpf::info;
#[xdp]
pub fn myapp(ctx: XdpContext) -> u32 {
match try_myapp(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
fn try_myapp(ctx: XdpContext) -> Result<u32, u32> {
info!(&ctx, "received a packet");
Ok(xdp_action::XDP_PASS)
}
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
#[xdp] 를 통해 해당 함수가 xdp 프로그램임을 명시하고 myapp 안에서 try_myapp 을 실행 시켜서 정상 작동시 info 매크로를 실행 시켜 로그를 작성 하는 간단한 함수가 있습니다.
우리의 목적은 들어오는 패킷을 파싱 하고 이후 ip 를 확인 및 block 아니면 pass 를 해야 합니다.
3. 패킷 파싱 ( packet parsing )
코드
fn try_myapp(ctx: XdpContext) -> Result<u32, ()> {
// ethernet part
let ethhdr: *const EthHdr = unsafe {ptr_at(&ctx, 0)? };
match unsafe {(*ethhdr).ether_type}{
EtherType::Ipv4 => {},
_ => return Ok(xdp_action::XDP_PASS),
}
//
// extract ip handling part
let ipv4hdr : *const Ipv4Hdr = unsafe { ptr_at(&ctx, EthHdr::LEN)?};
let source = u32::from_be(unsafe {(*ipv4hdr).src_addr});
//
// extract port handling part
let source_port = match unsafe {(*ipv4hdr).proto } {
IpProto::Tcp => {
let tcphdr: *const TcpHdr = unsafe {ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)?};
u16::from_be( unsafe { (*tcphdr).source})
},
IpProto::Udp => {
let udphdr: *const UdpHdr = unsafe { ptr_at(&ctx , EthHdr::LEN + Ipv4Hdr::LEN)?};
u16::from_be( unsafe { (*udphdr).source})
},
_ => return Err(()),
};
//
let action = if block_ip(source) {
xdp_action::XDP_DROP
} else {
xdp_action::XDP_PASS
};
info!(&ctx , "SRC: {:i} , ACTION: {}, PORT {}", source, action, source_port);
Ok(action)
}
우선 패킷 파싱을 위해서 xdp context 를 사용해야 합니다.
xdpcontext 는 ebpf 프로그램에서 XDP 와 관련된 컨텍스트 정보를 제공하는 구조체 입니다. 프로그램이 처리중인 패킷에 대한 메타 데이터를 제공 해주고 이를 통해서 적절한 처리를 수행할 있습니다.
우선 802.3 이더넷 프레임을 보면
출처 : https://en.wikipedia.org/wiki/Ethernet_frame
// ethernet part
위와 같이 MAC destination, MAC source , tag , type, payload , FCS 가 있습니다.
우리가 주목 해야 할거는 ether_type 입니다.
우선 들어오게 되면 해당 패킷의 타입이 ip 인지 확인 하고 그럴 경우 해당 패킷을 처리 해주어야 합니다.
그래서 예제에서는 ptr_at 이라는 함수를 통해서 이더넷 헤드 포인터 값을 가지고 오고 Ethertype 이 아닌 경우 XDP_PASS 를 주고 아닌 경우에는 추가적인 처리를 합니다.
// ip handling part
ethernet 에서 ipv4 라는 것을 확인 하고 다음으로는 이제 source ip 를 가지고 와야 한다. 이를 위해서 ptr_at 에 값을 주는데 IP header 의 경우 ethernet header 다음으로 나오게 되기 때문에
ptr_at 에서 시작 offset 을 EthHdr 의 길이 만큼에서 시작 해줍니다.
// 포인터 가지고 오기
let ipv4hdr : *const Ipv4Hdr = unsafe { ptr_at(&ctx, EthHdr::LEN)?};
// source addr 추출하기
let source = u32::from_be(unsafe {(*ipv4hdr).src_addr});
그러면 이제 ip block 을 위한 준비를 해보겠습니다.
우선은 필요한것은
* ip block 을 위한 function
* user space 에서 해당 아이피를 지정하기 위한 hashmap
이렇게 필요하게 됩니다.
#[map]
static BLOCKLIST: HashMap<u32, u32> = HashMap::<u32, u32>::with_max_entries(1024, 0 );
ebpf 코드 안에 map 이라고 명시 해두고 hash map 을 선언 해줍니다. 그리고 또한 이 ip 가 hash map 에 있는지 찾는 함수를 선언 해줍니다.
fn block_ip( address: u32) -> bool{
unsafe { BLOCKLIST.get(&address).is_some()}
}
위에 까지는 ebpf 코드의 영역이었고 이 아이피를 유저 영역에서 넣어줄 필요가 있습니다.
myapp/src/main.rs
use std::net::Ipv4Addr;
use anyhow::Context;
use aya::programs::{Xdp, XdpFlags};
use aya::{
include_bytes_aligned,
Bpf,
maps::HashMap
};
use aya_log::BpfLogger;
use clap::Parser;
use log::{info, warn, debug};
use tokio::signal;
#[derive(Debug, Parser)]
struct Opt {
#[clap(short, long, default_value = "enp0s1")]
iface: String,
}
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let opt = Opt::parse();
env_logger::init();
// Bump the memlock rlimit. This is needed for older kernels that don't use the
// new memcg based accounting, see https://lwn.net/Articles/837122/
let rlim = libc::rlimit {
rlim_cur: libc::RLIM_INFINITY,
rlim_max: libc::RLIM_INFINITY,
};
let ret = unsafe { libc::setrlimit(libc::RLIMIT_MEMLOCK, &rlim) };
if ret != 0 {
debug!("remove limit on locked memory failed, ret is: {}", ret);
}
// 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.
#[cfg(debug_assertions)]
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/debug/myapp-packet"
))?;
#[cfg(not(debug_assertions))]
let mut bpf = Bpf::load(include_bytes_aligned!(
"../../target/bpfel-unknown-none/release/myapp-packet"
))?;
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("myapp_packet").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")?;
let mut blocklist: HashMap< _ ,u32, u32> = HashMap::try_from(bpf.map_mut("BLOCKLIST").unwrap())?;
let block_addr:u32 = Ipv4Addr::new(1,1,1,1).try_into()?;
blocklist.insert(block_addr,0,0,)?;
info!("Waiting for Ctrl-C...");
signal::ctrl_c().await?;
info!("Exiting...");
Ok(())
}
코드가 복잡하고 길지만 59 line 을 보면
let mut blocklist: HashMap< _ ,u32, u32> = HashMap::try_from(bpf.map_mut("BLOCKLIST").unwrap())?;
let block_addr:u32 = Ipv4Addr::new(1,1,1,1).try_into()?;
blocklist.insert(block_addr,0,0,)?;
위와 같이 BLOCKLIST 라고 되어 있는 hashmap 을 가지고 오게 되고 이후 1.1.1.1 ip 를 insert 하는 과정을 통해서 ip block 할 주소를 추가 해주게 됩니다.
저의 경우 내부 다른 가상 머신을 만들어서 해당 주소를 넣어서 block 하게 되었습니다.
이런식으로 들어오는 IP 및 포트를 출력 해주게 됩니다.
이렇게 aya 실습을 한 차례 실습 해봤습니다. 하고 나서 감상은 확실히 예제에서 필요한 부분들을 잘 설명 해두어서 따라 하기만 했는데 이해가 확실히 되는 거 같았습니다.
하지만 rust, ebpf 도 중요했지만 역시 네트워크에 대한 이해가 수반 되어야지 이러한 코드를 작성할 수 있음을 다시한번 느꼈습니다.
긴글 읽어 주셔서 감사합니다.