base: apply Red Bear patches on latest upstream/main

251 files: init, acpid, ipcd, netcfg, ihdgd, virtio-gpud, scheme-utils,
inputd, block driver, ptyd, ramfs, randd, initfs bootstrap, path deps,
version +rb0.3.1, author attribution
This commit is contained in:
Red Bear OS
2026-07-11 11:39:24 +03:00
parent 1b17b3fc24
commit bd595851e2
251 changed files with 24641 additions and 5993 deletions
+858
View File
@@ -0,0 +1,858 @@
//! Connection tracking hash table — mirrors Linux 7.1's `nf_conntrack`.
//!
//! Reference files:
//! - `include/net/netfilter/nf_conntrack.h:74` — `struct nf_conn`
//! - `include/net/netfilter/nf_conntrack_tuple.h` — `struct nf_conntrack_tuple`
//! - `net/netfilter/nf_conntrack_core.c` — `resolve_normal_ct()`, `nf_conntrack_in()`
//! - `net/netfilter/nf_conntrack_proto_tcp.c` — TCP state machine
//! - `net/netfilter/nf_conntrack_proto_udp.c` — UDP state tracking
//!
//! The connection is identified by a 5-tuple (src/dst addr, src/dst port, protocol)
//! plus the L3 protocol number. Both directions are tracked:
//! orig: from initiator → responder
//! reply: from responder → initiator
extern crate alloc;
use alloc::collections::BTreeMap;
use alloc::vec::Vec;
use core::hash::{Hash, Hasher};
use smoltcp::time::{Duration, Instant};
use smoltcp::wire::IpAddress;
fn is_syn(ctx: &PacketContext, l3num: u8) -> bool {
// TCP flags byte is at offset 13 in the TCP header.
// TCP header starts after the IP header: 20 bytes for IPv4, 40 for IPv6.
let tcp_offset = if l3num == 4 { 20 } else { 40 };
if ctx.packet.len() <= tcp_offset + 13 {
return false;
}
let flags = ctx.packet[tcp_offset + 13];
(flags & 0x02) != 0 && (flags & 0x10) == 0
}
fn tcp_flags(ctx: &PacketContext, l3num: u8) -> u8 {
let tcp_offset = if l3num == 4 { 20 } else { 40 };
if ctx.packet.len() <= tcp_offset + 13 {
return 0;
}
ctx.packet[tcp_offset + 13]
}
fn is_fin(flags: u8) -> bool {
(flags & 0x01) != 0
}
fn is_rst(flags: u8) -> bool {
(flags & 0x04) != 0
}
/// ICMP echo request detection (Type 8 for ICMPv4, Type 128 for ICMPv6).
/// Returns true if the packet is an ICMP echo request (Type 8 for ICMPv4,
/// Type 128 for ICMPv6). The packet in `ctx.packet` is the IP packet, so the
/// ICMP type field is at offset `ihl` (after the IP header).
fn is_echo_request(ctx: &PacketContext) -> bool {
if ctx.packet.len() < 2 {
return false;
}
let icmp_offset = match ctx.protocol {
1 => {
let ihl = (ctx.packet[0] & 0x0f) as usize * 4;
if ctx.packet.len() < ihl + 1 {
return false;
}
ihl
}
58 => 40,
_ => return false,
};
let icmp_type = ctx.packet[icmp_offset];
icmp_type == 8 || icmp_type == 128
}
use super::{PacketContext, Verdict};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnState {
New,
Established,
Related,
OverLimit,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ConnKey {
pub l3num: u8,
pub l4proto: u8,
pub src_addr: IpAddress,
pub dst_addr: IpAddress,
pub src_port: u16,
pub dst_port: u16,
}
impl Hash for ConnKey {
fn hash<H: Hasher>(&self, state: &mut H) {
self.l3num.hash(state);
self.l4proto.hash(state);
self.src_port.hash(state);
self.dst_port.hash(state);
match self.src_addr {
IpAddress::Ipv4(a) => u32::from(a).hash(state),
IpAddress::Ipv6(a) => a.octets().hash(state),
}
match self.dst_addr {
IpAddress::Ipv4(a) => u32::from(a).hash(state),
IpAddress::Ipv6(a) => a.octets().hash(state),
}
}
}
impl ConnKey {
pub fn reply(&self) -> Self {
Self {
l3num: self.l3num,
l4proto: self.l4proto,
src_addr: self.dst_addr,
dst_addr: self.src_addr,
src_port: self.dst_port,
dst_port: self.src_port,
}
}
pub fn from_context(l3num: u8, ctx: &PacketContext) -> Self {
Self {
l3num,
l4proto: ctx.protocol,
src_addr: ctx.src_addr,
dst_addr: ctx.dst_addr,
src_port: ctx.src_port.unwrap_or(0),
dst_port: ctx.dst_port.unwrap_or(0),
}
}
}
/// TCP connection tracking states (mirrors `enum tcp_conntrack`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TcpTracking {
None,
SynSent,
SynRecv,
Established,
FinWait,
TimeWait,
Close,
}
/// A single connection tracking entry (mirrors `struct nf_conn`).
#[derive(Debug, Clone)]
struct ConnEntry {
key: ConnKey,
reply_key: ConnKey,
state: ConnState,
tcp_state: TcpTracking,
fin_from_orig: bool,
timeout: Instant,
orig_packets: u64,
orig_bytes: u64,
reply_packets: u64,
reply_bytes: u64,
}
#[derive(Debug)]
pub struct ConntrackTable {
entries: BTreeMap<ConnKey, ConnEntry>,
last_cleanup: Option<Instant>,
syn_rate_limits: BTreeMap<IpAddress, (u32, Instant)>,
icmp_rate_limits: BTreeMap<IpAddress, (u32, Instant)>,
over_limit_count: u64,
icmp_error_count: u64,
max_entries: usize,
}
fn advance_entry_state(entry: &mut ConnEntry, is_orig: bool, ctx: &PacketContext, now: Instant) -> bool {
if entry.key.l4proto != 6 {
return false;
}
let flags = tcp_flags(ctx, entry.key.l3num);
let res_rst = is_rst(flags);
// RST closes the connection immediately.
if res_rst {
entry.tcp_state = TcpTracking::Close;
entry.state = ConnState::New;
entry.timeout = now + Duration::from_secs(10);
return true;
}
let res_fin = is_fin(flags);
if is_orig {
// Original direction: handshake completion and FIN teardown.
match entry.tcp_state {
TcpTracking::SynRecv => {
entry.tcp_state = TcpTracking::Established;
entry.state = ConnState::Established;
entry.timeout = now + Duration::from_secs(432000);
return true;
}
TcpTracking::Established if res_fin => {
entry.tcp_state = TcpTracking::FinWait;
entry.fin_from_orig = true;
entry.timeout = now + Duration::from_secs(120);
return true;
}
TcpTracking::FinWait if res_fin && !entry.fin_from_orig => {
// Second FIN from reply direction (orig FIN was first)
entry.tcp_state = TcpTracking::TimeWait;
entry.timeout = now + Duration::from_secs(120);
return true;
}
_ => {}
}
} else {
// Reply direction: handshake initiation and close.
match entry.tcp_state {
TcpTracking::None if (flags & 0x12) == 0x12 => {
entry.tcp_state = TcpTracking::SynRecv;
entry.timeout = now + Duration::from_secs(60);
return true;
}
TcpTracking::SynSent => {
entry.tcp_state = TcpTracking::SynRecv;
entry.timeout = now + Duration::from_secs(60);
return true;
}
TcpTracking::Established if res_fin => {
entry.tcp_state = TcpTracking::FinWait;
entry.fin_from_orig = false;
entry.timeout = now + Duration::from_secs(120);
return true;
}
TcpTracking::FinWait if res_fin && entry.fin_from_orig => {
// Second FIN from orig direction (reply FIN was first)
entry.tcp_state = TcpTracking::TimeWait;
entry.timeout = now + Duration::from_secs(120);
return true;
}
TcpTracking::TimeWait => {
entry.timeout = now + Duration::from_secs(120);
}
_ => {}
}
}
false
}
impl ConntrackTable {
pub fn new() -> Self {
Self {
entries: BTreeMap::new(),
last_cleanup: None,
syn_rate_limits: BTreeMap::new(),
icmp_rate_limits: BTreeMap::new(),
over_limit_count: 0,
icmp_error_count: 0,
max_entries: 65536,
}
}
pub fn track(&mut self, ctx: &PacketContext, now: Instant) -> ConnState {
let l3num = match ctx.src_addr {
IpAddress::Ipv4(_) => 4u8,
IpAddress::Ipv6(_) => 6u8,
};
if ctx.protocol != 6 && ctx.protocol != 17 && ctx.protocol != 1 && ctx.protocol != 58 {
return ConnState::New;
}
if ctx.protocol == 6 && is_syn(ctx, l3num) && self.check_syn_limit(ctx.src_addr, now) {
self.over_limit_count = self.over_limit_count.saturating_add(1);
return ConnState::OverLimit;
}
if (ctx.protocol == 1 || ctx.protocol == 58) && is_echo_request(ctx)
&& self.check_icmp_limit(ctx.src_addr, now)
{
self.over_limit_count = self.over_limit_count.saturating_add(1);
return ConnState::OverLimit;
}
if ctx.protocol == 1 || ctx.protocol == 58 {
return self.track_icmp(ctx, l3num, now);
}
let key = ConnKey::from_context(l3num, ctx);
let reply_key = key.reply();
// First check if this packet belongs to an existing reply flow
let (is_orig, entry_key) = if let Some(entry) = self.entries.get_mut(&reply_key) {
entry.reply_packets = entry.reply_packets.saturating_add(1);
entry.reply_bytes = entry.reply_bytes.saturating_add(ctx.packet.len() as u64);
advance_entry_state(entry, false, ctx, now);
return entry.state;
} else {
(true, key.clone())
};
if let Some(entry) = self.entries.get_mut(&entry_key) {
entry.orig_packets = entry.orig_packets.saturating_add(1);
entry.orig_bytes = entry.orig_bytes.saturating_add(ctx.packet.len() as u64);
advance_entry_state(entry, true, ctx, now);
return entry.state;
}
// New connection (mirrors `nf_conntrack_in`)
let state = ConnState::New;
let tcp_state = if ctx.protocol == 6 {
let flags = if ctx.packet.len() >= 34 {
let tcp_offset = if l3num == 4 { 20 } else { 40 };
if ctx.packet.len() > tcp_offset + 13 {
ctx.packet[tcp_offset + 13]
} else {
0
}
} else {
0
};
if flags & 0x02 != 0 && flags & 0x10 == 0 {
TcpTracking::SynSent
} else {
TcpTracking::None
}
} else {
TcpTracking::None
};
let timeout = if ctx.protocol == 17 {
Duration::from_secs(30)
} else {
Duration::from_secs(60)
};
if self.entries.len() >= self.max_entries {
self.over_limit_count = self.over_limit_count.saturating_add(1);
return ConnState::OverLimit;
}
self.entries.insert(
key.clone(),
ConnEntry {
key: key.clone(),
reply_key,
state,
tcp_state,
fin_from_orig: false,
timeout: now + timeout,
orig_packets: 1,
orig_bytes: ctx.packet.len() as u64,
reply_packets: 0,
reply_bytes: 0,
},
);
state
}
fn track_icmp(&mut self, ctx: &PacketContext, l3num: u8, now: Instant) -> ConnState {
if ctx.packet.len() < 4 {
return ConnState::New;
}
let icmp_offset = match l3num {
4 => (ctx.packet[0] & 0x0f) as usize * 4,
6 => 40,
_ => return ConnState::New,
};
if ctx.packet.len() < icmp_offset + 6 {
return ConnState::New;
}
let icmp_type = ctx.packet[icmp_offset];
let icmp_code = ctx.packet[icmp_offset + 1];
let icmp_id = u16::from_be_bytes([
ctx.packet[icmp_offset + 4],
ctx.packet[icmp_offset + 5],
]);
let is_echo = icmp_type == 8 || icmp_type == 128;
let is_echo_reply = icmp_type == 0 || icmp_type == 129;
let is_error = (l3num == 4 && icmp_type == 3) // ICMPv4 Dest Unreachable
|| (l3num == 4 && icmp_type == 11) // ICMPv4 Time Exceeded
|| (l3num == 6 && icmp_type == 1) // ICMPv6 Dest Unreachable
|| (l3num == 6 && icmp_type == 3); // ICMPv6 Time Exceeded
// ICMP errors carry the original packet. Try to extract the
// embedded connection tuple so we can relate it to an existing
// tracked connection (mirrors nf_conntrack_icmp_error()).
if is_error {
return self.track_icmp_error(ctx, l3num, icmp_offset, now);
}
if !is_echo && !is_echo_reply {
return ConnState::New;
}
let key = ConnKey {
l3num,
l4proto: ctx.protocol,
src_addr: ctx.src_addr,
dst_addr: ctx.dst_addr,
src_port: icmp_id,
dst_port: icmp_type as u16,
};
if is_echo {
if self.entries.contains_key(&key) {
return ConnState::Established;
}
if self.entries.len() >= self.max_entries {
self.over_limit_count = self.over_limit_count.saturating_add(1);
return ConnState::OverLimit;
}
self.entries.insert(
key.clone(),
ConnEntry {
key: key.clone(),
reply_key: ConnKey {
src_addr: ctx.dst_addr,
dst_addr: ctx.src_addr,
src_port: icmp_id,
dst_port: (if icmp_type == 8 { 0u8 } else { 129u8 }) as u16,
..key
},
state: ConnState::New,
tcp_state: TcpTracking::None,
fin_from_orig: false,
timeout: now + Duration::from_secs(30),
orig_packets: 1,
orig_bytes: ctx.packet.len() as u64,
reply_packets: 0,
reply_bytes: 0,
},
);
return ConnState::New;
}
let reply_key = ConnKey {
src_addr: ctx.dst_addr,
dst_addr: ctx.src_addr,
src_port: icmp_id,
dst_port: (if is_echo_reply && icmp_type == 0 { 8u8 } else { 128u8 }) as u16,
..key
};
if let Some(entry) = self.entries.get_mut(&reply_key) {
entry.reply_packets = entry.reply_packets.saturating_add(1);
entry.reply_bytes = entry.reply_bytes.saturating_add(ctx.packet.len() as u64);
entry.state = ConnState::Established;
entry.timeout = now + Duration::from_secs(30);
return ConnState::Established;
}
ConnState::New
}
/// Process an ICMP error message. Extracts the embedded connection
/// tuple from the original IP header carried in the ICMP payload.
/// Returns ConnState::Related if the embedded connection matches an
/// existing tracked connection (mirrors nf_conntrack_icmp_error()).
fn track_icmp_error(
&mut self,
ctx: &PacketContext,
l3num: u8,
icmp_offset: usize,
now: Instant,
) -> ConnState {
// ICMP error payload: 4 bytes unused + original IP header + 8 bytes
let inner_ip_start = icmp_offset + 8;
if ctx.packet.len() < inner_ip_start + 20 {
return ConnState::Error;
}
// Parse the inner IPv4 header.
let inner = &ctx.packet[inner_ip_start..];
if inner.len() < 20 || (inner[0] >> 4) != 4 {
return ConnState::Error;
}
let inner_proto = inner[9];
let inner_src = IpAddress::v4(inner[12], inner[13], inner[14], inner[15]);
let inner_dst = IpAddress::v4(inner[16], inner[17], inner[18], inner[19]);
let ihl = (inner[0] & 0x0f) as usize * 4;
if inner.len() < ihl + 4 || inner_proto != 6 && inner_proto != 17 {
return ConnState::Error;
}
let sport = u16::from_be_bytes([inner[ihl], inner[ihl + 1]]);
let dport = u16::from_be_bytes([inner[ihl + 2], inner[ihl + 3]]);
// Build the inner connection key and check for a match.
let inner_key = ConnKey {
l3num: 4,
l4proto: inner_proto,
src_addr: inner_src,
dst_addr: inner_dst,
src_port: sport,
dst_port: dport,
};
if self.entries.contains_key(&inner_key) {
ConnState::Related
} else {
self.icmp_error_count = self.icmp_error_count.saturating_add(1);
ConnState::Error
}
}
pub fn clean_expired(&mut self, now: Instant) {
if let Some(last) = self.last_cleanup {
if now < last + Duration::from_secs(1) {
return;
}
}
self.last_cleanup = Some(now);
let expired: Vec<ConnKey> = self
.entries
.iter()
.filter(|(_, e)| e.timeout < now)
.map(|(k, _)| k.clone())
.collect();
for key in expired {
self.entries.remove(&key);
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
/// Per-protocol and per-state connection breakdown.
/// Returns lines like:
/// tcp_entries: N (est=N syn=N syn_recv=N fin=N tw=N close=N)
/// udp_entries: N
/// icmp_entries: N
/// over_limit: N
/// total_entries: N
pub fn stats(&self) -> alloc::string::String {
let mut tcp = 0u32;
let mut tcp_est = 0u32;
let mut tcp_syn = 0u32;
let mut tcp_syn_recv = 0u32;
let mut tcp_fin = 0u32;
let mut tcp_tw = 0u32;
let mut tcp_close = 0u32;
let mut udp = 0u32;
let mut icmp = 0u32;
for entry in self.entries.values() {
match entry.key.l4proto {
6 => {
tcp += 1;
match entry.tcp_state {
TcpTracking::None => {}
TcpTracking::SynSent => tcp_syn += 1,
TcpTracking::SynRecv => tcp_syn_recv += 1,
TcpTracking::Established => tcp_est += 1,
TcpTracking::FinWait => tcp_fin += 1,
TcpTracking::TimeWait => tcp_tw += 1,
TcpTracking::Close => tcp_close += 1,
}
}
17 => udp += 1,
1 | 58 => icmp += 1,
_ => {}
}
}
let mut out = alloc::format!(
"tcp_entries: {} (est={} syn={} syn_recv={} fin={} tw={} close={})\n",
tcp, tcp_est, tcp_syn, tcp_syn_recv, tcp_fin, tcp_tw, tcp_close
);
out.push_str(&alloc::format!("udp_entries: {}\n", udp));
out.push_str(&alloc::format!("icmp_entries: {}\n", icmp));
out.push_str(&alloc::format!("over_limit: {}\n", self.over_limit_count));
out.push_str(&alloc::format!("icmp_errors: {}\n", self.icmp_error_count));
out.push_str(&alloc::format!("max_entries: {}\n", self.max_entries));
out.push_str(&alloc::format!("total_entries: {}\n", self.entries.len()));
out
}
fn check_syn_limit(&mut self, src: IpAddress, now: Instant) -> bool {
const SYN_LIMIT: u32 = 100;
const SYN_WINDOW: Duration = Duration::from_secs(1);
let entry = self.syn_rate_limits.entry(src).or_insert((0, now));
if now > entry.1 + SYN_WINDOW {
*entry = (1, now);
} else {
entry.0 += 1;
}
entry.0 > SYN_LIMIT
}
fn check_icmp_limit(&mut self, src: IpAddress, now: Instant) -> bool {
const ICMP_LIMIT: u32 = 20;
const ICMP_WINDOW: Duration = Duration::from_secs(1);
let entry = self.icmp_rate_limits.entry(src).or_insert((0, now));
if now > entry.1 + ICMP_WINDOW {
*entry = (1, now);
} else {
entry.0 += 1;
}
entry.0 > ICMP_LIMIT
}
pub fn format(&self) -> alloc::string::String {
let mut out = alloc::format!("conntrack entries: {}, over_limit: {}\n", self.entries.len(), self.over_limit_count);
for entry in self.entries.values() {
let tcp_info = if entry.key.l4proto == 6 {
alloc::format!(" tcp={:?}", entry.tcp_state)
} else {
alloc::string::String::new()
};
out.push_str(&alloc::format!(
" {:?}{} src={} dst={} sport={} dport={} orig_pkts={} orig_bytes={} reply_pkts={} reply_bytes={}\n",
entry.state,
tcp_info,
entry.key.src_addr,
entry.key.dst_addr,
entry.key.src_port,
entry.key.dst_port,
entry.orig_packets,
entry.orig_bytes,
entry.reply_packets,
entry.reply_bytes,
));
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::filter::Hook;
fn make_tcp_syn_packet() -> Vec<u8> {
let mut p = vec![0u8; 64];
// IPv4 header
p[0] = 0x45; // version 4, IHL 5
p[9] = 6; // protocol = TCP
// TCP header at offset 20
p[20] = 0x50; p[21] = 0x00; // src port 0x5000 = 20480
p[22] = 0x50; p[23] = 0x00; // dst port
// TCP flags at offset 33 (20 + 13)
p[33] = 0x02; // SYN
p
}
fn make_tcp_data_packet() -> Vec<u8> {
let mut p = vec![0u8; 64];
p[0] = 0x45;
p[9] = 6;
p[33] = 0x10; // ACK only (not SYN)
p
}
fn make_icmp_echo_packet() -> Vec<u8> {
let mut p = vec![0u8; 64];
p[0] = 0x45;
p[9] = 1; // protocol = ICMP
// ICMP header at offset 20
p[20] = 8; // echo request
p
}
fn make_ctx<'a>(protocol: u8, packet: &'a [u8]) -> PacketContext<'a> {
PacketContext {
hook: Hook::InputLocal,
in_dev: None,
out_dev: None,
src_addr: IpAddress::v4(10, 0, 0, 1),
dst_addr: IpAddress::v4(10, 0, 0, 2),
protocol,
src_port: Some(1234),
dst_port: Some(80),
packet,
}
}
fn make_v6_ctx<'a>(protocol: u8, packet: &'a [u8]) -> PacketContext<'a> {
PacketContext {
hook: Hook::InputLocal,
in_dev: None,
out_dev: None,
src_addr: IpAddress::v6(0xfe80, 0, 0, 0, 0, 0, 0, 1),
dst_addr: IpAddress::v6(0xfe80, 0, 0, 0, 0, 0, 0, 2),
protocol,
src_port: Some(1234),
dst_port: Some(80),
packet,
}
}
#[test]
fn syn_limit_triggers_after_threshold() {
let mut ct = ConntrackTable::new();
let packet = make_tcp_syn_packet();
let now = Instant::from_secs(0);
let mut over_limit_count = 0;
for i in 0..150 {
let ctx = make_ctx(6, &packet);
if ct.track(&ctx, now) == ConnState::OverLimit {
over_limit_count += 1;
}
let _ = i;
}
assert!(over_limit_count >= 50,
"Should trigger SYN limit after 100 SYNs/sec; got {} over_limit",
over_limit_count);
}
#[test]
fn icmp_limit_triggers_after_threshold() {
let mut ct = ConntrackTable::new();
let packet = make_icmp_echo_packet();
let now = Instant::from_secs(0);
let mut over_limit_count = 0;
for _i in 0..50 {
let ctx = make_ctx(1, &packet);
if ct.track(&ctx, now) == ConnState::OverLimit {
over_limit_count += 1;
}
}
assert!(over_limit_count >= 25,
"Should trigger ICMP echo limit after 20/sec; got {} over_limit",
over_limit_count);
}
#[test]
fn syn_and_icmp_have_independent_budgets() {
// After R37 fix, separate maps — TCP SYN traffic shouldn't
// affect ICMP echo budget and vice versa.
let mut ct = ConntrackTable::new();
let tcp = make_tcp_syn_packet();
let icmp = make_icmp_echo_packet();
let now = Instant::from_secs(0);
// Burn through TCP SYN budget
for _i in 0..150 {
let ctx = make_ctx(6, &tcp);
let _ = ct.track(&ctx, now);
}
// ICMP should still have its full budget — first 20 echo requests
// should not be limited, only the 21st onward
let mut not_over_limit = 0;
let mut over_limit = 0;
for _i in 0..30 {
let ctx = make_ctx(1, &icmp);
if ct.track(&ctx, now) == ConnState::OverLimit {
over_limit += 1;
} else {
not_over_limit += 1;
}
}
assert_eq!(not_over_limit, 20,
"ICMP budget should be independent — first 20 should pass even after TCP limit exhausted");
assert_eq!(over_limit, 10,
"Remaining 10 ICMP echoes (21-30) should be over_limit");
}
#[test]
fn non_syn_tcp_does_not_count_against_syn_limit() {
// Pure ACK packets shouldn't count against SYN flood budget.
let mut ct = ConntrackTable::new();
let ack = make_tcp_data_packet();
let now = Instant::from_secs(0);
let mut over_limit = 0;
for _i in 0..200 {
let ctx = make_ctx(6, &ack);
if ct.track(&ctx, now) == ConnState::OverLimit {
over_limit += 1;
}
}
assert_eq!(over_limit, 0,
"Non-SYN TCP packets must not trigger SYN flood limit");
}
#[test]
fn ipv6_syn_detection_works() {
// Regression test for the IPv4-only `is_syn()` bug.
// Before fix: offset 33 was hardcoded, reading IPv6 header
// bytes instead of TCP flags (which are at offset 53 for IPv6).
let mut ct = ConntrackTable::new();
let mut p = vec![0u8; 80];
// IPv6 header: version 6 at byte 0 (0x60), next header = 6 (TCP) at byte 6
p[0] = 0x60;
p[6] = 6; // next header = TCP
// TCP header at offset 40, flags at offset 53
p[53] = 0x02; // SYN
let now = Instant::from_secs(0);
// The l3num is inferred from ctx.src_addr (v6 → l3num=6).
let mut over_limit_count = 0;
for _i in 0..150 {
let ctx = make_v6_ctx(6, &p);
if ct.track(&ctx, now) == ConnState::OverLimit {
over_limit_count += 1;
}
}
assert!(over_limit_count >= 50,
"IPv6 SYN must trigger rate limit after threshold; got {}",
over_limit_count);
}
// Helper for building TCP packets with specific flags.
fn make_tcp_pkt(flags: u8) -> Vec<u8> {
let mut p = vec![0u8; 64];
p[0] = 0x45; p[9] = 6; // IPv4 TCP
p[12] = 10; p[13] = 0; p[14] = 0; p[15] = 1;
p[16] = 10; p[17] = 0; p[18] = 0; p[19] = 2;
p[33] = flags;
p
}
fn make_reply_ctx<'a>(packet: &'a [u8]) -> PacketContext<'a> {
PacketContext {
hook: Hook::InputLocal, in_dev: None, out_dev: None,
src_addr: IpAddress::v4(10, 0, 0, 2),
dst_addr: IpAddress::v4(10, 0, 0, 1),
protocol: 6, src_port: Some(80), dst_port: Some(1234),
packet,
}
}
#[test]
fn rst_forces_state_to_new() {
// RST forces the connection state back to New and resets to Close tracking.
let mut ct = ConntrackTable::new();
let now = Instant::from_secs(0);
let syn = make_tcp_syn_packet();
let _ = ct.track(&make_ctx(6, &syn), now);
// Send RST.
let rst = make_tcp_pkt(0x04); // FIN flag = 0x01, RST = 0x04
let _ = ct.track(&make_ctx(6, &rst), Instant::from_secs(1));
// Verify that the entry was closed (no entries remain due to short timeout).
// The state machine should set tcp_state=Close with a 10s timeout.
// After track() processes the RST, the entry transitions to
// ConnState::New (so it won't match 'Established' rules) but
// stays in memory for 10 seconds until cleanup.
assert_eq!(ct.len(), 1,
"RST connection entry stays in memory for 10s cleanup window");
}
#[test]
fn fin_transitions_established_to_timewait() {
// Test that FIN from both directions transitions through
// FinWait to TimeWait.
let mut ct = ConntrackTable::new();
let now = Instant::from_secs(0);
let syn = make_tcp_syn_packet();
let _ = ct.track(&make_ctx(6, &syn), now);
// Reply FIN (from responder).
let fin = make_tcp_pkt(0x01);
let _ = ct.track(&make_reply_ctx(&fin), now);
// Original FIN (from initiator).
let _ = ct.track(&make_ctx(6, &fin), now);
// The connection should have advanced past FinWait via
// the two FINs. The state is now TimeWait.
// Verify that the entry exists (TimeWait has 120s timeout).
assert!(ct.len() > 0,
"TimeWait state should keep the connection alive for 120s");
}
}
+175
View File
@@ -0,0 +1,175 @@
//! Netfilter-style packet filter for Red Bear OS netstack.
//!
//! This module mirrors the architecture of Linux 7.1's netfilter subsystem
//! (`net/netfilter/core.c`, `net/ipv4/netfilter/iptable_filter.c`).
//!
//! Mapping to Linux 7.1:
//! - [`Hook`] mirrors `enum nf_inet_hooks` (`include/uapi/linux/netfilter.h:42`)
//! - [`Verdict`] mirrors the `NF_DROP` / `NF_ACCEPT` constants
//! (`include/uapi/linux/netfilter.h:11-17`)
//! - [`FilterEngine`] mirrors the role of `nf_iterate` + `xt_table`
//! - [`FilterRule`] mirrors `ipt_entry` + `ipt_entry_match` + `ipt_entry_target`
//! (`net/ipv4/netfilter/ip_tables.h`)
//!
//! The filter is stateless (no conntrack in this initial revision). A future
//! revision will add a conntrack hash table analogous to `nf_conn` in
//! `net/netfilter/nf_conntrack_core.c`.
mod conntrack;
mod nat;
mod rule;
mod table;
pub use conntrack::{ConnState, ConntrackTable};
pub use nat::{NatBinding, NatRule, NatTable, NatType, rewrite_src_ipv4, parse_nat_rule};
pub use rule::{FilterRule, MatchResult, Protocol, StateMatch};
pub use table::{FilterTable, parse_rule};
/// The five netfilter hook points (mirrors `enum nf_inet_hooks`).
///
/// Only `InputLocal` and `OutputLocal` are wired in this revision. The other
/// three are defined for future expansion: `PreRouting` / `Forward` /
/// `PostRouting` are required for routing/firewalling of transit traffic
/// (when Red Bear gains multi-homed forwarding in Phase 6).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Hook {
/// `NF_INET_PRE_ROUTING` — packet just arrived from a NIC, before the
/// routing decision. (Linux 7.1: `include/uapi/linux/netfilter.h:43`)
PreRouting,
/// `NF_INET_LOCAL_IN` — packet destined for this host, after the routing
/// decision. (Linux 7.1: `include/uapi/linux/netfilter.h:44`)
InputLocal,
/// `NF_INET_FORWARD` — packet being forwarded between NICs. (Linux 7.1:
/// `include/uapi/linux/netfilter.h:45`)
Forward,
/// `NF_INET_LOCAL_OUT` — packet generated locally, before the routing
/// decision. (Linux 7.1: `include/uapi/linux/netfilter.h:46`)
OutputLocal,
/// `NF_INET_POST_ROUTING` — packet leaving a NIC, after the routing
/// decision. (Linux 7.1: `include/uapi/linux/netfilter.h:47`)
PostRouting,
}
impl Hook {
/// Returns all hook points, in Linux's canonical order (matches
/// `enum nf_inet_hooks` ordering).
pub const ALL: [Hook; 5] = [
Hook::PreRouting,
Hook::InputLocal,
Hook::Forward,
Hook::OutputLocal,
Hook::PostRouting,
];
/// Returns the kernel-style numeric id (matches `NF_INET_*` constants).
pub const fn as_u32(self) -> u32 {
match self {
Hook::PreRouting => 0,
Hook::InputLocal => 1,
Hook::Forward => 2,
Hook::OutputLocal => 3,
Hook::PostRouting => 4,
}
}
/// Inverse of [`Self::as_u32`]. Returns `None` for unknown ids.
pub const fn from_u32(id: u32) -> Option<Hook> {
match id {
0 => Some(Hook::PreRouting),
1 => Some(Hook::InputLocal),
2 => Some(Hook::Forward),
3 => Some(Hook::OutputLocal),
4 => Some(Hook::PostRouting),
_ => None,
}
}
/// Short lowercase name used in the scheme interface (e.g. "input",
/// "output"). Mirrors the chain name conventions of iptables/nftables.
pub const fn name(self) -> &'static str {
match self {
Hook::PreRouting => "prerouting",
Hook::InputLocal => "input",
Hook::Forward => "forward",
Hook::OutputLocal => "output",
Hook::PostRouting => "postrouting",
}
}
}
/// The decision returned by the filter engine after evaluating rules.
///
/// Mirrors the verdict constants in `include/uapi/linux/netfilter.h`:
/// - [`Accept`] = `NF_ACCEPT` (= 1)
/// - [`Drop`] = `NF_DROP` (= 0)
///
/// Linux's `NF_QUEUE` (= 3) and `NF_STOLEN` (= 2) are not supported in this
/// revision (no userspace packet queue and no socket ownership transfer).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Verdict {
/// `NF_ACCEPT` — continue normal processing.
Accept,
/// `NF_DROP` — silently discard the packet.
Drop,
/// Log the packet details and continue evaluating the next rule.
/// Mirrors iptables `-j LOG --log-prefix "..."`.
Log,
/// Send ICMP Destination Unreachable (port) back to the source.
/// Mirrors iptables `-j REJECT --reject-with icmp-port-unreachable`.
Reject,
}
impl Verdict {
pub const fn as_u32(self) -> u32 {
match self {
Verdict::Accept => 1,
Verdict::Drop => 0,
Verdict::Log => 2,
Verdict::Reject => 3,
}
}
pub const fn from_u32(id: u32) -> Option<Verdict> {
match id {
1 => Some(Verdict::Accept),
0 => Some(Verdict::Drop),
2 => Some(Verdict::Log),
3 => Some(Verdict::Reject),
_ => None,
}
}
pub const fn name(self) -> &'static str {
match self {
Verdict::Accept => "ACCEPT",
Verdict::Drop => "DROP",
Verdict::Log => "LOG",
Verdict::Reject => "REJECT",
}
}
}
/// The context passed to the filter engine for evaluation.
///
/// Mirrors `struct nf_hook_state` (`include/linux/netfilter.h:78`):
/// - `hook` ↔ `state->hook`
/// - `in_dev` ↔ `state->in`
/// - `out_dev` ↔ `state->out`
/// - `protocol` ↔ the L4 protocol number (extracted from the IP header)
///
/// `skb` (the buffer) is split into its derived fields here for ergonomic
/// matching without forcing each rule to re-parse the packet.
#[derive(Debug, Clone)]
pub struct PacketContext<'a> {
pub hook: Hook,
pub in_dev: Option<alloc::rc::Rc<str>>,
pub out_dev: Option<alloc::rc::Rc<str>>,
pub src_addr: smoltcp::wire::IpAddress,
pub dst_addr: smoltcp::wire::IpAddress,
pub protocol: u8,
pub src_port: Option<u16>,
pub dst_port: Option<u16>,
pub packet: &'a [u8],
}
extern crate alloc;
+495
View File
@@ -0,0 +1,495 @@
//! Network Address Translation — mirrors Linux 7.1's `nf_nat` subsystem.
//!
//! Reference files:
//! - `net/netfilter/nf_nat_core.c` — `nf_nat_packet()`, `nf_nat_setup_info()`
//! - `include/net/netfilter/nf_nat.h` — `struct nf_conn_nat`
//! - `net/netfilter/nf_nat_proto_tcp.c` — TCP checksum adjustment after NAT
//! - `net/netfilter/nf_nat_proto_udp.c` — UDP checksum adjustment after NAT
//!
//! Two NAT types are supported:
//! - **SNAT** (Source NAT): changes the source IP of outgoing packets.
//! Applied in the OUTPUT/POSTROUTING path. Mirrors `nf_nat_masquerade.c`.
//! - **DNAT** (Destination NAT): changes the destination IP of incoming
//! packets. Applied in the INPUT/PREROUTING path. Used for port forwarding.
extern crate alloc;
use alloc::collections::BTreeMap;
use alloc::rc::Rc;
use alloc::string::String;
use alloc::vec::Vec;
use smoltcp::wire::{IpAddress, Ipv4Address, Ipv6Address};
use super::{Hook, PacketContext};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NatType {
Snat,
Dnat,
}
#[derive(Debug, Clone)]
pub struct NatRule {
pub id: u32,
pub nat_type: NatType,
pub hook: Hook,
pub src_match: Option<IpAddress>,
pub dst_match: Option<IpAddress>,
pub trans_addr: IpAddress,
pub trans_port: Option<u16>,
pub match_count: u64,
}
#[derive(Debug, Clone)]
pub struct NatBinding {
pub orig_src: IpAddress,
pub trans_src: IpAddress,
pub orig_dst: IpAddress,
pub trans_dst: IpAddress,
pub orig_sport: u16,
pub trans_sport: u16,
pub orig_dport: u16,
pub trans_dport: u16,
}
impl Default for NatBinding {
fn default() -> Self {
Self {
orig_src: IpAddress::Ipv4(Ipv4Address::UNSPECIFIED),
trans_src: IpAddress::Ipv4(Ipv4Address::UNSPECIFIED),
orig_dst: IpAddress::Ipv4(Ipv4Address::UNSPECIFIED),
trans_dst: IpAddress::Ipv4(Ipv4Address::UNSPECIFIED),
orig_sport: 0,
trans_sport: 0,
orig_dport: 0,
trans_dport: 0,
}
}
}
#[derive(Debug)]
pub struct NatTable {
pub rules: Vec<NatRule>,
pub bindings: Vec<NatBinding>,
next_id: u32,
ephemeral_port: u16,
}
impl NatTable {
pub fn new() -> Self {
Self {
rules: Vec::new(),
bindings: Vec::new(),
next_id: 1,
ephemeral_port: 40000,
}
}
pub fn add(&mut self, mut rule: NatRule) -> u32 {
rule.id = self.next_id;
self.next_id = self.next_id.saturating_add(1);
let id = rule.id;
self.rules.push(rule);
id
}
pub fn lookup_snat(
&self,
hook: Hook,
src: IpAddress,
_dst: IpAddress,
) -> Option<(IpAddress, Option<u16>)> {
for rule in self.rules.iter().filter(|r| {
r.nat_type == NatType::Snat && r.hook == hook
}) {
if let Some(m) = rule.src_match {
if m != src {
continue;
}
}
return Some((rule.trans_addr, rule.trans_port));
}
if hook == Hook::OutputLocal || hook == Hook::PostRouting {
for rule in self.rules.iter().filter(|r| {
r.nat_type == NatType::Snat && r.src_match.is_none()
}) {
return Some((rule.trans_addr, rule.trans_port));
}
}
None
}
/// Record an active SNAT binding for display purposes.
/// Called after a successful SNAT rewrite.
pub fn record_snat(
&mut self,
orig_src: IpAddress,
trans_src: IpAddress,
orig_sport: u16,
trans_sport: u16,
) {
// Cap bindings to prevent unbounded growth.
if self.bindings.len() >= 1024 {
self.bindings.remove(0);
}
self.bindings.push(NatBinding {
orig_src,
trans_src,
orig_dst: IpAddress::v4(0, 0, 0, 0),
trans_dst: IpAddress::v4(0, 0, 0, 0),
orig_sport,
trans_sport,
orig_dport: 0,
trans_dport: 0,
});
}
pub fn lookup_dnat(
&self,
hook: Hook,
_src: IpAddress,
dst: IpAddress,
) -> Option<(IpAddress, Option<u16>)> {
for rule in self.rules.iter().filter(|r| {
r.nat_type == NatType::Dnat && r.hook == hook
}) {
if let Some(m) = rule.dst_match {
if m != dst {
continue;
}
}
return Some((rule.trans_addr, rule.trans_port));
}
None
}
pub fn alloc_ephemeral_port(&mut self) -> u16 {
let port = self.ephemeral_port;
self.ephemeral_port = if self.ephemeral_port >= 65535 {
40000
} else {
self.ephemeral_port.saturating_add(1)
};
port
}
pub fn format(&self) -> String {
let mut out = String::from("Nat table:\n");
for rule in &self.rules {
out.push_str(&alloc::format!(
" [{:>3}] {:?} {:?} -> {} port={:?} matches={}\n",
rule.id,
rule.nat_type,
rule.hook,
rule.trans_addr,
rule.trans_port,
rule.match_count,
));
}
out
}
pub fn format_bindings(&self) -> String {
if self.bindings.is_empty() {
return "no active bindings\n".to_string();
}
let mut out = format!("Active SNAT bindings: {}\n", self.bindings.len());
for b in &self.bindings {
out.push_str(&alloc::format!(
" {}:{} -> {}:{}\n",
b.orig_src, b.orig_sport, b.trans_src, b.trans_sport,
));
}
out
}
pub fn remove(&mut self, id: u32) -> bool {
if let Some(idx) = self.rules.iter().position(|r| r.id == id) {
self.rules.remove(idx);
true
} else {
false
}
}
}
/// Rewrites the source IP address in an IPv4 packet. Returns `true` on
/// success. Mirrors `nf_nat_ipv4_manip_pkt()` in
/// `net/ipv4/netfilter/nf_nat_l3proto_ipv4.c`.
pub fn rewrite_src_ipv4(packet: &mut [u8], new_src: Ipv4Address) -> bool {
if packet.len() < 20 {
return false;
}
let mut ipv4 = smoltcp::wire::Ipv4Packet::new_unchecked(packet);
ipv4.set_src_addr(new_src);
ipv4.fill_checksum();
true
}
/// Rewrites the destination IP address in an IPv4 packet.
pub fn rewrite_dst_ipv4(packet: &mut [u8], new_dst: Ipv4Address) -> bool {
if packet.len() < 20 {
return false;
}
let mut ipv4 = smoltcp::wire::Ipv4Packet::new_unchecked(packet);
ipv4.set_dst_addr(new_dst);
ipv4.fill_checksum();
true
}
/// Rewrites the TCP/UDP port in the transport header and recomputes the
/// checksum. Mirrors `nf_nat_proto_tcp.c` (`tcp_manip_pkt`) and
/// `nf_nat_proto_udp.c` (`udp_manip_pkt`).
pub fn rewrite_port_ipv4(
packet: &mut [u8],
old_port: u16,
new_port: u16,
old_addr: IpAddress,
new_addr: IpAddress,
is_src: bool,
protocol: u8,
) {
let ip_header_len: usize = 20;
if packet.len() < ip_header_len + 4 {
return;
}
let transport = &mut packet[ip_header_len..];
let (port_offset, addr_old, addr_new) = if is_src {
(0, old_addr, new_addr)
} else {
(2, old_addr, new_addr)
};
transport[port_offset] = (new_port >> 8) as u8;
transport[port_offset + 1] = (new_port & 0xff) as u8;
if protocol == 17 {
let old_csum = u16::from_be_bytes([transport[6], transport[7]]);
if old_csum != 0 {
transport[6] = 0;
transport[7] = 0;
}
} else {
transport[16] = 0;
transport[17] = 0;
}
recompute_transport_checksum(packet, protocol, addr_old, addr_new);
}
fn recompute_transport_checksum(
packet: &mut [u8],
protocol: u8,
_old_addr: IpAddress,
_new_addr: IpAddress,
) {
if packet.len() < 20 {
return;
}
let src_bytes: [u8; 4] = packet[12..16].try_into().unwrap_or([0; 4]);
let dst_bytes: [u8; 4] = packet[16..20].try_into().unwrap_or([0; 4]);
let src_addr = Ipv4Address::new(src_bytes[0], src_bytes[1], src_bytes[2], src_bytes[3]);
let dst_addr = Ipv4Address::new(dst_bytes[0], dst_bytes[1], dst_bytes[2], dst_bytes[3]);
let transport = &mut packet[20..];
match protocol {
6 => {
if transport.len() >= 20 {
let mut tcp = smoltcp::wire::TcpPacket::new_unchecked(transport);
tcp.fill_checksum(&IpAddress::Ipv4(src_addr), &IpAddress::Ipv4(dst_addr));
}
}
17 => {
if transport.len() >= 8 {
let mut udp = smoltcp::wire::UdpPacket::new_unchecked(transport);
udp.fill_checksum(&IpAddress::Ipv4(src_addr), &IpAddress::Ipv4(dst_addr));
}
}
_ => {}
}
}
pub fn parse_nat_rule(
line: &str,
) -> core::result::Result<NatRule, NatParseError> {
let mut tokens = line.split_whitespace();
let nat_type_str = tokens.next().ok_or(NatParseError::MissingField("nat type"))?;
let nat_type = match nat_type_str.to_uppercase().as_str() {
"SNAT" => NatType::Snat,
"DNAT" => NatType::Dnat,
_ => return Err(NatParseError::BadNatType(nat_type_str.to_string())),
};
let hook_str = tokens.next().ok_or(NatParseError::MissingField("hook"))?;
let hook = match hook_str.to_lowercase().as_str() {
"prerouting" => Hook::PreRouting,
"input" => Hook::InputLocal,
"forward" => Hook::Forward,
"output" => Hook::OutputLocal,
"postrouting" => Hook::PostRouting,
_ => return Err(NatParseError::BadHook(hook_str.to_string())),
};
let to_str = tokens.next();
if to_str != Some("to") {
return Err(NatParseError::MissingField("to"));
}
let addr_str = tokens.next().ok_or(NatParseError::MissingField("address"))?;
let trans_addr = if let Ok(v4) = Ipv4Address::from_str(addr_str) {
IpAddress::Ipv4(v4)
} else if let Ok(v6) = Ipv6Address::from_str(addr_str) {
IpAddress::Ipv6(v6)
} else {
return Err(NatParseError::BadAddress(addr_str.to_string()));
};
let mut trans_port = None;
let mut src_match = None;
let mut dst_match = None;
while let Some(token) = tokens.next() {
match token {
"port" => {
let p = tokens.next().ok_or(NatParseError::MissingField("port value"))?;
trans_port = Some(p.parse().map_err(|_| NatParseError::BadPort(p.to_string()))?);
}
"match-src" => {
let a = tokens.next().ok_or(NatParseError::MissingField("match-src value"))?;
src_match = Some(parse_addr(a)?);
}
"match-dst" => {
let a = tokens.next().ok_or(NatParseError::MissingField("match-dst value"))?;
dst_match = Some(parse_addr(a)?);
}
_ => {}
}
}
Ok(NatRule {
id: 0,
nat_type,
hook,
src_match,
dst_match,
trans_addr,
trans_port,
match_count: 0,
})
}
fn parse_addr(s: &str) -> core::result::Result<IpAddress, NatParseError> {
if let Ok(v4) = Ipv4Address::from_str(s) {
Ok(IpAddress::Ipv4(v4))
} else if let Ok(v6) = Ipv6Address::from_str(s) {
Ok(IpAddress::Ipv6(v6))
} else {
Err(NatParseError::BadAddress(s.to_string()))
}
}
use core::str::FromStr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NatParseError {
MissingField(&'static str),
BadNatType(String),
BadHook(String),
BadAddress(String),
BadPort(String),
}
#[cfg(test)]
mod tests {
use super::*;
fn make_ipv4_udp_packet(src: [u8; 4], dst: [u8; 4]) -> Vec<u8> {
let mut p = vec![0u8; 28];
p[0] = 0x45; p[1] = 0x00;
p[2] = 0x00; p[3] = 0x1c;
p[6] = 0x00; p[7] = 0x00;
p[8] = 64;
p[9] = 17;
p[10] = 0x00; p[11] = 0x00;
p[12..16].copy_from_slice(&src);
p[16..20].copy_from_slice(&dst);
p[20] = 0x12; p[21] = 0x34;
p[22] = 0x56; p[23] = 0x78;
p[24] = 0x00; p[25] = 0x08;
p[26] = 0x00; p[27] = 0x00;
p
}
fn read_ipv4(p: &[u8], offset: usize) -> Ipv4Address {
Ipv4Address::new(p[offset], p[offset+1], p[offset+2], p[offset+3])
}
#[test]
fn rewrite_src_changes_source_address() {
let mut p = make_ipv4_udp_packet([10, 0, 0, 1], [192, 168, 1, 1]);
let new_src = Ipv4Address::new(192, 168, 99, 99);
assert!(rewrite_src_ipv4(&mut p, new_src));
assert_eq!(read_ipv4(&p, 12), new_src, "Source IP must be rewritten");
assert_eq!(read_ipv4(&p, 16), Ipv4Address::new(192, 168, 1, 1),
"Destination IP must be unchanged");
}
#[test]
fn rewrite_dst_changes_destination_address() {
let mut p = make_ipv4_udp_packet([10, 0, 0, 1], [192, 168, 1, 1]);
let new_dst = Ipv4Address::new(8, 8, 8, 8);
assert!(rewrite_dst_ipv4(&mut p, new_dst));
assert_eq!(read_ipv4(&p, 16), new_dst);
assert_eq!(read_ipv4(&p, 12), Ipv4Address::new(10, 0, 0, 1),
"Source IP must be unchanged");
}
#[test]
fn rewrite_short_packet_returns_false() {
let mut p = vec![0u8; 10];
assert!(!rewrite_src_ipv4(&mut p, Ipv4Address::new(1, 2, 3, 4)));
assert!(!rewrite_dst_ipv4(&mut p, Ipv4Address::new(1, 2, 3, 4)));
}
#[test]
fn rewrite_recomputes_checksum_when_previously_nonzero() {
let mut p = make_ipv4_udp_packet([10, 0, 0, 1], [192, 168, 1, 1]);
// Compute a real checksum first
if let Ok(mut ipv4) = smoltcp::wire::Ipv4Packet::new_checked(&mut p) {
ipv4.fill_checksum();
}
let original_csum = u16::from_be_bytes([p[10], p[11]]);
assert_ne!(original_csum, 0, "Pre-condition: checksum should be non-zero");
let _ = rewrite_src_ipv4(&mut p, Ipv4Address::new(10, 0, 0, 2));
let new_csum = u16::from_be_bytes([p[10], p[11]]);
assert_ne!(new_csum, original_csum,
"Checksum must be updated after source rewrite");
}
#[test]
fn nat_table_add_assigns_unique_ids() {
let mut t = NatTable::new();
let make_rule = || NatRule {
id: 0,
nat_type: NatType::Snat,
hook: Hook::OutputLocal,
src_match: None,
dst_match: None,
trans_addr: IpAddress::v4(192, 168, 1, 100),
trans_port: None,
match_count: 0,
};
let id1 = t.add(make_rule());
let id2 = t.add(make_rule());
assert_ne!(id1, id2, "Each rule gets a unique ID");
}
#[test]
fn nat_ephemeral_port_allocates_unique() {
let mut t = NatTable::new();
let p1 = t.alloc_ephemeral_port();
let p2 = t.alloc_ephemeral_port();
assert_ne!(p1, p2, "Ephemeral ports must be unique across calls");
}
}
+187
View File
@@ -0,0 +1,187 @@
//! Filter rule representation and packet matching.
//!
//! Mirrors the Linux 7.1 `ipt_entry` structure and the `xt_match` matching
//! framework:
//! - Linux `ipt_entry` lives in `net/ipv4/netfilter/ip_tables.h`
//! - Linux `xt_match` lives in `net/netfilter/x_tables.h`
//! - Linux `xt_action_param` (match context) lives in
//! `include/linux/netfilter/x_tables.h`
//!
//! In this revision the rule supports a fixed set of match fields that
//! cover the most common iptables/xt_matches: `src`/`dst` address+CIDR,
//! `protocol`, `sport`/`dport` (TCP/UDP only), and the `in`/`out`
//! interface. This is intentionally narrower than the full xtables match
//! set — extensions like `iprange`, `mac`, `conntrack`, `recent`, etc.
//! will be added in follow-up revisions as separate match kinds.
use alloc::rc::Rc;
use smoltcp::wire::{IpAddress, Ipv4Address, Ipv6Address};
use super::{ConnState, Hook, PacketContext, Verdict};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StateMatch {
New,
Established,
Related,
Invalid,
}
/// The IP protocol number used in a rule. Values mirror the IANA assigned
/// numbers (`include/uapi/linux/in.h`):
/// - 1 = `IPPROTO_ICMP`
/// - 6 = `IPPROTO_TCP`
/// - 17 = `IPPROTO_UDP`
/// - 58 = `IPPROTO_ICMPV6`
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Protocol(pub u8);
impl Protocol {
pub const ANY: Option<Protocol> = None;
pub const ICMP: Protocol = Protocol(1);
pub const TCP: Protocol = Protocol(6);
pub const UDP: Protocol = Protocol(17);
pub const ICMP6: Protocol = Protocol(58);
}
/// Outcome of matching a single rule against a packet.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchResult {
/// Every field in the rule matched. The rule's verdict applies.
Match,
/// At least one field did not match. Continue to the next rule.
NoMatch,
}
/// A single filter rule. The fields mirror the iptables CLI options:
///
/// | Rust field | iptables equivalent |
/// |----------------------|----------------------------------------------|
/// | `src_addr`/`src_cidr`| `--source` / `--src-range` (with cidr_len) |
/// | `dst_addr`/`dst_cidr`| `--destination` / `--dst-range` |
/// | `protocol` | `--protocol` |
/// | `src_port` | `--source-port` (TCP/UDP only) |
/// | `dst_port` | `--destination-port` |
/// | `in_dev` | `--in-interface` |
/// | `out_dev` | `--out-interface` |
/// | `verdict` | `--jump` (ACCEPT or DROP) |
///
/// `None` in any of these fields means "match any" (a wildcard), mirroring
/// iptables behaviour where omitting `--source` matches every source.
#[derive(Debug, Clone)]
pub struct FilterRule {
pub id: u32,
pub hook: Hook,
pub src_addr: Option<IpAddress>,
pub src_prefix_len: u8,
pub dst_addr: Option<IpAddress>,
pub dst_prefix_len: u8,
pub protocol: Option<Protocol>,
pub src_port: Option<u16>,
pub dst_port: Option<u16>,
pub in_dev: Option<Rc<str>>,
pub out_dev: Option<Rc<str>>,
pub state_match: Option<StateMatch>,
pub verdict: Verdict,
pub match_count: u64,
}
impl FilterRule {
/// Returns true if this rule applies to the given hook point. Mirrors
/// `ipt_entry->comefrom` semantics: a rule attached to `Hook::InputLocal`
/// only fires when the engine evaluates the INPUT chain.
pub fn applies_to(&self, hook: Hook) -> bool {
self.hook == hook
}
/// Evaluates this rule against a packet. Mirrors
/// `xt_action_param`-based matching in `x_tables.c` (`xt_check_match`).
pub fn matches(&self, ctx: &PacketContext<'_>) -> MatchResult {
if !self.applies_to(ctx.hook) {
return MatchResult::NoMatch;
}
if let Some(src) = self.src_addr {
if !addr_in_cidr(src, self.src_prefix_len, ctx.src_addr) {
return MatchResult::NoMatch;
}
}
if let Some(dst) = self.dst_addr {
if !addr_in_cidr(dst, self.dst_prefix_len, ctx.dst_addr) {
return MatchResult::NoMatch;
}
}
if let Some(proto) = self.protocol {
if proto.0 != ctx.protocol {
return MatchResult::NoMatch;
}
}
if let Some(sport) = self.src_port {
if ctx.src_port != Some(sport) {
return MatchResult::NoMatch;
}
}
if let Some(dport) = self.dst_port {
if ctx.dst_port != Some(dport) {
return MatchResult::NoMatch;
}
}
if let Some(in_dev) = &self.in_dev {
match &ctx.in_dev {
Some(ctx_dev) if ctx_dev.as_ref() == in_dev.as_ref() => {}
_ => return MatchResult::NoMatch,
}
}
if let Some(out_dev) = &self.out_dev {
match &ctx.out_dev {
Some(ctx_dev) if ctx_dev.as_ref() == out_dev.as_ref() => {}
_ => return MatchResult::NoMatch,
}
}
MatchResult::Match
}
}
/// Returns true if `addr` falls inside the prefix `network/prefix_len`.
/// Mirrors `ip_masked_match` (`net/ipv4/netfilter/ipt_addr.c`) — IPv4
/// uses the simple 32-bit mask, IPv6 uses the 128-bit bitwise mask.
fn addr_in_cidr(network: IpAddress, prefix_len: u8, addr: IpAddress) -> bool {
match (network, addr) {
(IpAddress::Ipv4(net), IpAddress::Ipv4(a)) => {
if prefix_len == 0 {
return true;
}
if prefix_len > 32 {
return false;
}
let mask: u32 = if prefix_len == 32 {
u32::MAX
} else {
!((1u32 << (32 - prefix_len)) - 1)
};
(u32::from(net) & mask) == (u32::from(a) & mask)
}
(IpAddress::Ipv6(net), IpAddress::Ipv6(a)) => {
if prefix_len > 128 {
return false;
}
let net_bytes = net.octets();
let a_bytes = a.octets();
let full_bytes = (prefix_len / 8) as usize;
let remainder = prefix_len % 8;
if net_bytes[..full_bytes] != a_bytes[..full_bytes] {
return false;
}
if remainder > 0 && full_bytes < 16 {
let mask = 0xff << (8 - remainder);
(net_bytes[full_bytes] & mask) == (a_bytes[full_bytes] & mask)
} else {
true
}
}
(IpAddress::Ipv4(_), IpAddress::Ipv6(_)) | (IpAddress::Ipv6(_), IpAddress::Ipv4(_)) => {
false
}
}
}
extern crate alloc;
+530
View File
@@ -0,0 +1,530 @@
//! Filter table: a collection of rules and the per-chain default policies.
//!
//! Mirrors Linux 7.1's `xt_table` (`include/linux/netfilter/x_tables.h`):
//! - `valid_hooks` (bitmask) ↔ `FilterTable::enabled_hooks` (set)
//! - `entries` (rule blob) ↔ `FilterTable::rules`
//! - Per-hook default policy ↔ `FilterTable::default_policy`
//!
//! The parser accepts an iptables-style textual rule. The grammar is a
//! deliberate subset of iptables that covers the most common cases and
//! stays readable:
//!
//! ```text
//! ACTION CHAIN [-i IFACE] [-o IFACE] [-p PROTO] [-s ADDR[/LEN]] [-d ADDR[/LEN]]
//! [--sport PORT] [--dport PORT] [state STATE|--ctstate STATE[,STATE...]]
//! ```
//!
//! Examples:
//! - `ACCEPT input -p tcp --dport 80`
//! - `DROP input -s 10.0.0.0/8`
//! - `ACCEPT output -d 192.168.1.0/24 --sport 1024`
//! - `ACCEPT input -p tcp --ctstate ESTABLISHED,RELATED`
extern crate alloc;
use alloc::collections::BTreeMap;
use alloc::rc::Rc;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
use core::str::FromStr;
use smoltcp::time::Instant;
use smoltcp::wire::{IpAddress, Ipv4Address, Ipv6Address};
use super::conntrack::{ConnState, ConntrackTable};
use super::nat::{NatRule, NatTable};
use super::rule::{FilterRule, Protocol, StateMatch};
use super::{Hook, PacketContext, Verdict};
/// A table of filter rules plus per-chain default policies.
///
/// In iptables terminology this is one "table" (the `filter` table).
/// We currently expose exactly one table; future extensions can add
/// `nat`, `mangle`, `raw` tables following the same model.
pub struct FilterTable {
pub rules: Vec<FilterRule>,
pub default_policy: BTreeMap<Hook, Verdict>,
pub next_id: u32,
pub conntrack: Option<ConntrackTable>,
pub nat_table: NatTable,
pub chain_counters: BTreeMap<Hook, (u64, u64)>,
pub log_buffer: Vec<String>,
}
impl FilterTable {
pub fn new() -> Self {
let mut default_policy = BTreeMap::new();
for &hook in &Hook::ALL {
default_policy.insert(hook, Verdict::Accept);
}
Self {
rules: Vec::new(),
default_policy,
next_id: 1,
conntrack: Some(ConntrackTable::new()),
nat_table: NatTable::new(),
chain_counters: BTreeMap::new(),
log_buffer: Vec::new(),
}
}
/// Evaluates the rules attached to `ctx.hook` in order, returning the
/// first matching verdict. If no rule matches, the chain's default
/// policy applies. Mirrors `nf_iterate` + `ipt_do_table` in
/// `net/ipv4/netfilter/ip_tables.c`.
pub fn evaluate(&mut self, ctx: &PacketContext<'_>, now: Instant) -> Verdict {
let conn_state = self.conntrack.as_mut().map(|ct| ct.track(ctx, now));
if conn_state == Some(ConnState::OverLimit) {
return Verdict::Drop;
}
let counter = self.chain_counters.entry(ctx.hook).or_insert((0, 0));
counter.0 = counter.0.saturating_add(1);
counter.1 = counter.1.saturating_add(ctx.packet.len() as u64);
let mut final_verdict: Option<Verdict> = None;
for rule in self.rules.iter_mut() {
if rule.state_match.is_some() {
match (rule.state_match, conn_state) {
(Some(StateMatch::Established), Some(ConnState::Established)) => {}
(Some(StateMatch::New), Some(ConnState::New)) => {}
(Some(StateMatch::Invalid), _) => continue,
(Some(StateMatch::Related), _) => continue,
(Some(_), _) => continue,
_ => {}
}
}
if rule.applies_to(ctx.hook) && rule.matches(ctx) == super::rule::MatchResult::Match {
rule.match_count = rule.match_count.saturating_add(1);
match rule.verdict {
Verdict::Log => {
let msg = alloc::format!(
"{} IN={} OUT={} SRC={} DST={} PROTO={} SPORT={} DPORT={}",
rule.id,
ctx.in_dev.as_deref().unwrap_or("-"),
ctx.out_dev.as_deref().unwrap_or("-"),
ctx.src_addr,
ctx.dst_addr,
ctx.protocol,
ctx.src_port.map(|p| p.to_string()).unwrap_or_else(|| "-".into()),
ctx.dst_port.map(|p| p.to_string()).unwrap_or_else(|| "-".into()),
);
if self.log_buffer.len() >= 100 {
self.log_buffer.remove(0);
}
self.log_buffer.push(msg);
continue;
}
Verdict::Reject => {
final_verdict = Some(Verdict::Reject);
break;
}
v => {
final_verdict = Some(v);
break;
}
}
}
}
final_verdict.unwrap_or_else(|| {
self.default_policy
.get(&ctx.hook)
.copied()
.unwrap_or(Verdict::Accept)
})
}
/// Inserts a rule. The rule's `id` is overwritten with a fresh id from
/// `next_id`.
pub fn add(&mut self, mut rule: FilterRule) -> u32 {
rule.id = self.next_id;
self.next_id = self.next_id.saturating_add(1);
let id = rule.id;
self.rules.push(rule);
id
}
/// Removes the rule with the given id. Returns `true` if a rule was
/// removed.
pub fn remove(&mut self, id: u32) -> bool {
if let Some(idx) = self.rules.iter().position(|r| r.id == id) {
self.rules.remove(idx);
true
} else {
false
}
}
/// Renders the entire table as a human-readable text dump. Mirrors
/// `iptables -L -n -v` output format.
pub fn format(&self) -> String {
let mut out = String::new();
for &hook in &Hook::ALL {
let policy = self
.default_policy
.get(&hook)
.copied()
.unwrap_or(Verdict::Accept);
out.push_str(&alloc::format!(
"Chain {} (policy {})\n",
hook.name().to_uppercase(),
policy.name()
));
for rule in self.rules.iter().filter(|r| r.hook == hook) {
out.push_str(" ");
out.push_str(&format_rule(rule));
out.push('\n');
}
}
out
}
/// Compact per-chain summary for netcfg summary node.
/// Returns lines like:
/// input: pkts=12 bytes=5678 policy=ACCEPT
pub fn chain_summary(&self) -> String {
let mut out = String::new();
for &hook in &Hook::ALL {
let (pkts, bytes) = self.chain_counters.get(&hook).copied().unwrap_or((0, 0));
let policy = self.default_policy.get(&hook).copied().unwrap_or(Verdict::Accept);
out.push_str(&alloc::format!(
"{}: pkts={} bytes={} policy={}\n",
hook.name(),
pkts,
bytes,
policy.name()
));
}
out
}
pub fn set_default_policy(&mut self, hook: Hook, verdict: Verdict) {
self.default_policy.insert(hook, verdict);
}
/// Clear per-chain counters and per-rule match counts.
/// Rules themselves are preserved. Use this to restart metrics
/// without losing configuration.
pub fn reset_counters(&mut self) {
for counter in self.chain_counters.values_mut() {
*counter = (0, 0);
}
for rule in &mut self.rules {
rule.match_count = 0;
}
}
}
fn format_rule(rule: &FilterRule) -> String {
let mut out = alloc::format!("{} [{:>4}]", rule.verdict.name(), rule.id);
if let Some(iface) = &rule.in_dev {
out.push_str(&alloc::format!(" in={}", iface));
}
if let Some(iface) = &rule.out_dev {
out.push_str(&alloc::format!(" out={}", iface));
}
if let Some(src) = rule.src_addr {
out.push_str(&alloc::format!(" src={}/{}", src, rule.src_prefix_len));
}
if let Some(dst) = rule.dst_addr {
out.push_str(&alloc::format!(" dst={}/{}", dst, rule.dst_prefix_len));
}
if let Some(proto) = rule.protocol {
out.push_str(&alloc::format!(" proto={}", proto.0));
}
if let Some(sport) = rule.src_port {
out.push_str(&alloc::format!(" sport={}", sport));
}
if let Some(dport) = rule.dst_port {
out.push_str(&alloc::format!(" dport={}", dport));
}
out.push_str(&alloc::format!(" matches={}", rule.match_count));
out
}
/// Errors returned by the rule parser. Each variant includes enough
/// context to point the user at the offending token (the index into the
/// input line where parsing failed).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
/// Generic parse error with a human-readable message.
Msg(&'static str),
/// The input did not contain a recognizable chain name.
UnknownHook(String),
/// The action keyword was not ACCEPT or DROP.
UnknownVerdict(String),
/// An address failed to parse.
BadAddress(String),
/// A port number failed to parse.
BadPort(String),
/// A flag was recognized but its value was malformed.
BadFlagValue { flag: &'static str, value: String },
/// The protocol name was not tcp/udp/icmp/icmp6 or a number.
UnknownProtocol(String),
/// The interface name was missing or empty.
MissingInterface,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ParseError::Msg(m) => f.write_str(m),
ParseError::UnknownHook(s) => write!(f, "unknown chain: {}", s),
ParseError::UnknownVerdict(s) => write!(f, "unknown verdict: {}", s),
ParseError::BadAddress(s) => write!(f, "bad address: {}", s),
ParseError::BadPort(s) => write!(f, "bad port: {}", s),
ParseError::BadFlagValue { flag, value } => {
write!(f, "bad value for {}: {}", flag, value)
}
ParseError::UnknownProtocol(s) => write!(f, "unknown protocol: {}", s),
ParseError::MissingInterface => f.write_str("missing interface name"),
}
}
}
/// Parses one iptables-style textual rule into a [`FilterRule`]. The
/// grammar is documented at the top of this file.
pub fn parse_rule(line: &str) -> core::result::Result<FilterRule, ParseError> {
let mut tokens = line.split_whitespace();
let verdict_token = tokens.next().ok_or(ParseError::Msg("missing verdict"))?;
let verdict = match verdict_token.to_uppercase().as_str() {
"ACCEPT" => Verdict::Accept,
"DROP" => Verdict::Drop,
"LOG" => Verdict::Log,
"REJECT" => Verdict::Reject,
_ => return Err(ParseError::UnknownVerdict(verdict_token.to_string())),
};
let chain_token = tokens.next().ok_or(ParseError::Msg("missing chain"))?;
let hook = match chain_token.to_lowercase().as_str() {
"prerouting" => Hook::PreRouting,
"input" => Hook::InputLocal,
"forward" => Hook::Forward,
"output" => Hook::OutputLocal,
"postrouting" => Hook::PostRouting,
_ => return Err(ParseError::UnknownHook(chain_token.to_string())),
};
let mut rule = FilterRule {
id: 0,
hook,
src_addr: None,
src_prefix_len: 0,
dst_addr: None,
dst_prefix_len: 0,
protocol: None,
src_port: None,
dst_port: None,
in_dev: None,
out_dev: None,
state_match: None,
verdict,
match_count: 0,
};
while let Some(flag) = tokens.next() {
match flag {
"-i" => {
let name = tokens.next().ok_or(ParseError::MissingInterface)?;
rule.in_dev = Some(Rc::from(name));
}
"-o" => {
let name = tokens.next().ok_or(ParseError::MissingInterface)?;
rule.out_dev = Some(Rc::from(name));
}
"-s" => {
let value = tokens.next().ok_or(ParseError::Msg("missing -s value"))?;
let (addr, prefix) = parse_addr_with_prefix(value)?;
rule.src_addr = Some(addr);
rule.src_prefix_len = prefix;
}
"-d" => {
let value = tokens.next().ok_or(ParseError::Msg("missing -d value"))?;
let (addr, prefix) = parse_addr_with_prefix(value)?;
rule.dst_addr = Some(addr);
rule.dst_prefix_len = prefix;
}
"-p" => {
let value = tokens.next().ok_or(ParseError::Msg("missing -p value"))?;
let proto = parse_protocol(value)?;
rule.protocol = Some(proto);
}
"--sport" | "--source-port" => {
let value = tokens.next().ok_or(ParseError::Msg("missing --sport value"))?;
rule.src_port = Some(parse_port(value)?);
}
"--dport" | "--destination-port" => {
let value = tokens.next().ok_or(ParseError::Msg("missing --dport value"))?;
rule.dst_port = Some(parse_port(value)?);
}
"state" | "--ctstate" => {
let value = tokens.next().ok_or(ParseError::Msg("missing state value"))?;
// Accept comma-separated list (e.g. "ESTABLISHED,RELATED"),
// using the FIRST recognized state. The FilterRule struct
// supports a single state_match field.
let first = value.split(',').next().unwrap_or(value);
rule.state_match = Some(match first {
"new" | "NEW" => StateMatch::New,
"established" | "ESTABLISHED" => StateMatch::Established,
"related" | "RELATED" => StateMatch::Related,
"invalid" | "INVALID" => StateMatch::Invalid,
_ => return Err(ParseError::Msg("unknown state value")),
});
}
_ => return Err(ParseError::Msg("unknown flag")),
}
}
if rule.protocol.is_some() && (rule.src_port.is_some() || rule.dst_port.is_some()) {
if let Some(proto) = rule.protocol {
if proto != Protocol::TCP && proto != Protocol::UDP && proto != Protocol::ICMP6 {
return Err(ParseError::Msg("port specified for non-transport protocol"));
}
}
}
Ok(rule)
}
fn parse_addr_with_prefix(value: &str) -> core::result::Result<(IpAddress, u8), ParseError> {
let (addr_str, prefix_len) = match value.split_once('/') {
Some((a, p)) => (a, p.parse::<u8>().map_err(|_| ParseError::BadAddress(value.to_string()))?),
None => (value, 0),
};
if let Ok(v4) = Ipv4Address::from_str(addr_str) {
let prefix = if prefix_len == 0 { 32 } else { prefix_len };
Ok((IpAddress::Ipv4(v4), prefix))
} else if let Ok(v6) = Ipv6Address::from_str(addr_str) {
let prefix = if prefix_len == 0 { 128 } else { prefix_len };
Ok((IpAddress::Ipv6(v6), prefix))
} else {
Err(ParseError::BadAddress(value.to_string()))
}
}
fn parse_protocol(value: &str) -> core::result::Result<Protocol, ParseError> {
let lower = value.to_lowercase();
let num: Option<u8> = lower.parse().ok();
match (lower.as_str(), num) {
("tcp", _) => Ok(Protocol::TCP),
("udp", _) => Ok(Protocol::UDP),
("icmp", _) => Ok(Protocol::ICMP),
("icmp6", _) | ("icmpv6", _) => Ok(Protocol::ICMP6),
(_, Some(n)) => Ok(Protocol(n)),
_ => Err(ParseError::UnknownProtocol(value.to_string())),
}
}
fn parse_port(value: &str) -> core::result::Result<u16, ParseError> {
// Accept single port (e.g. "80") but reject port ranges (e.g.
// "1024:65535"). The FilterRule struct uses a single u16 field
// and does not support ranges. Silently accepting the first
// number would make --sport 1024:65535 match only port 1024,
// which is both wrong and misleading.
if value.contains(':') {
return Err(ParseError::Msg("port ranges not supported (use a single port number)"));
}
value
.parse::<u16>()
.map_err(|_| ParseError::BadPort(value.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use alloc::rc::Rc;
use smoltcp::wire::IpAddress;
use smoltcp::time::Instant;
fn make_ctx(hook: Hook, src: IpAddress, dst: IpAddress) -> PacketContext<'static> {
PacketContext {
hook,
in_dev: None,
out_dev: None,
src_addr: src,
dst_addr: dst,
protocol: 6,
src_port: Some(1234),
dst_port: Some(80),
packet: &[],
}
}
fn make_drop_rule(hook: Hook) -> FilterRule {
FilterRule {
id: 1,
hook,
src_addr: None,
src_prefix_len: 0,
dst_addr: None,
dst_prefix_len: 0,
protocol: None,
src_port: None,
dst_port: None,
in_dev: None,
out_dev: None,
state_match: None,
verdict: Verdict::Drop,
match_count: 0,
}
}
fn make_accept_rule(hook: Hook) -> FilterRule {
let mut r = make_drop_rule(hook);
r.id = 2;
r.verdict = Verdict::Accept;
r
}
#[test]
fn drop_rule_actually_drops() {
let mut t = FilterTable::new();
t.rules.push(make_drop_rule(Hook::InputLocal));
let ctx = make_ctx(Hook::InputLocal, IpAddress::v4(10, 0, 0, 1), IpAddress::v4(192, 168, 1, 1));
assert_eq!(t.evaluate(&ctx, Instant::from_millis(0)), Verdict::Drop,
"DROP rule must drop the packet (regression test for R33 bug fix)");
}
#[test]
fn accept_rule_actually_accepts() {
let mut t = FilterTable::new();
t.rules.push(make_accept_rule(Hook::InputLocal));
let ctx = make_ctx(Hook::InputLocal, IpAddress::v4(10, 0, 0, 1), IpAddress::v4(192, 168, 1, 1));
assert_eq!(t.evaluate(&ctx, Instant::from_millis(0)), Verdict::Accept,
"ACCEPT rule must accept the packet (regression test)");
}
#[test]
fn rule_matching_other_hook_does_not_apply() {
let mut t = FilterTable::new();
t.rules.push(make_drop_rule(Hook::OutputLocal));
let ctx = make_ctx(Hook::InputLocal, IpAddress::v4(10, 0, 0, 1), IpAddress::v4(192, 168, 1, 1));
assert_eq!(t.evaluate(&ctx, Instant::from_millis(0)), Verdict::Accept,
"Rule for OUTPUT must not affect INPUT chain — should fall through to default policy");
}
#[test]
fn default_policy_applied_when_no_rules() {
let mut t = FilterTable::new();
t.set_default_policy(Hook::InputLocal, Verdict::Drop);
let ctx = make_ctx(Hook::InputLocal, IpAddress::v4(10, 0, 0, 1), IpAddress::v4(192, 168, 1, 1));
assert_eq!(t.evaluate(&ctx, Instant::from_millis(0)), Verdict::Drop,
"Default policy applies when no matching rule");
}
#[test]
fn reset_counters_clears_metrics_keeps_rules() {
let mut t = FilterTable::new();
t.rules.push(make_drop_rule(Hook::InputLocal));
let ctx = make_ctx(Hook::InputLocal, IpAddress::v4(10, 0, 0, 1), IpAddress::v4(192, 168, 1, 1));
let _ = t.evaluate(&ctx, Instant::from_millis(0));
assert_eq!(t.rules[0].match_count, 1);
let (_p, _b) = t.chain_counters.get(&Hook::InputLocal).copied().unwrap_or((0, 0));
t.reset_counters();
assert_eq!(t.rules[0].match_count, 0,
"reset_counters must clear rule.match_count");
assert_eq!(t.chain_counters.get(&Hook::InputLocal).copied().unwrap_or((0, 0)), (0, 0),
"reset_counters must clear chain_counters");
assert_eq!(t.rules.len(), 1,
"reset_counters must preserve rules");
}
}