Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,11 +115,10 @@ The repository should be cloned under `/root` so the provided `setup-*.sh` scrip
### nullnet-client

- set environment variables (in `members/nullnet-client/.env`; set `CONTROL_SERVICE_ADDR` to the IP
of `nullnet-server`, `ETH_NAME` to the ethernet interface to monitor)
of `nullnet-server`). The uplink interface is auto-detected from the host's default route.
```
CONTROL_SERVICE_ADDR=192.168.1.100
CONTROL_SERVICE_PORT=50051
ETH_NAME=ens18
```

- service configuration must be stored at `members/nullnet-client/services.toml`. Each entry
Expand Down
132 changes: 124 additions & 8 deletions ebpf/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,16 +1,132 @@
#![no_std]
#![no_main]

use aya_ebpf::{bindings::TC_ACT_SHOT, macros::classifier, programs::TcContext};
use aya_ebpf::{
bindings::{TC_ACT_OK, TC_ACT_SHOT},
macros::{classifier, map},
maps::HashMap,
programs::TcContext,
};
use core::mem;
use network_types::{
eth::{EthHdr, EtherType},
ip::{IpProto, Ipv4Hdr},
tcp::TcpHdr,
udp::UdpHdr,
};

// Host-NIC default-deny firewall (strict nullnet-only mode). Attached to TC
// ingress + egress on the host's primary interface. Only nullnet traffic is
// allowed; everything else on that NIC is dropped:
// - ARP (required for next-hop resolution)
// - TCP to/from SERVER_IP:PORT (nullnet control plane / gRPC)
// - UDP 4789/9999 to/from a peer (nullnet data plane: VXLAN / forward)
// Peers are added/removed from PEERS by userspace as the control channel
// installs/tears down VXLAN/VLAN edges.

const VXLAN_PORT: u16 = 4789;
const FORWARD_PORT: u16 = 9999;

// Allowlist of peer underlay IPs (host-order `u32::from(Ipv4Addr)` keys, which
// is exactly what `u32::from_be_bytes(ipv4_header.src_addr)` yields here).
#[map]
static PEERS: HashMap<u32, u8> = HashMap::with_max_entries(4096, 0);

// Set from userspace at load time (see members/nullnet-client/src/ebpf).
// SERVER_IP is host-order (`u32::from(Ipv4Addr)`); CONTROL_PORT is host-order.
#[unsafe(no_mangle)]
static SERVER_IP: u32 = 0;
#[unsafe(no_mangle)]
static CONTROL_PORT: u16 = 0;

/// Unconditional drop classifier. Currently defined but not attached anywhere;
/// kept as the seed for a future "block traffic not associated with a nullnet
/// flow" feature that will hook this (gated by a policy) somewhere on the
/// host's network path. Trigger detection has moved to the userspace NFQUEUE
/// listener — see members/nullnet-client/src/nfqueue/.
#[classifier]
pub fn nullnet_drop(_ctx: TcContext) -> i32 {
TC_ACT_SHOT
pub fn nullnet_firewall(ctx: TcContext) -> i32 {
// A malformed / too-short frame can't be nullnet traffic → drop (strict).
match try_firewall(&ctx) {
Ok(ret) => ret,
Err(()) => TC_ACT_SHOT,
}
}

#[inline]
fn ptr_at<T>(ctx: &TcContext, offset: usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = mem::size_of::<T>();

if start + offset + len > end {
return Err(());
}

Ok((start + offset) as *const T)
}

#[inline]
fn try_firewall(ctx: &TcContext) -> Result<i32, ()> {
let eth_header: *const EthHdr = ptr_at(ctx, 0)?;
let ether_type = EtherType::try_from(unsafe { (*eth_header).ether_type }).map_err(|_| ())?;

match ether_type {
// ARP must pass: without next-hop resolution nothing flows, nullnet
// control/data plane included.
EtherType::Arp => Ok(TC_ACT_OK),
EtherType::Ipv4 => {
let ipv4_header: *const Ipv4Hdr = ptr_at(ctx, EthHdr::LEN)?;
let src = u32::from_be_bytes(unsafe { (*ipv4_header).src_addr });
let dst = u32::from_be_bytes(unsafe { (*ipv4_header).dst_addr });

match unsafe { (*ipv4_header).proto } {
IpProto::Tcp => {
let tcp_header: *const TcpHdr = ptr_at(ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
let src_port = u16::from_be_bytes(unsafe { (*tcp_header).source });
let dst_port = u16::from_be_bytes(unsafe { (*tcp_header).dest });
Ok(verdict_control_plane(src, dst, src_port, dst_port))
}
IpProto::Udp => {
let udp_header: *const UdpHdr = ptr_at(ctx, EthHdr::LEN + Ipv4Hdr::LEN)?;
let src_port = u16::from_be_bytes(unsafe { (*udp_header).src });
let dst_port = u16::from_be_bytes(unsafe { (*udp_header).dst });
Ok(verdict_data_plane(src, dst, src_port, dst_port))
}
_ => Ok(TC_ACT_SHOT),
}
}
_ => Ok(TC_ACT_SHOT),
}
}

// Control plane: TCP where the server endpoint is on the control port, in
// either direction (egress to it, or its return traffic on ingress).
#[inline]
fn verdict_control_plane(src: u32, dst: u32, src_port: u16, dst_port: u16) -> i32 {
let server = unsafe { core::ptr::read_volatile(&SERVER_IP) };
let ctrl_port = unsafe { core::ptr::read_volatile(&CONTROL_PORT) };
if (dst == server && dst_port == ctrl_port) || (src == server && src_port == ctrl_port) {
TC_ACT_OK
} else {
TC_ACT_SHOT
}
}

// Data plane: UDP on the VXLAN (4789) or forward (9999) port with a known peer
// as either endpoint. VXLAN's destination port is 4789 in both directions; the
// forward socket uses 9999 on both ends — checking src or dst covers both.
#[inline]
fn verdict_data_plane(src: u32, dst: u32, src_port: u16, dst_port: u16) -> i32 {
let on_data_port = dst_port == VXLAN_PORT
|| src_port == VXLAN_PORT
|| dst_port == FORWARD_PORT
|| src_port == FORWARD_PORT;
if on_data_port && (is_peer(src) || is_peer(dst)) {
TC_ACT_OK
} else {
TC_ACT_SHOT
}
}

#[inline]
fn is_peer(ip: u32) -> bool {
unsafe { PEERS.get(&ip) }.is_some()
}

#[panic_handler]
Expand Down
3 changes: 1 addition & 2 deletions members/nullnet-client/.env
Original file line number Diff line number Diff line change
@@ -1,3 +1,2 @@
CONTROL_SERVICE_ADDR=192.168.1.100
CONTROL_SERVICE_PORT=50051
ETH_NAME=ens18
CONTROL_SERVICE_PORT=50051
6 changes: 3 additions & 3 deletions members/nullnet-client/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,10 @@ license.workspace = true
authors.workspace = true

[dependencies]
nullnet-firewall = { git = "https://github.com/GyulyVGC/nullnet-firewall.git" }
tun-rs = { version = "2.8.1", features = ["async_tokio"] }
etherparse = "0.19.0"
clap = { version = "4.6.0", features = ["derive"] }
tokio = { workspace = true, features = ["net", "sync", "rt-multi-thread", "macros", "io-util", "time", "fs", "process"] }
notify.workspace = true
serde = { version = "1.0.228", default-features = false, features = ["derive", "alloc"] }
nullnet-liberror.workspace = true
nullnet-grpc-lib.workspace = true
Expand All @@ -23,4 +21,6 @@ futures = "0.3.32"
network-interface = "2.0.5"
gag.workspace = true
chrono.workspace = true
nfq = "0.2"
nfq = "0.2"
aya = "0.13"
libc = "0.2"
14 changes: 0 additions & 14 deletions members/nullnet-client/firewall/firewall.txt

This file was deleted.

3 changes: 0 additions & 3 deletions members/nullnet-client/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ pub struct Args {
/// Maximum Transmission Unit (bytes)
#[arg(long, default_value_t = 42500)]
pub mtu: u16,
/// Path of the file defining firewall rules (it should be inside a dedicated folder)
#[arg(long, default_value_t = String::from("firewall/firewall.txt"))]
pub firewall_path: String,
/// Number of asynchronous tasks to use (AKA coroutines)
#[arg(long, default_value_t = 2, value_parser=clap::value_parser!(u8).range(2..))]
pub num_tasks: u8,
Expand Down
18 changes: 18 additions & 0 deletions members/nullnet-client/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,24 @@ pub(crate) async fn find_ethernet_ip(rtnetlink_handle: &RtNetLinkHandle) -> Opti
netlink::find_ethernet_ip(&rtnetlink_handle.handle).await
}

/// Returns the name of the interface carrying `ip`, so the eBPF firewall can
/// attach to the same NIC the forward socket binds to.
pub(crate) fn find_ethernet_interface(ip: Ipv4Addr) -> Option<String> {
use network_interface::{NetworkInterface, NetworkInterfaceConfig};
use std::net::IpAddr;

NetworkInterface::show()
.ok()?
.into_iter()
.find_map(|iface| {
iface
.addr
.iter()
.any(|addr| matches!(addr.ip(), IpAddr::V4(v4) if v4 == ip))
.then_some(iface.name)
})
}

#[derive(Clone)]
pub(crate) struct RtNetLinkHandle {
handle: Handle,
Expand Down
Loading