Implement XDP firewall with real-time TUI monitoring

Features:
- High-performance packet filtering via eBPF/XDP
- Instant blocklist with dynamic CLI management
- Exact-match rules with Drop/Pass/Log actions
- CIDR-based IP range dropping via LPM trie
- Token-bucket rate limiting (IP-based and flow-based)
- Auto temp bans for rate limit violators
- Real-time event logging via BPF ring buffer
- Interactive TUI monitor with live stats

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-15 09:10:52 +07:00
commit 6101de6887
24 changed files with 3235 additions and 0 deletions

21
xdp-firewall/Cargo.toml Normal file
View File

@@ -0,0 +1,21 @@
[package]
name = "xdp-firewall"
version = "0.1.0"
edition = "2021"
[dependencies]
aya = { version = "0.13.1", features = ["async_tokio"] }
aya-log = "0.2.1"
clap = { version = "4.5", features = ["derive"] }
tokio = { version = "1.44", features = ["macros", "rt", "rt-multi-thread", "time", "sync", "signal"] }
ratatui = "0.29"
crossterm = { version = "0.29", features = ["event-stream"] }
anyhow = "1.0"
thiserror = "2.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
ipnetwork = "0.21"
xdp-firewall-common = { path = "../xdp-firewall-common", features = ["userspace"] }
[build-dependencies]
duct = "0.13"

22
xdp-firewall/build.rs Normal file
View File

@@ -0,0 +1,22 @@
use std::{env, path::PathBuf};
fn main() {
let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
let root = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap())
.parent()
.unwrap()
.to_path_buf();
let ebpf_target = root.join("target/bpfel-unknown-none/release/xdp-firewall");
if !ebpf_target.exists() {
let status = std::process::Command::new("cargo")
.args(["run", "--package", "xtask", "--", "build-ebpf"])
.current_dir(&root)
.status()
.expect("failed to build eBPF program");
assert!(status.success(), "eBPF build failed");
}
std::fs::copy(&ebpf_target, out_dir.join("xdp-firewall.o")).unwrap();
println!("cargo:rerun-if-changed={}", ebpf_target.display());
}

118
xdp-firewall/src/cli.rs Normal file
View File

@@ -0,0 +1,118 @@
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser)]
#[command(name = "xdp-firewall")]
#[command(about = "High-performance XDP firewall with real-time monitoring")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
Load {
#[arg(short, long)]
iface: String,
#[arg(short, long)]
config: Option<String>,
},
Unload {
#[arg(short, long)]
iface: String,
},
Monitor {
#[arg(short, long)]
iface: String,
},
Block {
#[arg(short, long)]
iface: String,
#[arg(short, long)]
ip: String,
#[arg(short, long, default_value = "0")]
duration: u64,
},
Unblock {
#[arg(short, long)]
iface: String,
#[arg(short, long)]
ip: String,
},
AddRule {
#[arg(short, long)]
iface: String,
#[arg(long, default_value = "0.0.0.0")]
src_ip: String,
#[arg(long, default_value = "0.0.0.0")]
dst_ip: String,
#[arg(long, default_value = "0")]
proto: u8,
#[arg(long, default_value = "0")]
src_port: u16,
#[arg(long, default_value = "0")]
dst_port: u16,
#[arg(short, long, value_enum)]
action: ActionArg,
},
DelRule {
#[arg(short, long)]
iface: String,
#[arg(short, long)]
id: u32,
},
ListRules {
#[arg(short, long)]
iface: String,
},
AddRange {
#[arg(short, long)]
iface: String,
#[arg(short, long)]
cidr: String,
#[arg(short, long, value_enum, default_value = "drop")]
action: ActionArg,
},
DelRange {
#[arg(short, long)]
iface: String,
#[arg(short, long)]
cidr: String,
},
SetRatelimit {
#[arg(short, long)]
iface: String,
#[arg(long)]
rate: u64,
#[arg(long)]
burst: u64,
#[arg(long)]
ban_threshold: u32,
#[arg(long)]
ban_duration: u32,
#[arg(long, value_enum)]
mode: RatelimitModeArg,
},
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum ActionArg {
Drop,
Pass,
Log,
}
impl ActionArg {
pub fn as_u8(&self) -> u8 {
match self {
ActionArg::Drop => xdp_firewall_common::ACTION_DROP,
ActionArg::Pass => xdp_firewall_common::ACTION_PASS,
ActionArg::Log => xdp_firewall_common::ACTION_LOG,
}
}
}
#[derive(Clone, Copy, Debug, ValueEnum)]
pub enum RatelimitModeArg {
Ip,
Flow,
}

View File

@@ -0,0 +1,108 @@
use std::process::Command;
use aya::{
maps::{Array, HashMap, lpm_trie::LpmTrie, MapData, RingBuf},
programs::{Xdp, xdp::XdpFlags},
EbpfLoader,
};
use aya_log::EbpfLogger;
use xdp_firewall_common::*;
pub struct FirewallHandle {
pub bpf: aya::Ebpf,
}
const BPF_ELF: &[u8] = include_bytes!("../../target/bpfel-unknown-none/release/xdp-firewall");
const PIN_PATH: &str = "/sys/fs/bpf/xdp-firewall";
impl FirewallHandle {
pub fn load(iface: &str) -> anyhow::Result<Self> {
eprintln!("Step 1: create_dir_all {}", PIN_PATH);
std::fs::create_dir_all(PIN_PATH)?;
eprintln!("Step 2: EbpfLoader::load");
let elf_bytes = BPF_ELF.to_vec();
let mut bpf = EbpfLoader::new()
.map_pin_path(PIN_PATH)
.load(&elf_bytes)
.map_err(|e| {
eprintln!("EbpfLoader::load failed: {:?}", e);
e
})?;
let _ = EbpfLogger::init(&mut bpf);
let prog: &mut Xdp = bpf.program_mut("xdp_firewall").unwrap().try_into()?;
prog.load().map_err(|e| {
eprintln!("prog.load() failed: {:?}", e);
e
})?;
prog.attach(iface, XdpFlags::default()).map_err(|e| {
eprintln!("prog.attach() failed: {:?}", e);
e
})?;
let mut global: Array<_, Stats> = Array::try_from(bpf.map_mut("GLOBAL_STATS").unwrap())?;
global.set(0, Stats { packets: 0, bytes: 0 }, 0)?;
Ok(Self { bpf })
}
}
pub fn unload(iface: &str) -> anyhow::Result<()> {
let status = Command::new("ip")
.args(["link", "set", "dev", iface, "xdp", "off"])
.status()?;
if !status.success() {
anyhow::bail!("failed to detach XDP program from {}", iface);
}
Ok(())
}
pub fn open_blocklist() -> anyhow::Result<HashMap<MapData, u32, u64>> {
let data = MapData::from_pin(format!("{}/BLOCKLIST", PIN_PATH))?;
let map = aya::maps::Map::HashMap(data);
Ok(HashMap::try_from(map)?)
}
pub fn open_ip_ranges() -> anyhow::Result<LpmTrie<MapData, u32, u8>> {
let data = MapData::from_pin(format!("{}/IP_RANGES", PIN_PATH))?;
let map = aya::maps::Map::LpmTrie(data);
Ok(LpmTrie::try_from(map)?)
}
pub fn open_rules() -> anyhow::Result<HashMap<MapData, RuleKey, RuleValue>> {
let data = MapData::from_pin(format!("{}/RULES", PIN_PATH))?;
let map = aya::maps::Map::HashMap(data);
Ok(HashMap::try_from(map)?)
}
pub fn open_rate_limit_state() -> anyhow::Result<HashMap<MapData, RateLimitKey, RateLimitState>> {
let data = MapData::from_pin(format!("{}/RATE_LIMIT_STATE", PIN_PATH))?;
let map = aya::maps::Map::HashMap(data);
Ok(HashMap::try_from(map)?)
}
pub fn open_rate_limit_config() -> anyhow::Result<HashMap<MapData, u8, RateLimitConfig>> {
let data = MapData::from_pin(format!("{}/RATE_LIMIT_CONFIG", PIN_PATH))?;
let map = aya::maps::Map::HashMap(data);
Ok(HashMap::try_from(map)?)
}
pub fn open_stats() -> anyhow::Result<HashMap<MapData, u32, Stats>> {
let data = MapData::from_pin(format!("{}/STATS", PIN_PATH))?;
let map = aya::maps::Map::HashMap(data);
Ok(HashMap::try_from(map)?)
}
pub fn open_global_stats() -> anyhow::Result<Array<MapData, Stats>> {
let data = MapData::from_pin(format!("{}/GLOBAL_STATS", PIN_PATH))?;
let map = aya::maps::Map::Array(data);
Ok(Array::try_from(map)?)
}
pub fn open_events() -> anyhow::Result<RingBuf<MapData>> {
let data = MapData::from_pin(format!("{}/EVENTS", PIN_PATH))?;
let map = aya::maps::Map::RingBuf(data);
Ok(RingBuf::try_from(map)?)
}

View File

@@ -0,0 +1,24 @@
use std::borrow::Borrow;
use aya::maps::MapData;
use tokio::io::unix::AsyncFd;
use xdp_firewall_common::FirewallEvent;
pub async fn run_logger<T: Borrow<MapData>>(
mut ring_buf: aya::maps::RingBuf<T>,
tx: tokio::sync::mpsc::Sender<FirewallEvent>,
) -> anyhow::Result<()> {
let mut async_fd = AsyncFd::new(ring_buf)?;
loop {
while let Some(item) = async_fd.get_mut().next() {
let data: &[u8] = &item;
if data.len() >= core::mem::size_of::<FirewallEvent>() {
let ev = unsafe { *(data.as_ptr() as *const FirewallEvent) };
let _ = tx.try_send(ev);
}
}
let mut guard = async_fd.readable().await?;
guard.clear_ready();
}
}

159
xdp-firewall/src/main.rs Normal file
View File

@@ -0,0 +1,159 @@
mod cli;
mod firewall;
mod logger;
mod maps;
mod monitor;
mod stats;
use aya::maps::lpm_trie::Key as LpmKey;
use clap::Parser;
use xdp_firewall_common::*;
use crate::cli::{ActionArg, Cli, Commands, RatelimitModeArg};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
match cli.command {
Commands::Load { iface, .. } => {
let _handle = firewall::FirewallHandle::load(&iface)?;
tracing::info!("XDP firewall loaded on {}", iface);
tokio::signal::ctrl_c().await?;
}
Commands::Unload { iface } => {
firewall::unload(&iface)?;
tracing::info!("XDP firewall unloaded from {}", iface);
}
Commands::Monitor { iface } => {
monitor::run(&iface).await?;
}
Commands::Block { ip, duration, .. } => {
let ip = maps::parse_ipv4(&ip)?;
let mut blocklist = firewall::open_blocklist()?;
let expiry = if duration == 0 {
u64::MAX
} else {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs()
+ duration
};
blocklist.insert(ip, expiry, 0)?;
println!("Blocked {} until epoch {}", maps::fmt_ipv4(ip), expiry);
}
Commands::Unblock { ip, .. } => {
let ip = maps::parse_ipv4(&ip)?;
let mut blocklist = firewall::open_blocklist()?;
blocklist.remove(&ip)?;
println!("Unblocked {}", maps::fmt_ipv4(ip));
}
Commands::AddRule { src_ip, dst_ip, proto, src_port, dst_port, action, .. } => {
let mut rules = firewall::open_rules()?;
let mut stats = firewall::open_stats()?;
let rule_id = stats.iter().count() as u32;
let key = RuleKey {
src_ip: maps::parse_ipv4(&src_ip)?,
dst_ip: maps::parse_ipv4(&dst_ip)?,
src_port,
dst_port,
proto,
_pad: [0; 3],
};
let value = RuleValue {
action: action.as_u8(),
rule_id,
_pad: [0; 3],
};
rules.insert(key, value, 0)?;
stats.insert(rule_id, Stats::default(), 0)?;
println!("Added rule id={}", rule_id);
}
Commands::DelRule { id, .. } => {
let mut rules = firewall::open_rules()?;
let mut stats = firewall::open_stats()?;
let target = rules.iter().find(|r| {
if let Ok((_, v)) = r {
v.rule_id == id
} else {
false
}
});
if let Some(Ok((k, _))) = target {
let key = k;
rules.remove(&key)?;
let _ = stats.remove(&id);
println!("Deleted rule id={}", id);
} else {
println!("Rule id={} not found", id);
}
}
Commands::ListRules { .. } => {
let rules = firewall::open_rules()?;
let stats = firewall::open_stats()?;
println!("{:<5} {:<15} {:<15} {:<6} {:<6} {:<6} {:<10} {:<12} {:<12}",
"ID", "SRC_IP", "DST_IP", "PROTO", "SPORT", "DPORT", "ACTION", "PACKETS", "BYTES");
for item in rules.iter() {
let (k, v) = item?;
let s = stats.get(&v.rule_id, 0).unwrap_or_default();
let action = match v.action {
ACTION_DROP => "drop",
ACTION_PASS => "pass",
ACTION_LOG => "log",
_ => "?",
};
println!("{:<5} {:<15} {:<15} {:<6} {:<6} {:<6} {:<10} {:<12} {:<12}",
v.rule_id,
if k.src_ip == 0 { "*".to_string() } else { maps::fmt_ipv4(k.src_ip) },
if k.dst_ip == 0 { "*".to_string() } else { maps::fmt_ipv4(k.dst_ip) },
k.proto,
if k.src_port == 0 { "*".to_string() } else { k.src_port.to_string() },
if k.dst_port == 0 { "*".to_string() } else { k.dst_port.to_string() },
action,
s.packets,
s.bytes,
);
}
}
Commands::AddRange { cidr, action, .. } => {
let net = cidr.parse::<ipnetwork::Ipv4Network>()?;
let prefix = net.prefix();
let addr = u32::from_be_bytes(net.network().octets());
let mut ranges = firewall::open_ip_ranges()?;
let key = LpmKey::new(prefix.into(), addr.to_be());
ranges.insert(&key, action.as_u8(), 0)?;
println!("Added range {}/{} -> {:?}", maps::fmt_ipv4(addr), prefix, action);
}
Commands::DelRange { cidr, .. } => {
let net = cidr.parse::<ipnetwork::Ipv4Network>()?;
let prefix = net.prefix();
let addr = u32::from_be_bytes(net.network().octets());
let mut ranges = firewall::open_ip_ranges()?;
let key = LpmKey::new(prefix.into(), addr.to_be());
ranges.remove(&key)?;
println!("Deleted range {}/{}", maps::fmt_ipv4(addr), prefix);
}
Commands::SetRatelimit { rate, burst, ban_threshold, ban_duration, mode, .. } => {
let mut config_map = firewall::open_rate_limit_config()?;
let fixed_rate = rate * 1_000_000;
let fixed_burst = burst * 1_000_000;
let config = RateLimitConfig {
rate_per_sec: fixed_rate,
burst: fixed_burst,
ban_threshold,
ban_duration_sec: ban_duration,
enabled: 1,
_pad: [0; 3],
};
let key = match mode {
RatelimitModeArg::Ip => RL_TYPE_IP,
RatelimitModeArg::Flow => RL_TYPE_FLOW,
};
config_map.insert(key, config, 0)?;
println!("Set rate limit: rate={}pps burst={} mode={:?}", rate, burst, mode);
}
}
Ok(())
}

55
xdp-firewall/src/maps.rs Normal file
View File

@@ -0,0 +1,55 @@
use aya::{
maps::{Array, HashMap, lpm_trie::{LpmTrie, Key as LpmKey}, RingBuf},
Ebpf,
};
use xdp_firewall_common::*;
pub type BpfBlocklist<'a> = HashMap<&'a mut aya::maps::MapData, u32, u64>;
pub type BpfIpRanges<'a> = LpmTrie<&'a mut aya::maps::MapData, u32, u8>;
pub type BpfRules<'a> = HashMap<&'a mut aya::maps::MapData, RuleKey, RuleValue>;
pub type BpfRateLimitState<'a> = HashMap<&'a mut aya::maps::MapData, RateLimitKey, RateLimitState>;
pub type BpfRateLimitConfig<'a> = HashMap<&'a mut aya::maps::MapData, u8, RateLimitConfig>;
pub type BpfStats<'a> = HashMap<&'a mut aya::maps::MapData, u32, Stats>;
pub type BpfGlobalStats<'a> = Array<&'a mut aya::maps::MapData, Stats>;
pub type BpfEvents<'a> = RingBuf<&'a mut aya::maps::MapData>;
pub fn blocklist(bpf: &mut Ebpf) -> anyhow::Result<BpfBlocklist> {
Ok(HashMap::try_from(bpf.map_mut("BLOCKLIST").unwrap())?)
}
pub fn ip_ranges(bpf: &mut Ebpf) -> anyhow::Result<BpfIpRanges> {
Ok(LpmTrie::try_from(bpf.map_mut("IP_RANGES").unwrap())?)
}
pub fn rules(bpf: &mut Ebpf) -> anyhow::Result<BpfRules> {
Ok(HashMap::try_from(bpf.map_mut("RULES").unwrap())?)
}
pub fn rate_limit_state(bpf: &mut Ebpf) -> anyhow::Result<BpfRateLimitState> {
Ok(HashMap::try_from(bpf.map_mut("RATE_LIMIT_STATE").unwrap())?)
}
pub fn rate_limit_config(bpf: &mut Ebpf) -> anyhow::Result<BpfRateLimitConfig> {
Ok(HashMap::try_from(bpf.map_mut("RATE_LIMIT_CONFIG").unwrap())?)
}
pub fn stats(bpf: &mut Ebpf) -> anyhow::Result<BpfStats> {
Ok(HashMap::try_from(bpf.map_mut("STATS").unwrap())?)
}
pub fn global_stats(bpf: &mut Ebpf) -> anyhow::Result<BpfGlobalStats> {
Ok(Array::try_from(bpf.map_mut("GLOBAL_STATS").unwrap())?)
}
pub fn events(bpf: &mut Ebpf) -> anyhow::Result<BpfEvents> {
Ok(RingBuf::try_from(bpf.map_mut("EVENTS").unwrap())?)
}
pub fn parse_ipv4(s: &str) -> anyhow::Result<u32> {
let addr: std::net::Ipv4Addr = s.parse()?;
Ok(u32::from_be_bytes(addr.octets()))
}
pub fn fmt_ipv4(ip: u32) -> String {
std::net::Ipv4Addr::from(ip).to_string()
}

View File

@@ -0,0 +1,84 @@
use std::collections::VecDeque;
use xdp_firewall_common::{FirewallEvent, RuleKey, RuleValue, Stats};
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Pane {
Rules,
Blocklist,
Logs,
}
pub struct App {
pub iface: String,
pub quit: bool,
pub pane: Pane,
pub global_stats: Stats,
pub prev_global_stats: Stats,
pub rules: Vec<(RuleKey, RuleValue, Stats)>,
pub blocklist: Vec<(u32, u64)>,
pub logs: VecDeque<FirewallEvent>,
pub max_logs: usize,
pub pps: u64,
pub bps: u64,
pub dialog: Dialog,
pub rules_selected: usize,
pub blocklist_selected: usize,
}
#[derive(Clone, Default, PartialEq, Eq)]
pub enum Dialog {
#[default]
None,
BlockIp { input: String },
AddRule { src_ip: String, dst_ip: String, proto: String, src_port: String, dst_port: String, action: String },
ConfirmDelete,
}
impl App {
pub fn new(iface: String) -> Self {
Self {
iface,
quit: false,
pane: Pane::Rules,
global_stats: Stats::default(),
prev_global_stats: Stats::default(),
rules: Vec::new(),
blocklist: Vec::new(),
logs: VecDeque::new(),
max_logs: 100,
pps: 0,
bps: 0,
dialog: Dialog::None,
rules_selected: 0,
blocklist_selected: 0,
}
}
pub fn on_tick(&mut self, global: Stats, rules: Vec<(RuleKey, RuleValue, Stats)>, blocklist: Vec<(u32, u64)>) {
let elapsed = 0.1;
let pkts = global.packets.saturating_sub(self.prev_global_stats.packets);
let bytes = global.bytes.saturating_sub(self.prev_global_stats.bytes);
self.pps = (pkts as f64 / elapsed) as u64;
self.bps = (bytes as f64 / elapsed) as u64;
self.prev_global_stats = self.global_stats;
self.global_stats = global;
self.rules = rules;
self.blocklist = blocklist;
}
pub fn add_log(&mut self, ev: FirewallEvent) {
if self.logs.len() >= self.max_logs {
self.logs.pop_back();
}
self.logs.push_front(ev);
}
pub fn selected_rule_id(&self) -> Option<u32> {
self.rules.get(self.rules_selected).map(|(_, v, _)| v.rule_id)
}
pub fn selected_block_ip(&self) -> Option<u32> {
self.blocklist.get(self.blocklist_selected).map(|(ip, _)| *ip)
}
}

View File

@@ -0,0 +1,260 @@
mod app;
mod ui;
use std::{
io,
time::{Duration, Instant},
};
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{
backend::{Backend, CrosstermBackend},
Terminal,
};
use tokio::sync::mpsc;
use crate::{
firewall, logger, maps,
monitor::app::{App, Dialog, Pane},
};
use xdp_firewall_common::{FirewallEvent, Stats};
pub async fn run(iface: &str) -> anyhow::Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let res = run_app(&mut terminal, iface.to_string()).await;
disable_raw_mode()?;
execute!(
terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
)?;
terminal.show_cursor()?;
res
}
async fn run_app<B: Backend>(terminal: &mut Terminal<B>, iface: String) -> anyhow::Result<()> {
let mut app = App::new(iface.clone());
let (log_tx, mut log_rx) = mpsc::channel::<FirewallEvent>(1024);
let ring_buf = firewall::open_events()?;
tokio::spawn(async move {
let _ = logger::run_logger(ring_buf, log_tx).await;
});
let mut last_tick = Instant::now();
let tick_rate = Duration::from_millis(100);
loop {
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
if crossterm::event::poll(timeout)? {
if let Event::Key(key) = event::read()? {
if key.kind == KeyEventKind::Press {
handle_input(&mut app, key.code);
}
}
}
while let Ok(ev) = log_rx.try_recv() {
app.add_log(ev);
}
if last_tick.elapsed() >= tick_rate {
let (global, rules, blocklist) = poll_maps()?;
app.on_tick(global, rules, blocklist);
last_tick = Instant::now();
}
terminal.draw(|f| ui::draw(f, &app))?;
if app.quit {
return Ok(());
}
}
}
fn poll_maps() -> anyhow::Result<(Stats, Vec<(xdp_firewall_common::RuleKey, xdp_firewall_common::RuleValue, Stats)>, Vec<(u32, u64)>)> {
let global = firewall::open_global_stats()?.get(&0, 0).unwrap_or_default();
let rules_map = firewall::open_rules()?;
let stats_map = firewall::open_stats()?;
let mut rules = Vec::new();
for item in rules_map.iter() {
let (k, v) = item?;
let s = stats_map.get(&v.rule_id, 0).unwrap_or_default();
rules.push((k, v, s));
}
let blocklist_map = firewall::open_blocklist()?;
let mut blocklist = Vec::new();
for item in blocklist_map.iter() {
let (k, v) = item?;
blocklist.push((k, v));
}
Ok((global, rules, blocklist))
}
fn handle_input(app: &mut App, key: KeyCode) {
match &mut app.dialog {
Dialog::None => match key {
KeyCode::Char('q') => app.quit = true,
KeyCode::Tab => {
app.pane = match app.pane {
Pane::Rules => Pane::Blocklist,
Pane::Blocklist => Pane::Logs,
Pane::Logs => Pane::Rules,
};
}
KeyCode::Down => match app.pane {
Pane::Rules => {
if app.rules_selected + 1 < app.rules.len() {
app.rules_selected += 1;
}
}
Pane::Blocklist => {
if app.blocklist_selected + 1 < app.blocklist.len() {
app.blocklist_selected += 1;
}
}
_ => {}
},
KeyCode::Up => match app.pane {
Pane::Rules => {
if app.rules_selected > 0 {
app.rules_selected -= 1;
}
}
Pane::Blocklist => {
if app.blocklist_selected > 0 {
app.blocklist_selected -= 1;
}
}
_ => {}
},
KeyCode::Char('b') => {
app.dialog = Dialog::BlockIp { input: String::new() };
}
KeyCode::Char('u') => {
if app.pane == Pane::Blocklist {
if let Some(ip) = app.selected_block_ip() {
let _ = unblock_ip(ip);
}
}
}
KeyCode::Char('a') => {
app.dialog = Dialog::AddRule {
src_ip: "0.0.0.0".into(),
dst_ip: "0.0.0.0".into(),
proto: "0".into(),
src_port: "0".into(),
dst_port: "0".into(),
action: "drop".into(),
};
}
KeyCode::Char('d') => {
if app.pane == Pane::Rules && !app.rules.is_empty() {
app.dialog = Dialog::ConfirmDelete;
}
}
_ => {}
},
Dialog::BlockIp { input } => match key {
KeyCode::Enter => {
if !input.is_empty() {
if let Ok(ip) = input.parse::<std::net::Ipv4Addr>() {
let _ = block_ip(u32::from_be_bytes(ip.octets()));
}
}
app.dialog = Dialog::None;
}
KeyCode::Esc => app.dialog = Dialog::None,
KeyCode::Char(c) => input.push(c),
KeyCode::Backspace => { input.pop(); }
_ => {}
},
Dialog::AddRule { src_ip, dst_ip, proto, src_port, dst_port, action } => match key {
KeyCode::Enter => {
let _ = add_rule_from_dialog(src_ip, dst_ip, proto, src_port, dst_port, action);
app.dialog = Dialog::None;
}
KeyCode::Esc => app.dialog = Dialog::None,
_ => {}
},
Dialog::ConfirmDelete => match key {
KeyCode::Enter => {
if let Some(id) = app.selected_rule_id() {
let _ = delete_rule(id);
}
app.dialog = Dialog::None;
}
KeyCode::Esc => app.dialog = Dialog::None,
_ => {}
},
}
}
fn block_ip(ip: u32) -> anyhow::Result<()> {
let mut blocklist = firewall::open_blocklist()?;
blocklist.insert(ip, u64::MAX, 0)?;
Ok(())
}
fn unblock_ip(ip: u32) -> anyhow::Result<()> {
let mut blocklist = firewall::open_blocklist()?;
blocklist.remove(&ip)?;
Ok(())
}
fn add_rule_from_dialog(src_ip: &str, dst_ip: &str, proto: &str, src_port: &str, dst_port: &str, action: &str) -> anyhow::Result<()> {
use xdp_firewall_common::*;
let mut rules = firewall::open_rules()?;
let mut stats = firewall::open_stats()?;
let rule_id = stats.iter().count() as u32;
let key = RuleKey {
src_ip: maps::parse_ipv4(src_ip)?,
dst_ip: maps::parse_ipv4(dst_ip)?,
src_port: src_port.parse()?,
dst_port: dst_port.parse()?,
proto: proto.parse()?,
_pad: [0; 3],
};
let action_u8 = match action {
"pass" => ACTION_PASS,
"log" => ACTION_LOG,
_ => ACTION_DROP,
};
let value = RuleValue {
action: action_u8,
rule_id,
_pad: [0; 3],
};
rules.insert(key, value, 0)?;
stats.insert(rule_id, Stats::default(), 0)?;
Ok(())
}
fn delete_rule(id: u32) -> anyhow::Result<()> {
let mut rules = firewall::open_rules()?;
let mut stats = firewall::open_stats()?;
let target = rules.iter().find(|r| {
if let Ok((_, v)) = r {
v.rule_id == id
} else {
false
}
});
if let Some(Ok((k, _))) = target {
let key = k;
rules.remove(&key)?;
let _ = stats.remove(&id);
}
Ok(())
}

View File

@@ -0,0 +1,203 @@
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, Cell, Clear, Paragraph, Row, Table, Wrap},
Frame,
};
use crate::maps::fmt_ipv4;
use crate::monitor::app::{App, Dialog, Pane};
use xdp_firewall_common::*;
pub fn draw(f: &mut Frame, app: &App) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0), Constraint::Length(1)])
.split(f.area());
draw_header(f, app, chunks[0]);
draw_main(f, app, chunks[1]);
draw_footer(f, app, chunks[2]);
if app.dialog != Dialog::None {
draw_dialog(f, app);
}
}
fn draw_header(f: &mut Frame, app: &App, area: Rect) {
let header = Paragraph::new(format!(
"Interface: {} | Global Packets: {} | Global Bytes: {} | PPS: {} | BPS: {}",
app.iface, app.global_stats.packets, app.global_stats.bytes, app.pps, app.bps
))
.block(Block::default().borders(Borders::ALL).title("XDP Firewall Monitor"))
.alignment(Alignment::Center);
f.render_widget(header, area);
}
fn draw_main(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area);
draw_rules(f, app, chunks[0]);
draw_right(f, app, chunks[1]);
}
fn draw_rules(f: &mut Frame, app: &App, area: Rect) {
let border_style = if app.pane == Pane::Rules {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let block = Block::default().borders(Borders::ALL).title("Rules").border_style(border_style);
let header = Row::new(vec!["ID", "SRC", "DST", "P", "SP", "DP", "ACT", "PKTS", "BYTES"])
.style(Style::default().fg(Color::Yellow));
let rows: Vec<Row> = app.rules.iter().enumerate().map(|(i, (k, v, s))| {
let style = if app.pane == Pane::Rules && i == app.rules_selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
let action = match v.action {
ACTION_DROP => "DR",
ACTION_PASS => "PS",
ACTION_LOG => "LG",
_ => "?",
};
Row::new(vec![
Cell::from(v.rule_id.to_string()),
Cell::from(if k.src_ip == 0 { "*".into() } else { fmt_ipv4(k.src_ip) }),
Cell::from(if k.dst_ip == 0 { "*".into() } else { fmt_ipv4(k.dst_ip) }),
Cell::from(k.proto.to_string()),
Cell::from(if k.src_port == 0 { "*".into() } else { k.src_port.to_string() }),
Cell::from(if k.dst_port == 0 { "*".into() } else { k.dst_port.to_string() }),
Cell::from(action),
Cell::from(s.packets.to_string()),
Cell::from(s.bytes.to_string()),
]).style(style)
}).collect();
let table = Table::new(rows, [
Constraint::Length(4),
Constraint::Length(15),
Constraint::Length(15),
Constraint::Length(4),
Constraint::Length(6),
Constraint::Length(6),
Constraint::Length(5),
Constraint::Length(10),
Constraint::Length(10),
])
.header(header)
.block(block);
f.render_widget(table, area);
}
fn draw_right(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(40), Constraint::Percentage(60)])
.split(area);
draw_blocklist(f, app, chunks[0]);
draw_logs(f, app, chunks[1]);
}
fn draw_blocklist(f: &mut Frame, app: &App, area: Rect) {
let border_style = if app.pane == Pane::Blocklist {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let block = Block::default().borders(Borders::ALL).title("Blocklist").border_style(border_style);
let rows: Vec<Row> = app.blocklist.iter().enumerate().map(|(i, (ip, expiry))| {
let style = if app.pane == Pane::Blocklist && i == app.blocklist_selected {
Style::default().bg(Color::DarkGray)
} else {
Style::default()
};
let exp = if *expiry == u64::MAX {
"permanent".into()
} else {
expiry.to_string()
};
Row::new(vec![fmt_ipv4(*ip), exp]).style(style)
}).collect();
let table = Table::new(rows, [Constraint::Length(16), Constraint::Min(0)])
.header(Row::new(vec!["IP", "Expiry"]).style(Style::default().fg(Color::Yellow)))
.block(block);
f.render_widget(table, area);
}
fn draw_logs(f: &mut Frame, app: &App, area: Rect) {
let border_style = if app.pane == Pane::Logs {
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let block = Block::default().borders(Borders::ALL).title("Logs").border_style(border_style);
let lines: Vec<Line> = app.logs.iter().map(|ev| {
let action = match ev.action {
ACTION_DROP => "DROP",
ACTION_PASS => "PASS",
ACTION_LOG => "LOG",
ACTION_RATELIMIT_DROP => "RL_DROP",
ACTION_BLOCKLIST_DROP => "BL_DROP",
ACTION_RANGE_DROP => "RNG_DROP",
_ => "?",
};
Line::from(vec![
Span::raw(format!("{} ", action)),
Span::raw(format!("{}:{} -> {}:{} proto={}",
fmt_ipv4(ev.src_ip), ev.src_port,
fmt_ipv4(ev.dst_ip), ev.dst_port, ev.proto)),
])
}).collect();
let para = Paragraph::new(Text::from(lines)).block(block).wrap(Wrap { trim: true });
f.render_widget(para, area);
}
fn draw_footer(f: &mut Frame, _app: &App, area: Rect) {
let text = "q:quit | Tab:switch | b:block | u:unblock | a:add-rule | d:del-rule";
let para = Paragraph::new(text).alignment(Alignment::Center);
f.render_widget(para, area);
}
fn draw_dialog(f: &mut Frame, app: &App) {
let area = centered_rect(60, 40, f.area());
f.render_widget(Clear, area);
match &app.dialog {
Dialog::BlockIp { input } => {
let block = Block::default().borders(Borders::ALL).title("Block IP");
let para = Paragraph::new(format!("IP: {}", input)).block(block);
f.render_widget(para, area);
}
Dialog::AddRule { src_ip, dst_ip, proto, src_port, dst_port, action } => {
let block = Block::default().borders(Borders::ALL).title("Add Rule");
let text = format!(
"src_ip: {}\ndst_ip: {}\nproto: {}\nsrc_port: {}\ndst_port: {}\naction: {}",
src_ip, dst_ip, proto, src_port, dst_port, action
);
let para = Paragraph::new(text).block(block);
f.render_widget(para, area);
}
Dialog::ConfirmDelete => {
let block = Block::default().borders(Borders::ALL).title("Confirm Delete");
let para = Paragraph::new("Press Enter to confirm, Esc to cancel").block(block).alignment(Alignment::Center);
f.render_widget(para, area);
}
Dialog::None => {}
}
}
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2)])
.split(r);
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2)])
.split(popup_layout[1])[1]
}

15
xdp-firewall/src/stats.rs Normal file
View File

@@ -0,0 +1,15 @@
use xdp_firewall_common::Stats;
pub struct Rate {
pub pps: u64,
pub bps: u64,
}
pub fn compute_rate(now: Stats, last: Stats, elapsed_secs: f64) -> Rate {
let pkts = now.packets.saturating_sub(last.packets);
let bytes = now.bytes.saturating_sub(last.bytes);
Rate {
pps: (pkts as f64 / elapsed_secs) as u64,
bps: (bytes as f64 / elapsed_secs) as u64,
}
}