From 4ac40cde4d06f11472b3ddf5ec44b15499fc8346 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Mon, 19 Oct 2026 18:55:46 +0200 Subject: [PATCH 1/9] feat: add policy-multiplexed VXLAN dataplane --- ebpf/src/main.rs | 1 + ebpf/src/policy.rs | 178 +++ .../src/commands/endpoint_namespace.rs | 4 + members/nullnet-client/src/commands/mod.rs | 59 +- .../src/commands/native_netlink.rs | 75 + .../src/commands/native_xfrm.rs | 125 +- members/nullnet-client/src/commands/policy.rs | 1236 +++++++++++++++++ members/nullnet-client/src/commands/vxlan.rs | 1 + .../src/commands/vxlan_environment.rs | 109 ++ members/nullnet-client/src/control_channel.rs | 108 +- members/nullnet-client/src/main.rs | 6 + .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 14 + .../src/proto/nullnet_grpc.rs | 24 +- shared/policy.rs | 38 + 14 files changed, 1907 insertions(+), 71 deletions(-) create mode 100644 ebpf/src/policy.rs create mode 100644 members/nullnet-client/src/commands/policy.rs create mode 100644 members/nullnet-client/src/commands/vxlan_environment.rs create mode 100644 shared/policy.rs diff --git a/ebpf/src/main.rs b/ebpf/src/main.rs index f5be322..729ee9f 100644 --- a/ebpf/src/main.rs +++ b/ebpf/src/main.rs @@ -3,6 +3,7 @@ mod device_events; mod endpoint_tc; +mod policy; use aya_ebpf::{ bindings::{TC_ACT_OK, TC_ACT_SHOT}, diff --git a/ebpf/src/policy.rs b/ebpf/src/policy.rs new file mode 100644 index 0000000..5497712 --- /dev/null +++ b/ebpf/src/policy.rs @@ -0,0 +1,178 @@ +//! Permission-gated IPv4 forwarding over shared policy transports. +use aya_ebpf::{ + EbpfContext, + bindings::{TC_ACT_SHOT, bpf_tunnel_key}, + helpers::{ + bpf_ktime_get_ns, bpf_redirect, bpf_skb_get_tunnel_key, bpf_skb_get_tunnel_opt, + bpf_skb_set_tunnel_key, bpf_skb_set_tunnel_opt, + }, + macros::{classifier, map}, + maps::{Array, HashMap, LruHashMap}, + programs::TcContext, +}; +use core::mem::{size_of, zeroed}; + +#[path = "../../shared/policy.rs"] +#[allow(dead_code)] +mod types; +use types::{APP_IP, FlowKey, OutKey, Rule}; + +#[map] +static POLICY_OUT: HashMap = HashMap::with_max_entries(65536, 0); +#[map] +static POLICY_IN: HashMap = HashMap::with_max_entries(65536, 0); +#[map] +static POLICY_DEADLINE: Array = Array::with_max_entries(1, 0); +#[map] +static POLICY_FLOWS: LruHashMap = LruHashMap::with_max_entries(262144, 0); + +fn alive() -> bool { + POLICY_DEADLINE + .get(0) + .is_some_and(|deadline| *deadline > unsafe { bpf_ktime_get_ns() }) +} + +fn header(ctx: &TcContext) -> Result<(usize, u8), i64> { + if u16::from_be(ctx.load::(12)?) != 0x0800 { + return Err(-1); + } + let version = ctx.load::(14)?; + let len = usize::from(version & 15) * 4; + if version >> 4 != 4 || len < 20 || u16::from_be(ctx.load::(20)?) & 0x3fff != 0 { + return Err(-1); + } + let proto = ctx.load::(23)?; + if proto != 6 && proto != 17 && proto != 1 { + return Err(-1); + } + Ok((len, proto)) +} + +fn translate( + ctx: &TcContext, + offset: usize, + target: u32, + len: usize, + proto: u8, +) -> Result<(), i64> { + let old = ctx.load::(offset)?; + let new = target.to_be(); + ctx.l3_csum_replace(24, old as u64, new as u64, 4)?; + let checksum = 14 + len + if proto == 6 { 16 } else { 6 }; + if proto == 6 || (proto == 17 && ctx.load::(checksum)? != 0) { + ctx.l4_csum_replace(checksum, old as u64, new as u64, 4 | 16 | 32)?; + } + ctx.store(offset, &new, 0) +} + +fn flow( + ctx: &TcContext, + rule: &Rule, + interface: u32, + len: usize, + proto: u8, + incoming: bool, +) -> Result<(), i64> { + if proto != 6 { + return Ok(()); + } + let source = ctx.load::(14 + len)?; + let dest = ctx.load::(16 + len)?; + let flags = ctx.load::(27 + len)?; + let key = FlowKey { + interface, + peer: rule.peer, + local_port: if incoming { dest } else { source }, + remote_port: if incoming { source } else { dest }, + }; + if flags & 0x02 != 0 { + POLICY_FLOWS.insert(key, rule.generation, 0)?; + } else if unsafe { POLICY_FLOWS.get(key) }.copied() != Some(rule.generation) { + return Err(-1); + } + Ok(()) +} + +#[classifier] +pub fn nullnet_policy_out(ctx: TcContext) -> i32 { + outgoing(&ctx).unwrap_or(TC_ACT_SHOT) +} + +fn outgoing(ctx: &TcContext) -> Result { + if !alive() { + return Err(-1); + } + let (len, proto) = header(ctx)?; + if u32::from_be(ctx.load::(26)?) != APP_IP { + return Err(-1); + } + let interface = unsafe { (*ctx.skb.skb).ifindex }; + let key = OutKey { + interface, + peer: u32::from_be(ctx.load::(30)?), + }; + let rule = unsafe { POLICY_OUT.get(key) }.copied().ok_or(-1)?; + if rule.enabled == 0 { + return Err(-1); + } + flow(ctx, &rule, interface, len, proto, false)?; + translate(ctx, 26, rule.local, len, proto)?; + + if rule.underlay_local == rule.underlay_remote { + translate(ctx, 30, APP_IP, len, proto)?; + ctx.store(0, &rule.mac, 0)?; + return Ok(unsafe { bpf_redirect(rule.output, 0) } as i32); + } + + let mut tunnel: bpf_tunnel_key = unsafe { zeroed() }; + tunnel.tunnel_id = (rule.generation & 0xff_ffff) as u32; + tunnel.tunnel_ttl = 64; + tunnel.__bindgen_anon_1.remote_ipv4 = rule.underlay_remote; + tunnel.__bindgen_anon_3.local_ipv4 = rule.underlay_local; + let mut option = (rule.generation >> 24) as u32 | (1 << 22); + let skb = ctx.as_ptr().cast(); + if unsafe { bpf_skb_set_tunnel_key(skb, &mut tunnel, size_of::() as u32, 2) } + != 0 + || unsafe { bpf_skb_set_tunnel_opt(skb, (&mut option as *mut u32).cast(), 4) } != 0 + { + return Err(-1); + } + ctx.set_mark(rule.mark); + Ok(unsafe { bpf_redirect(rule.transport, 0) } as i32) +} + +#[classifier] +pub fn nullnet_policy_in(ctx: TcContext) -> i32 { + incoming(&ctx).unwrap_or(TC_ACT_SHOT) +} + +fn incoming(ctx: &TcContext) -> Result { + if !alive() { + return Err(-1); + } + let (len, proto) = header(ctx)?; + let local = u32::from_be(ctx.load::(30)?); + let rule = unsafe { POLICY_IN.get(local) }.copied().ok_or(-1)?; + let mut tunnel: bpf_tunnel_key = unsafe { zeroed() }; + let mut option = 0u32; + let skb = ctx.as_ptr().cast(); + if unsafe { (*ctx.skb.skb).mark } != rule.mark + || u32::from_be(ctx.load::(26)?) != rule.peer + || unsafe { + bpf_skb_get_tunnel_key(skb, &mut tunnel, size_of::() as u32, 0) + } != 0 + || unsafe { bpf_skb_get_tunnel_opt(skb, (&mut option as *mut u32).cast(), 4) } != 4 + { + return Err(-1); + } + if unsafe { tunnel.__bindgen_anon_1.remote_ipv4 } != rule.underlay_remote + || (u64::from(option & 0xffff) << 24 | u64::from(tunnel.tunnel_id)) != rule.generation + { + return Err(-1); + } + flow(ctx, &rule, rule.output, len, proto, true)?; + translate(ctx, 30, APP_IP, len, proto)?; + ctx.store(0, &rule.mac, 0)?; + ctx.set_mark(0); + Ok(unsafe { bpf_redirect(rule.output, 0) } as i32) +} diff --git a/members/nullnet-client/src/commands/endpoint_namespace.rs b/members/nullnet-client/src/commands/endpoint_namespace.rs index afd729b..f3090fb 100644 --- a/members/nullnet-client/src/commands/endpoint_namespace.rs +++ b/members/nullnet-client/src/commands/endpoint_namespace.rs @@ -15,6 +15,10 @@ pub(crate) struct EndpointNamespace { } impl EndpointNamespace { + pub(crate) async fn root() -> io::Result> { + Self::open(File::open("/proc/self/ns/net")?, None).await + } + pub(crate) fn validate(&self) -> io::Result<()> { if let Some(process) = &self.process { let mut poll = libc::pollfd { diff --git a/members/nullnet-client/src/commands/mod.rs b/members/nullnet-client/src/commands/mod.rs index 88eed43..3d029a3 100644 --- a/members/nullnet-client/src/commands/mod.rs +++ b/members/nullnet-client/src/commands/mod.rs @@ -16,8 +16,10 @@ mod native_xfrm; mod netlink; pub(crate) mod nfqueue; mod ovs; +pub(crate) mod policy; pub(crate) mod vxlan; mod vxlan_cleanup; +pub(crate) mod vxlan_environment; mod vxlan_recovery; pub(crate) async fn setup_br0(rtnetlink_handle: &RtNetLinkHandle) { @@ -151,6 +153,9 @@ pub(crate) async fn cleanup_network( egress::init(); install_overlay_forwarding().handle_err(location!())?; let mss_error = install_mss_clamp(); + policy::recover(&rtnetlink_handle.handle) + .await + .handle_err(location!())?; vxlan_recovery::recover(&rtnetlink_handle.handle) .await .handle_err(location!())?; @@ -171,31 +176,27 @@ pub(crate) async fn cleanup_network( // Preserve routed overlay traffic without changing the host's FORWARD policy. fn install_overlay_forwarding() -> std::io::Result<()> { - for direction in ["--src-group", "--dst-group"] { - let rule = [ - "FORWARD", - "-m", - "devgroup", - direction, - "0x4e000000/0xffc00000", - "-j", - "ACCEPT", - ]; - let check = std::process::Command::new("iptables") - .args(["-w", "-C"]) - .args(rule) - .output()?; - if check.status.success() { - continue; - } - let installed = std::process::Command::new("iptables") - .args(["-w", "-I"]) - .args(rule) - .output()?; - if !installed.status.success() { - return Err(std::io::Error::other( - String::from_utf8_lossy(&installed.stderr).into_owned(), - )); + for group in ["0x4e000000/0xffc00000", "0x4e700000/0xffffffff"] { + for direction in ["--src-group", "--dst-group"] { + let rule = [ + "FORWARD", "-m", "devgroup", direction, group, "-j", "ACCEPT", + ]; + let check = std::process::Command::new("iptables") + .args(["-w", "-C"]) + .args(rule) + .output()?; + if check.status.success() { + continue; + } + let installed = std::process::Command::new("iptables") + .args(["-w", "-I"]) + .args(rule) + .output()?; + if !installed.status.success() { + return Err(std::io::Error::other( + String::from_utf8_lossy(&installed.stderr).into_owned(), + )); + } } } Ok(()) @@ -206,6 +207,8 @@ fn install_overlay_forwarding() -> std::io::Result<()> { /// the VXLAN maximum (see nullnet-server's `net_id_pool`). const XFRM_SPI_MIN: u32 = 1_000 + 101; const XFRM_SPI_MAX: u32 = 1_000 + 2_097_151; +const POLICY_SPI_MIN: u32 = 0x4e40_0000; +const POLICY_SPI_MAX: u32 = 0x4e7f_ffff; /// Delete IPsec state/policy pairs left behind by a previous run. /// @@ -302,9 +305,9 @@ fn parse_src_dst(line: &str) -> Option<(String, String)> { fn parse_our_spi(line: &str) -> Option { let raw = line.split_whitespace().skip_while(|t| *t != "spi").nth(1)?; let value = u32::from_str_radix(raw.strip_prefix("0x")?, 16).ok()?; - (XFRM_SPI_MIN..=XFRM_SPI_MAX) - .contains(&value) - .then(|| raw.to_string()) + ((XFRM_SPI_MIN..=XFRM_SPI_MAX).contains(&value) + || (POLICY_SPI_MIN..=POLICY_SPI_MAX).contains(&value)) + .then(|| raw.to_string()) } fn privileged_output(args: &[&str]) -> Option { diff --git a/members/nullnet-client/src/commands/native_netlink.rs b/members/nullnet-client/src/commands/native_netlink.rs index 773786b..0e76d60 100644 --- a/members/nullnet-client/src/commands/native_netlink.rs +++ b/members/nullnet-client/src/commands/native_netlink.rs @@ -96,6 +96,81 @@ impl NativeSocket { .collect()) } + pub(super) fn requests( + &mut self, + requests: &[(u16, u16, &[u8])], + ) -> io::Result>> { + let mut sequences = Vec::with_capacity(requests.len()); + let mut message = Vec::new(); + for (kind, flags, body) in requests { + self.sequence = self.sequence.wrapping_add(1); + sequences.push(self.sequence); + let len = 16 + body.len(); + message.extend_from_slice(&(len as u32).to_ne_bytes()); + message.extend_from_slice(&kind.to_ne_bytes()); + message.extend_from_slice(&flags.to_ne_bytes()); + message.extend_from_slice(&self.sequence.to_ne_bytes()); + message.extend_from_slice(&0u32.to_ne_bytes()); + message.extend_from_slice(body); + message.resize((message.len() + 3) & !3, 0); + } + if message.is_empty() { + return Ok(Vec::new()); + } + if self.socket.send(&message, 0)? != message.len() { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "short netlink batch write", + )); + } + let mut replies: Vec>> = std::iter::repeat_with(|| None) + .take(requests.len()) + .collect(); + let mut remaining = replies.len(); + while remaining != 0 { + let (data, sender) = self.socket.recv_from_full()?; + if sender.port_number() != 0 { + continue; + } + let mut offset = 0; + while offset < data.len() { + if data.len() - offset < 16 { + return Err(io::Error::other("truncated netlink header")); + } + let header = &data[offset..offset + 16]; + let len = u32::from_ne_bytes(header[..4].try_into().unwrap()) as usize; + if len < 16 || len > data.len() - offset { + return Err(io::Error::other("invalid netlink message length")); + } + let kind = u16::from_ne_bytes(header[4..6].try_into().unwrap()); + let sequence = u32::from_ne_bytes(header[8..12].try_into().unwrap()); + if let Some(index) = sequences.iter().position(|value| *value == sequence) + && replies[index].is_none() + && matches!(kind, 2 | 3) + { + let payload = &data[offset + 16..offset + len]; + let result = if kind == 3 { + Ok(()) + } else { + let code = payload + .get(..4) + .ok_or_else(|| io::Error::other("truncated netlink ACK"))?; + let error = i32::from_ne_bytes(code.try_into().unwrap()); + if error == 0 { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(-error)) + } + }; + replies[index] = Some(result); + remaining -= 1; + } + offset += (len + 3) & !3; + } + } + Ok(replies.into_iter().map(Option::unwrap).collect()) + } + pub(super) fn dump(&mut self, kind: u16, body: &[u8]) -> io::Result>> { self.exchange(kind, REQUEST | 0x300, body) } diff --git a/members/nullnet-client/src/commands/native_xfrm.rs b/members/nullnet-client/src/commands/native_xfrm.rs index 45793c3..6523803 100644 --- a/members/nullnet-client/src/commands/native_xfrm.rs +++ b/members/nullnet-client/src/commands/native_xfrm.rs @@ -9,6 +9,7 @@ use tokio::sync::Mutex as AsyncMutex; const MARK_PREFIX: u32 = 0x4e80_0000; const SPI_PREFIX: u32 = 0x4e00_0000; +const POLICY_INDEX_PREFIX: u32 = 0x4e00_0000; const REPLAY_WINDOW: u32 = 4096; type Peer = (Ipv4Addr, Ipv4Addr, u16); type Owners = Arc>>; @@ -18,6 +19,7 @@ static EDGES: LazyLock>> = LazyLock::new(|| Mutex::new( #[derive(Clone, Copy)] struct Edge { id: u32, + generation: u32, local: Ipv4Addr, remote: Ipv4Addr, port: u16, @@ -32,6 +34,20 @@ impl Edge { fn spi(self, direction: u32) -> u32 { SPI_PREFIX | (self.id << 1) | direction } + fn policy_index(self) -> u32 { + if self.generation == 0 { + 0 + } else { + POLICY_INDEX_PREFIX | (self.id << 3) | 1 + } + } + fn policy_priority(self) -> u32 { + if self.generation == 0 { + 0 + } else { + u32::MAX - self.generation + } + } fn owners(self) -> Owners { PEERS .lock() @@ -70,13 +86,7 @@ fn mark_attribute(mark: u32) -> Vec { attr(21, &[mark.to_ne_bytes(), u32::MAX.to_ne_bytes()].concat()) } -fn state( - socket: &mut NativeSocket, - edge: Edge, - direction: u32, - key: &[u8; 32], - delete: bool, -) -> io::Result<()> { +fn state_message(edge: Edge, direction: u32, key: &[u8; 32], delete: bool) -> (u16, u16, Vec) { let inbound = direction != edge.direction(); let (source, destination) = if inbound { (edge.remote, edge.local) @@ -92,7 +102,7 @@ fn state( if !inbound { body.extend(mark_attribute(edge.mark())); } - return missing_ok(socket.request(17, REQUEST, &body)); + return (17, REQUEST, body); } let mut body = vec![0; 224]; body[56..80].copy_from_slice(&identity(destination, spi)); @@ -122,8 +132,7 @@ fn state( } else { body.extend(mark_attribute(edge.mark())); } - socket.request(16, CREATE, &body)?; - Ok(()) + (16, CREATE, body) } fn policy_mark(edge: Edge, inbound: bool) -> Vec { @@ -137,7 +146,7 @@ fn policy_mark(edge: Edge, inbound: bool) -> Vec { } } -fn policy(socket: &mut NativeSocket, edge: Edge, inbound: bool, delete: bool) -> io::Result<()> { +fn policy_message(edge: Edge, inbound: bool, delete: bool) -> (u16, u16, Vec) { let (source, destination) = if inbound { (edge.remote, edge.local) } else { @@ -146,12 +155,17 @@ fn policy(socket: &mut NativeSocket, edge: Edge, inbound: bool, delete: bool) -> let direction = u8::from(!inbound); let mut body = selector(source, destination, edge.port); if delete { - body.extend([0, 0, 0, 0, direction, 0, 0, 0]); + body.extend(if inbound { 0 } else { edge.policy_index() }.to_ne_bytes()); + body.extend([direction, 0, 0, 0]); body.extend(policy_mark(edge, inbound)); - return missing_ok(socket.request(20, REQUEST, &body)); + return (20, REQUEST, body); } body.extend(lifetime()); body.resize(168, 0); + if !inbound { + body[152..156].copy_from_slice(&edge.policy_priority().to_ne_bytes()); + body[156..160].copy_from_slice(&edge.policy_index().to_ne_bytes()); + } body[160] = direction; let mut template = identity( destination, @@ -169,7 +183,36 @@ fn policy(socket: &mut NativeSocket, edge: Edge, inbound: bool, delete: bool) -> template.extend([255; 12]); body.extend(attr(5, &template)); body.extend(policy_mark(edge, inbound)); - socket.request(19, CREATE, &body)?; + (19, CREATE, body) +} + +fn policy(socket: &mut NativeSocket, edge: Edge, inbound: bool, delete: bool) -> io::Result<()> { + let (kind, flags, body) = policy_message(edge, inbound, delete); + let result = socket.request(kind, flags, &body); + if delete { + missing_ok(result) + } else { + result.map(drop) + } +} + +fn batch( + socket: &mut NativeSocket, + messages: &[(u16, u16, Vec)], + delete: bool, +) -> io::Result<()> { + let requests: Vec<_> = messages + .iter() + .map(|(kind, flags, body)| (*kind, *flags, body.as_slice())) + .collect(); + for result in socket.requests(&requests)? { + match result { + Ok(()) => {} + Err(error) + if delete && matches!(error.raw_os_error(), Some(libc::ENOENT | libc::ESRCH)) => {} + Err(error) => return Err(error), + } + } Ok(()) } @@ -179,6 +222,7 @@ pub(super) fn mark(id: u32) -> u32 { pub(super) async fn install_port( id: u32, + generation: u32, local: Ipv4Addr, remote: Ipv4Addr, key: [u8; 32], @@ -186,6 +230,7 @@ pub(super) async fn install_port( ) -> io::Result<()> { let edge = Edge { id, + generation, local, remote, port, @@ -208,9 +253,15 @@ pub(super) async fn install_port( drop(owners); blocking(move || { let mut socket = NativeSocket::new(libc::NETLINK_XFRM as _)?; - state(&mut socket, edge, edge.direction(), &key, false)?; - state(&mut socket, edge, 1 - edge.direction(), &key, false)?; - policy(&mut socket, edge, false, false) + batch( + &mut socket, + &[ + state_message(edge, edge.direction(), &key, false), + state_message(edge, 1 - edge.direction(), &key, false), + policy_message(edge, false, false), + ], + false, + ) }) .await } @@ -220,17 +271,25 @@ pub(super) async fn remove(id: u32) -> io::Result<()> { let Some(edge) = edge else { return Ok(()); }; - blocking(move || { + let mut first_error = blocking(move || { let mut socket = NativeSocket::new(libc::NETLINK_XFRM as _)?; - policy(&mut socket, edge, false, true)?; - state(&mut socket, edge, edge.direction(), &[0; 32], true)?; - state(&mut socket, edge, 1 - edge.direction(), &[0; 32], true) + Ok(batch( + &mut socket, + &[ + policy_message(edge, false, true), + state_message(edge, edge.direction(), &[0; 32], true), + state_message(edge, 1 - edge.direction(), &[0; 32], true), + ], + true, + ) + .err()) }) .await?; let owners = edge.owners(); let mut owners = owners.lock().await; - if owners.contains(&id) && owners.len() == 1 { - blocking(move || { + if owners.contains(&id) + && owners.len() == 1 + && let Err(error) = blocking(move || { policy( &mut NativeSocket::new(libc::NETLINK_XFRM as _)?, edge, @@ -238,11 +297,17 @@ pub(super) async fn remove(id: u32) -> io::Result<()> { true, ) }) - .await?; + .await + { + first_error.get_or_insert(error); + } + if let Some(error) = first_error { + Err(error) + } else { + owners.remove(&id); + EDGES.lock().unwrap().remove(&id); + Ok(()) } - owners.remove(&id); - EDGES.lock().unwrap().remove(&id); - Ok(()) } fn missing_ok(result: io::Result>) -> io::Result<()> { @@ -261,6 +326,7 @@ mod tests { fn both_hosts_derive_the_same_directional_identity() { let a = Edge { id: 101, + generation: 7, port: 4790, local: Ipv4Addr::new(192, 0, 2, 1), remote: Ipv4Addr::new(192, 0, 2, 2), @@ -273,11 +339,16 @@ mod tests { assert_eq!(a.spi(a.direction()), b.spi(1 - b.direction())); assert_ne!(a.spi(0), a.spi(1)); assert_eq!(a.mark(), b.mark()); + assert_eq!(a.policy_index() & 7, 1); + assert_eq!(a.policy_index() & 0xfe00_0000, POLICY_INDEX_PREFIX); + assert_eq!(a.policy_priority(), u32::MAX - 7); assert_eq!(selector(a.local, a.remote, a.port).len(), 56); assert_eq!( &selector(a.local, a.remote, a.port)[32..36], &[0x12, 0xb6, 0xff, 0xff] ); + assert_eq!(Edge { generation: 0, ..a }.policy_index(), 0); + assert_eq!(Edge { generation: 0, ..a }.policy_priority(), 0); } } diff --git a/members/nullnet-client/src/commands/policy.rs b/members/nullnet-client/src/commands/policy.rs new file mode 100644 index 0000000..a2678cf --- /dev/null +++ b/members/nullnet-client/src/commands/policy.rs @@ -0,0 +1,1236 @@ +//! Persistent attachments and permission groups for overflow IPv4 edges. +use super::netlink::{add_link_echo, get_link_by_name}; +use super::{RtNetLinkHandle, endpoint_namespace::EndpointNamespace, native_xfrm}; +use crate::nfqueue::BridgeIpCache; +use aya::{ + Ebpf, + maps::{Array, HashMap as BpfMap, MapData}, + programs::{ + SchedClassifier, TcAttachType, + tc::{self, NlOptions, TcAttachOptions}, + }, +}; +use futures::TryStreamExt; +use nullnet_grpc_lib::nullnet_grpc::{PolicyTransport, VxlanSetup}; +use rtnetlink::{ + Handle, LinkUnspec, LinkVeth, LinkVxlan, + packet_route::link::{InfoData, InfoVeth, LinkAttribute}, +}; +use std::{ + collections::HashMap, + net::Ipv4Addr, + os::{fd::AsRawFd, unix::fs::MetadataExt}, + sync::{ + Arc, LazyLock, Mutex, + atomic::{AtomicU32, Ordering}, + }, +}; +use tokio::sync::{Mutex as AsyncMutex, OnceCell, Semaphore}; + +#[path = "../../../../shared/policy.rs"] +#[allow(dead_code)] +pub(crate) mod types; +use types::{APP_IP, GATEWAY_IP, GROUP, MAX_GENERATION, OutKey, POLICY_PORT, Rule}; +unsafe impl aya::Pod for OutKey {} +unsafe impl aya::Pod for Rule {} + +const APP_NAME: &str = "nnp_app"; +const DELIVERY_NAME: &str = "nnp_delivery"; +const VXLAN_NAME: &str = "nnp_vx"; +static ENGINE: AsyncMutex>> = AsyncMutex::const_new(None); +static HEARTBEAT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); +static SERVER_EPOCH: AsyncMutex>> = AsyncMutex::const_new(None); +static ROOT_NAMESPACE: OnceCell> = OnceCell::const_new(); +static ROOT_DELIVERY: OnceCell> = OnceCell::const_new(); +static ATTACH_ID: AtomicU32 = AtomicU32::new(1); +static COMMAND_SLOTS: Semaphore = Semaphore::const_new(8); +type AttachmentKey = (u64, u64); +type AttachmentCell = Arc>>; +static ATTACHMENTS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +struct Maps { + outgoing: BpfMap, + incoming: BpfMap, + deadline: Array, +} +struct Engine { + bpf: Mutex, + maps: Mutex, + transport: OnceCell, + peers: AsyncMutex>>>, + edges: Mutex>, +} +struct Attachment { + namespace: Arc, + outer: u32, + mac: [u8; 6], +} +#[derive(Clone)] +struct PolicyEdge { + key: OutKey, + rule: Rule, + attachment: Arc, + local: bool, + transport_id: u32, +} +impl PolicyEdge { + fn output_ready(&self) -> bool { + !self.local || self.rule.output != 0 + } +} +fn error(e: impl std::fmt::Display) -> String { + e.to_string() +} +fn clock() -> u64 { + let mut now = libc::timespec { + tv_sec: 0, + tv_nsec: 0, + }; + unsafe { + libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now); + } + now.tv_sec as u64 * 1_000_000_000 + now.tv_nsec as u64 +} + +async fn engine() -> Result, String> { + let mut current = ENGINE.lock().await; + if let Some(engine) = &*current { + return Ok(engine.clone()); + } + let value = tokio::task::spawn_blocking(|| { + let mut bpf = + Ebpf::load(aya::include_bytes_aligned!(env!("NULLNET_BIN_PATH"))).map_err(error)?; + for name in ["nullnet_policy_out", "nullnet_policy_in"] { + let program: &mut SchedClassifier = bpf + .program_mut(name) + .ok_or("Policy program missing")? + .try_into() + .map_err(error)?; + program.load().map_err(error)?; + } + let mut deadline = Array::try_from( + bpf.take_map("POLICY_DEADLINE") + .ok_or("Policy deadline map missing")?, + ) + .map_err(error)?; + deadline + .set(0, HEARTBEAT.load(Ordering::Acquire), 0) + .map_err(error)?; + let maps = Maps { + outgoing: BpfMap::try_from( + bpf.take_map("POLICY_OUT") + .ok_or("Policy sender map missing")?, + ) + .map_err(error)?, + incoming: BpfMap::try_from( + bpf.take_map("POLICY_IN") + .ok_or("Policy receiver map missing")?, + ) + .map_err(error)?, + deadline, + }; + Ok::<_, String>(Arc::new(Engine { + bpf: Mutex::new(bpf), + maps: Mutex::new(maps), + transport: OnceCell::new(), + peers: AsyncMutex::new(HashMap::new()), + edges: Mutex::new(HashMap::new()), + })) + }) + .await + .map_err(error)??; + *current = Some(value.clone()); + Ok(value) +} + +impl Engine { + fn attach(&self, name: &str, program: &str) -> Result<(), String> { + tc::qdisc_add_clsact(name).map_err(error)?; + let mut bpf = self.bpf.lock().unwrap(); + let program: &mut SchedClassifier = bpf + .program_mut(program) + .unwrap() + .try_into() + .map_err(error)?; + // A classic TC filter survives SIGKILL; its deadline still fails closed. + program + .attach_with_options( + name, + TcAttachType::Ingress, + TcAttachOptions::Netlink(NlOptions::default()), + ) + .map_err(error)?; + Ok(()) + } + async fn transport(&self, handle: &Handle) -> Result { + self.transport + .get_or_try_init(|| async { + let link = LinkVxlan::new(VXLAN_NAME, 0) + .collect_metadata(true) + .gbp() + .learning(false) + .port(POLICY_PORT) + .mtu(1080) + .link_group(GROUP) + .build(); + handle.link().add(link).execute().await.map_err(error)?; + let index = get_link_by_name(handle, VXLAN_NAME) + .await + .map_err(|e| e.to_str().to_owned())? + .header + .index; + let setup = async { + self.attach(VXLAN_NAME, "nullnet_policy_in")?; + handle + .link() + .set(LinkUnspec::new_with_index(index).up().build()) + .execute() + .await + .map_err(error)?; + get_link_by_name(handle, VXLAN_NAME) + .await + .map_err(|e| e.to_str().to_owned())?; + Ok(index) + } + .await; + if setup.is_err() { + handle.link().del(index).execute().await.map_err(error)?; + } + setup + }) + .await + .copied() + } + async fn peer( + &self, + transport: &PolicyTransport, + local: Ipv4Addr, + remote: Ipv4Addr, + ) -> Result<(), String> { + if !(0x20_0000..0x40_0000).contains(&transport.id) { + return Err("Invalid policy transport identity".into()); + } + let key: [u8; 32] = transport + .encryption_key + .as_slice() + .try_into() + .map_err(error)?; + let cell = self + .peers + .lock() + .await + .entry(transport.id) + .or_default() + .clone(); + cell.get_or_try_init(|| async { + let result = native_xfrm::install_port( + transport.id, + transport.generation as u32, + local, + remote, + key, + POLICY_PORT, + ) + .await; + if result.is_err() { + native_xfrm::remove(transport.id).await.map_err(error)?; + } + result.map_err(error) + }) + .await?; + Ok(()) + } +} + +async fn namespace_program( + namespace: &EndpointNamespace, + program: &str, + args: &[&str], +) -> Result<(), String> { + let path = format!( + "/proc/{}/fd/{}", + std::process::id(), + namespace.file.as_raw_fd() + ); + let _permit = COMMAND_SLOTS.acquire().await.map_err(error)?; + let output = tokio::process::Command::new("nsenter") + .arg(format!("--net={path}")) + .arg(program) + .args(args) + .kill_on_drop(true) + .output() + .await + .map_err(error)?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + Ok(()) +} + +async fn namespace_command(namespace: &EndpointNamespace, args: &[&str]) -> Result<(), String> { + namespace_program(namespace, "ip", args).await +} + +async fn attachment_pair( + handle: &Handle, + namespace: &EndpointNamespace, + outer_name: &str, + inner_name: &str, + serial: u32, +) -> Result<(u32, u32, [u8; 6]), String> { + let peer = LinkUnspec::new_with_name(inner_name) + .address(super::vxlan::veth_mac(serial, 0x70)) + .mtu(1080) + .link_group(GROUP) + .setns_by_fd(namespace.file.as_raw_fd()) + .build(); + let outer = add_link_echo( + handle, + LinkVeth::new(outer_name, inner_name) + .address(super::vxlan::veth_mac(serial, 0x71)) + .mtu(1080) + .link_group(GROUP) + .set_info_data(InfoData::Veth(InfoVeth::Peer(peer))) + .build(), + ) + .await + .map_err(error)? + .header + .index; + let result = async { + let inner = get_link_by_name(&namespace.handle, inner_name) + .await + .map_err(|e| e.to_str().to_owned())?; + let mac = inner + .attributes + .iter() + .find_map(|a| match a { + LinkAttribute::Address(mac) => <[u8; 6]>::try_from(mac.as_slice()).ok(), + _ => None, + }) + .ok_or("Application MAC missing")?; + Ok::<_, String>((inner.header.index, mac)) + } + .await; + match result { + Ok((inner, mac)) => Ok((outer, inner, mac)), + Err(value) => { + handle.link().del(outer).execute().await.map_err(error)?; + Err(value) + } + } +} + +async fn attachment( + handle: &Handle, + cache: &BridgeIpCache, + container: Option<&str>, + engine: &Engine, +) -> Result, String> { + let namespace = match container { + Some(name) => cache.namespace(name).await.map_err(error)?, + None => ROOT_NAMESPACE + .get_or_try_init(EndpointNamespace::root) + .await + .map_err(error)? + .clone(), + }; + namespace.validate().map_err(error)?; + let metadata = namespace.file.metadata().map_err(error)?; + let key = (metadata.dev(), metadata.ino()); + let cell = ATTACHMENTS.lock().unwrap().entry(key).or_default().clone(); + cell.get_or_try_init(|| async { + let serial = ATTACH_ID.fetch_add(1, Ordering::Relaxed); + let name = format!("nnp_a{serial}"); + let (outer, inner, mac) = + attachment_pair(handle, &namespace, &name, APP_NAME, serial).await?; + let setup = async { + engine.attach(&name, "nullnet_policy_out")?; + namespace_command( + &namespace, + &[ + "addr", + "add", + &format!("{}/30", Ipv4Addr::from(APP_IP)), + "dev", + APP_NAME, + ], + ) + .await?; + namespace + .handle + .link() + .set(LinkUnspec::new_with_index(inner).up().build()) + .execute() + .await + .map_err(error)?; + handle + .link() + .set(LinkUnspec::new_with_index(outer).up().build()) + .execute() + .await + .map_err(error)?; + namespace_command( + &namespace, + &[ + "neigh", + "add", + &Ipv4Addr::from(GATEWAY_IP).to_string(), + "lladdr", + "02:4e:70:00:00:01", + "nud", + "permanent", + "dev", + APP_NAME, + ], + ) + .await?; + namespace_command( + &namespace, + &[ + "route", + "add", + "10.0.0.0/8", + "via", + &Ipv4Addr::from(GATEWAY_IP).to_string(), + "dev", + APP_NAME, + "src", + &Ipv4Addr::from(APP_IP).to_string(), + ], + ) + .await?; + get_link_by_name(handle, &name) + .await + .map_err(|e| e.to_str().to_owned())?; + get_link_by_name(&namespace.handle, APP_NAME) + .await + .map_err(|e| e.to_str().to_owned())?; + Ok::<_, String>(Arc::new(Attachment { + namespace: namespace.clone(), + outer, + mac, + })) + } + .await; + if setup.is_err() { + handle.link().del(outer).execute().await.map_err(error)?; + } + setup + }) + .await + .cloned() +} + +async fn root_delivery( + handle: &Handle, + namespace: &Arc, +) -> Result, String> { + ROOT_DELIVERY + .get_or_try_init(|| async { + let serial = ATTACH_ID.fetch_add(1, Ordering::Relaxed); + let name = format!("nnp_d{serial}"); + let (outer, inner, mac) = + attachment_pair(handle, namespace, &name, DELIVERY_NAME, serial).await?; + let setup = async { + namespace_program( + namespace, + "sysctl", + &["-q", "-w", "net.ipv4.conf.nnp_delivery.rp_filter=0"], + ) + .await?; + namespace + .handle + .link() + .set(LinkUnspec::new_with_index(inner).up().build()) + .execute() + .await + .map_err(error)?; + handle + .link() + .set(LinkUnspec::new_with_index(outer).up().build()) + .execute() + .await + .map_err(error)?; + get_link_by_name(handle, &name) + .await + .map_err(|e| e.to_str().to_owned())?; + get_link_by_name(&namespace.handle, DELIVERY_NAME) + .await + .map_err(|e| e.to_str().to_owned())?; + Ok::<_, String>(Arc::new(Attachment { + namespace: namespace.clone(), + outer, + mac, + })) + } + .await; + if setup.is_err() { + handle.link().del(outer).execute().await.map_err(error)?; + } + setup + }) + .await + .cloned() +} + +pub(crate) async fn setup( + message: &VxlanSetup, + rtnetlink: &RtNetLinkHandle, + cache: &BridgeIpCache, +) -> Result<(), String> { + if !super::vxlan_environment::ready() { + return Err("Host plaintext protection is not ready".into()); + } + let transport = message + .policy_transport + .as_ref() + .ok_or("Missing policy transport")?; + if transport.generation == 0 || transport.generation > MAX_GENERATION { + return Err("Policy generation exhausted".into()); + } + let local: Ipv4Addr = message.local_ip.parse().map_err(error)?; + let remote: Ipv4Addr = message.remote_ip.parse().map_err(error)?; + if !message.encrypted + || message.egress_steer.unwrap_or(false) + || message.egress_intercept.unwrap_or(false) + || message.dnat_port.is_some() + { + return Err("Unsupported policy placement".into()); + } + let engine = engine().await?; + if engine.edges.lock().unwrap().contains_key(&message.ns_name) { + return Err("Policy endpoint is already active".into()); + } + let output = attachment( + &rtnetlink.handle, + cache, + message.docker_container.as_deref(), + &engine, + ) + .await?; + output.namespace.validate().map_err(error)?; + let local_transport = local == remote; + let vxlan = if local_transport { + 0 + } else { + let vxlan = engine.transport(&rtnetlink.handle).await?; + engine.peer(transport, local, remote).await?; + vxlan + }; + let block = u32::from(Ipv4Addr::new(10, 0, 0, 0)) + message.vxlan_id * 8; + let client = message.ns_name.ends_with("_c"); + let local_ip = + block + if client { 3 } else { 1 } + u32::from(message.docker_container.is_none()); + let peer_ip = block + if client { 1 } else { 3 } + u32::from(!transport.peer_container); + let key = OutKey { + interface: output.outer, + peer: peer_ip, + }; + let rule = Rule { + generation: transport.generation, + local: local_ip, + peer: peer_ip, + underlay_local: local.into(), + underlay_remote: remote.into(), + transport: vxlan, + mark: 0x4e80_0000 | transport.id, + output: if local_transport { 0 } else { output.outer }, + mac: if local_transport { [0; 6] } else { output.mac }, + ..Default::default() + }; + let delivery = + if local_transport && message.docker_container.is_none() && !transport.peer_container { + Some(root_delivery(&rtnetlink.handle, &output.namespace).await?) + } else { + None + }; + let map_result = { + let mut edges = engine.edges.lock().unwrap(); + let mut maps = engine.maps.lock().unwrap(); + (|| -> Result<(), String> { + if local_transport { + let peer_name = if client { + format!("ns_{}_s", message.vxlan_id) + } else { + format!("ns_{}_c", message.vxlan_id) + }; + if let Some(peer) = edges.get_mut(&peer_name) { + let target = delivery.as_ref().unwrap_or(&output); + peer.rule.output = target.outer; + peer.rule.mac = target.mac; + maps.outgoing + .insert(peer.key, peer.rule, 0) + .map_err(error)?; + let mut local_rule = rule; + let target = delivery.as_ref().unwrap_or(&peer.attachment); + local_rule.output = target.outer; + local_rule.mac = target.mac; + maps.outgoing.insert(key, local_rule, 0).map_err(error)?; + edges.insert( + message.ns_name.clone(), + PolicyEdge { + key, + rule: local_rule, + attachment: output.clone(), + local: true, + transport_id: transport.id, + }, + ); + } else { + maps.outgoing.insert(key, rule, 0).map_err(error)?; + edges.insert( + message.ns_name.clone(), + PolicyEdge { + key, + rule, + attachment: output.clone(), + local: true, + transport_id: transport.id, + }, + ); + } + } else { + maps.outgoing.insert(key, rule, 0).map_err(error)?; + if let Err(map_error) = maps.incoming.insert(local_ip, rule, 0) { + let _ = maps.outgoing.remove(&key); + return Err(error(map_error)); + } + edges.insert( + message.ns_name.clone(), + PolicyEdge { + key, + rule, + attachment: output.clone(), + local: false, + transport_id: transport.id, + }, + ); + } + Ok(()) + })() + }; + if let Err(map_error) = map_result { + if !local_transport { + engine.peers.lock().await.remove(&transport.id); + native_xfrm::remove(transport.id).await.map_err(error)?; + } + return Err(map_error); + } + if let Some(container) = &message.docker_container { + cache.add_overlay(&message.ns_name, Ipv4Addr::from(local_ip), container); + } + Ok(()) +} + +pub(crate) fn heartbeat_valid() -> Option { + let deadline = HEARTBEAT.load(Ordering::Acquire); + (deadline != 0).then(|| deadline > clock()) +} + +pub(crate) async fn heartbeat(server_epoch: &[u8]) -> Result<(), String> { + if server_epoch.len() != 16 { + return Err("Invalid policy server epoch".into()); + } + let changed = { + let current = SERVER_EPOCH.lock().await; + current + .as_deref() + .is_some_and(|value| value != server_epoch) + }; + if changed { + disable().await?; + let names: Vec = ENGINE + .lock() + .await + .as_ref() + .map(|engine| engine.edges.lock().unwrap().keys().cloned().collect()) + .unwrap_or_default(); + let mut first_error = None; + for name in names { + if let Err(error) = retire(&name).await { + first_error.get_or_insert(error); + } + } + if let Some(error) = first_error { + return Err(error); + } + } + *SERVER_EPOCH.lock().await = Some(server_epoch.to_vec()); + let deadline = clock() + 3_000_000_000; + HEARTBEAT.store(deadline, Ordering::Release); + if let Some(engine) = &*ENGINE.lock().await { + engine + .maps + .lock() + .unwrap() + .deadline + .set(0, deadline, 0) + .map_err(error)?; + } + Ok(()) +} +pub(crate) async fn disable() -> Result<(), String> { + HEARTBEAT.store(0, Ordering::Release); + if let Some(engine) = &*ENGINE.lock().await { + engine + .maps + .lock() + .unwrap() + .deadline + .set(0, 0, 0) + .map_err(error)?; + } + Ok(()) +} +pub(crate) async fn ready(id: u32) -> Result { + let engine = ENGINE.lock().await.clone(); + let Some(engine) = engine else { + return Ok(false); + }; + let mut found = false; + let mut edges = engine.edges.lock().unwrap(); + for (name, edge) in edges.iter_mut() { + if name == &format!("ns_{id}_s") || name == &format!("ns_{id}_c") { + if edge.output_ready() { + edge.rule.enabled = 1; + } + engine + .maps + .lock() + .unwrap() + .outgoing + .insert(edge.key, edge.rule, 0) + .map_err(error)?; + found = true; + } + } + Ok(found) +} + +pub(crate) async fn retire(name: &str) -> Result { + let engine = ENGINE.lock().await.clone(); + let Some(engine) = engine else { + return Ok(false); + }; + let (local, transport_id, removed, map_error) = { + let mut edges = engine.edges.lock().unwrap(); + let Some(edge) = edges.get(name) else { + return Ok(false); + }; + let local = edge.local; + let transport_id = edge.transport_id; + let id = name + .strip_prefix("ns_") + .and_then(|value| { + value + .strip_suffix("_s") + .or_else(|| value.strip_suffix("_c")) + }) + .and_then(|value| value.parse::().ok()); + let names: Vec = if local { + let Some(id) = id else { + return Err("Malformed local policy edge name".into()); + }; + vec![format!("ns_{id}_s"), format!("ns_{id}_c")] + } else { + vec![name.to_string()] + }; + let mut maps = engine.maps.lock().unwrap(); + let mut map_error = None; + let mut removed = Vec::new(); + for name in names { + let Some(edge) = edges.remove(&name) else { + continue; + }; + let mut results = vec![maps.outgoing.remove(&edge.key)]; + if !edge.local { + results.push(maps.incoming.remove(&edge.rule.local)); + } + for result in results { + if let Err(error) = result + && !matches!(&error, aya::maps::MapError::SyscallError(e) if e.io_error.raw_os_error() == Some(libc::ENOENT)) + { + map_error.get_or_insert_with(|| error.to_string()); + } + } + removed.push((name, edge)); + } + (local, transport_id, removed, map_error) + }; + let mut xfrm_error = None; + if !local { + engine.peers.lock().await.remove(&transport_id); + if let Err(value) = native_xfrm::remove(transport_id).await { + xfrm_error = Some(error(value)); + } + } + let result = match (map_error, xfrm_error) { + (Some(map), Some(xfrm)) => Err(format!("{map}; {xfrm}")), + (Some(value), None) | (None, Some(value)) => Err(value), + (None, None) => Ok(true), + }; + if result.is_err() { + let mut edges = engine.edges.lock().unwrap(); + for (name, edge) in removed { + edges.entry(name).or_insert(edge); + } + } + result +} + +pub(crate) async fn recover(handle: &Handle) -> Result<(), String> { + let links: Vec<_> = handle + .link() + .get() + .execute() + .try_collect() + .await + .map_err(error)?; + for link in links { + if !link + .attributes + .iter() + .any(|a| matches!(a, LinkAttribute::Group(group) if *group == GROUP)) + { + continue; + } + match handle.link().del(link.header.index).execute().await { + Ok(()) => (), + Err(rtnetlink::Error::NetlinkError(e)) + if e.code.map(std::num::NonZeroI32::get) == Some(-libc::ENODEV) => {} + Err(e) => return Err(e.to_string()), + } + } + Ok(()) +} +#[cfg(test)] +pub(crate) async fn destroy_all(rtnetlink: &RtNetLinkHandle) -> Result<(), String> { + disable().await?; + recover(&rtnetlink.handle).await?; + let mut current = ENGINE.lock().await; + if let Some(engine) = current.as_ref() { + let peers: Vec<_> = engine.peers.lock().await.keys().copied().collect(); + for id in peers { + native_xfrm::remove(id).await.map_err(error)?; + } + } + *current = None; + ATTACHMENTS.lock().unwrap().clear(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn same_host_message( + net_id: u32, + side: &str, + container: &str, + transport_id: u32, + generation: u64, + ) -> VxlanSetup { + VxlanSetup { + vxlan_id: net_id, + ns_name: format!("ns_{net_id}_{side}"), + ns_net: "10.0.0.1/29".into(), + br_name: format!("br_{net_id}_{side}"), + br_net: "10.0.0.2/29".into(), + local_ip: "192.0.2.1".into(), + remote_ip: "192.0.2.1".into(), + docker_container: Some(container.into()), + encryption_key: vec![7; 32], + dstport: u32::from(POLICY_PORT), + encrypted: true, + policy_transport: Some(PolicyTransport { + id: transport_id, + encryption_key: vec![7; 32], + peer_container: true, + generation, + }), + ..Default::default() + } + } + + fn server_ip(net_id: u32) -> Ipv4Addr { + Ipv4Addr::from(u32::from(Ipv4Addr::new(10, 0, 0, 0)) + net_id * 8 + 1) + } + + async fn ping(namespace: &str, destination: Ipv4Addr) -> bool { + tokio::process::Command::new("ip") + .args([ + "netns", + "exec", + namespace, + "ping", + "-c", + "1", + "-W", + "1", + &destination.to_string(), + ]) + .status() + .await + .is_ok_and(|status| status.success()) + } + + fn python(namespace: Option<&str>, script: &str, args: &[String]) -> tokio::process::Command { + let mut command = if let Some(namespace) = namespace { + let mut command = tokio::process::Command::new("ip"); + command.args(["netns", "exec", namespace, "python3"]); + command + } else { + tokio::process::Command::new("python3") + }; + command.arg("-c").arg(script).args(args); + command + } + + async fn tcp_listener(namespace: Option<&str>, port: u16) -> tokio::process::Child { + let script = "import socket,sys; s=socket.socket(); s.setsockopt(socket.SOL_SOCKET,socket.SO_REUSEADDR,1); s.bind(('0.0.0.0',int(sys.argv[1]))); s.listen(1); s.settimeout(10); c,_=s.accept(); d=c.recv(64); c.sendall(d); c.close(); s.close()"; + let mut child = python(namespace, script, &[port.to_string()]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()) + .spawn() + .unwrap(); + for _ in 0..100 { + let mut command = if let Some(namespace) = namespace { + let mut command = tokio::process::Command::new("ip"); + command.args(["netns", "exec", namespace, "ss"]); + command + } else { + tokio::process::Command::new("ss") + }; + let output = command + .args(["-H", "-ltn", "sport", "=", &format!(":{port}")]) + .output() + .await + .unwrap(); + if output.status.success() && !output.stdout.is_empty() { + return child; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + child.kill().await.unwrap(); + panic!("TCP listener did not start"); + } + + async fn tcp_echo(namespace: Option<&str>, destination: Ipv4Addr, port: u16) -> bool { + let script = "import socket,sys; s=socket.create_connection((sys.argv[1],int(sys.argv[2])),3); s.sendall(b'policy-tcp'); assert s.recv(64)==b'policy-tcp'; s.close()"; + python( + namespace, + script, + &[destination.to_string(), port.to_string()], + ) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await + .is_ok_and(|status| status.success()) + } + + async fn finish_tcp_listener(child: tokio::process::Child) { + let output = + tokio::time::timeout(std::time::Duration::from_secs(10), child.wait_with_output()) + .await + .expect("TCP listener timed out") + .unwrap(); + assert!( + output.status.success(), + "TCP listener failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn cross_host_message( + net_id: u32, + side: &str, + local: Ipv4Addr, + remote: Ipv4Addr, + key: u8, + ) -> VxlanSetup { + let mut message = same_host_message( + net_id, + side, + &format!("ns_{net_id}_{side}"), + 0x20_0000 + net_id, + (1 << 24) + u64::from(net_id), + ); + message.local_ip = local.to_string(); + message.remote_ip = remote.to_string(); + message.encryption_key = vec![key; 32]; + message.policy_transport.as_mut().unwrap().encryption_key = vec![key; 32]; + message + } + + async fn wait_for(path: &str) { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(45); + while !std::path::Path::new(path).exists() { + assert!( + tokio::time::Instant::now() < deadline, + "coordinator timeout" + ); + heartbeat(&[1; 16]).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + + #[tokio::test] + #[ignore = "requires coordinated execution on both lab hosts"] + async fn cross_host_groups_encrypt_isolate_and_retire_independently() { + let side = std::env::var("NN_POLICY_TEST_SIDE").unwrap(); + let local: Ipv4Addr = std::env::var("NN_POLICY_TEST_LOCAL") + .unwrap() + .parse() + .unwrap(); + let remote: Ipv4Addr = std::env::var("NN_POLICY_TEST_REMOTE") + .unwrap() + .parse() + .unwrap(); + super::super::vxlan_environment::initialize_test() + .await + .unwrap(); + heartbeat(&[1; 16]).await.unwrap(); + let handle = RtNetLinkHandle::new().unwrap(); + let cache = BridgeIpCache::new(); + let keys = if side == "s" { + [0x42, 0x43, 0x44] + } else { + [0x42, 0x43, 0x45] + }; + let mut messages = Vec::new(); + for (net_id, key) in [711, 712, 713].into_iter().zip(keys) { + let name = format!("ns_{net_id}_{side}"); + let namespace = super::super::endpoint_namespace::create(&name) + .await + .unwrap(); + cache.test_namespace(name, namespace); + let message = cross_host_message(net_id, &side, local, remote, key); + setup(&message, &handle, &cache).await.unwrap(); + messages.push(message); + } + assert_ne!( + messages[0] + .policy_transport + .as_ref() + .unwrap() + .encryption_key, + messages[1] + .policy_transport + .as_ref() + .unwrap() + .encryption_key + ); + std::fs::write("/tmp/nn-policy-cross-ready", "ok").unwrap(); + wait_for("/tmp/nn-policy-cross-go").await; + + for net_id in [711, 712, 713] { + assert!(ready(net_id).await.unwrap()); + } + let tcp = if side == "s" { + Some(tcp_listener(Some("ns_712_s"), 24712).await) + } else { + None + }; + std::fs::write("/tmp/nn-policy-cross-activated", "ok").unwrap(); + wait_for("/tmp/nn-policy-cross-traffic-go").await; + let peer = |net_id: u32| { + let offset = if side == "c" { 1u32 } else { 3u32 }; + Ipv4Addr::from(u32::from(Ipv4Addr::new(10, 0, 0, 0)) + net_id * 8 + offset) + }; + assert!(ping(&format!("ns_711_{side}"), peer(711)).await); + assert!(ping(&format!("ns_712_{side}"), peer(712)).await); + assert!(!ping(&format!("ns_711_{side}"), peer(712)).await); + assert!(!ping(&format!("ns_713_{side}"), peer(713)).await); + if side == "c" { + assert!(tcp_echo(Some("ns_712_c"), peer(712), 24712).await); + } else { + finish_tcp_listener(tcp.unwrap()).await; + } + + assert!(retire(&format!("ns_711_{side}")).await.unwrap()); + std::fs::write("/tmp/nn-policy-cross-retired", "ok").unwrap(); + wait_for("/tmp/nn-policy-cross-survivor-go").await; + heartbeat(&[1; 16]).await.unwrap(); + assert!(ping(&format!("ns_712_{side}"), peer(712)).await); + std::fs::write("/tmp/nn-policy-cross-survived", "ok").unwrap(); + wait_for("/tmp/nn-policy-cross-cleanup-go").await; + + for net_id in [712, 713] { + assert!(retire(&format!("ns_{net_id}_{side}")).await.unwrap()); + } + destroy_all(&handle).await.unwrap(); + for message in messages { + super::super::endpoint_namespace::remove(&message.ns_name) + .await + .unwrap(); + } + } + + #[tokio::test] + #[ignore = "requires root in an isolated network namespace"] + async fn same_host_groups_redirect_isolate_and_revoke() { + super::super::vxlan_environment::initialize_test() + .await + .unwrap(); + heartbeat(&[1; 16]).await.unwrap(); + let handle = RtNetLinkHandle::new().unwrap(); + let cache = BridgeIpCache::new(); + for name in ["nnpc1", "nnps1", "nnpc2", "nnps2"] { + let namespace = super::super::endpoint_namespace::create(name) + .await + .unwrap(); + cache.test_namespace(name.into(), namespace); + } + for message in [ + same_host_message(701, "c", "nnpc1", 0x20_02bd, 11), + same_host_message(701, "s", "nnps1", 0x20_02bd, 11), + same_host_message(702, "c", "nnpc2", 0x20_02be, 12), + same_host_message(702, "s", "nnps2", 0x20_02be, 12), + ] { + setup(&message, &handle, &cache).await.unwrap(); + } + ready(701).await.unwrap(); + ready(702).await.unwrap(); + + assert!(ping("nnpc1", server_ip(701)).await); + assert!(ping("nnpc2", server_ip(702)).await); + assert!(!ping("nnpc1", server_ip(702)).await); + let tcp = tcp_listener(Some("nnps2"), 24702).await; + assert!(tcp_echo(Some("nnpc2"), server_ip(702), 24702).await); + finish_tcp_listener(tcp).await; + + assert!(retire("ns_701_c").await.unwrap()); + assert!(!ping("nnpc1", server_ip(701)).await); + heartbeat(&[2; 16]).await.unwrap(); + assert!(!ping("nnpc2", server_ip(702)).await); + assert!(!retire("ns_702_s").await.unwrap()); + destroy_all(&handle).await.unwrap(); + for name in ["nnpc1", "nnps1", "nnpc2", "nnps2"] { + super::super::endpoint_namespace::remove(name) + .await + .unwrap(); + } + } + + #[tokio::test] + #[ignore = "requires root in an isolated network namespace"] + async fn same_host_root_attachment_redirects() { + super::super::vxlan_environment::initialize_test() + .await + .unwrap(); + heartbeat(&[1; 16]).await.unwrap(); + let handle = RtNetLinkHandle::new().unwrap(); + let cache = BridgeIpCache::new(); + let mut client = same_host_message(703, "c", "unused", 0x20_02bf, 13); + let mut server = same_host_message(703, "s", "unused", 0x20_02bf, 13); + client.docker_container = None; + server.docker_container = None; + client.policy_transport.as_mut().unwrap().peer_container = false; + server.policy_transport.as_mut().unwrap().peer_container = false; + setup(&client, &handle, &cache).await.unwrap(); + setup(&server, &handle, &cache).await.unwrap(); + assert!(ready(703).await.unwrap()); + let destination = Ipv4Addr::from(u32::from(server_ip(703)) + 1); + + assert!( + tokio::process::Command::new("ping") + .args(["-c", "1", "-W", "1", &destination.to_string()]) + .status() + .await + .unwrap() + .success() + ); + let tcp = tcp_listener(None, 24703).await; + assert!(tcp_echo(None, destination, 24703).await); + finish_tcp_listener(tcp).await; + + assert!(retire("ns_703_c").await.unwrap()); + assert!(!tcp_echo(None, destination, 24703).await); + destroy_all(&handle).await.unwrap(); + } + + #[tokio::test] + #[ignore = "requires root in an isolated network namespace"] + async fn kernel_programs_attachments_and_scoped_recovery() { + super::super::vxlan_environment::initialize_test() + .await + .unwrap(); + assert!( + tokio::process::Command::new("iptables") + .args(["-I", "OUTPUT", "2", "-j", "ACCEPT"]) + .status() + .await + .unwrap() + .success() + ); + super::super::vxlan_environment::initialize_test() + .await + .unwrap(); + let rules = tokio::process::Command::new("iptables") + .args(["-S", "OUTPUT"]) + .output() + .await + .unwrap(); + let rules = String::from_utf8(rules.stdout).unwrap(); + assert!(rules.lines().any(|line| { + line.contains("nullnet-policy-ciphertext") && line.contains("--dport 4791 ") + })); + let handle = RtNetLinkHandle::new().unwrap(); + handle + .handle + .link() + .add(rtnetlink::LinkDummy::new("keep_me").build()) + .execute() + .await + .unwrap(); + let engine = engine().await.unwrap(); + let transport = engine.transport(&handle.handle).await.unwrap(); + assert_ne!(transport, 0); + let cache = BridgeIpCache::new(); + let first = attachment(&handle.handle, &cache, None, &engine) + .await + .unwrap(); + let second = attachment(&handle.handle, &cache, None, &engine) + .await + .unwrap(); + assert!(Arc::ptr_eq(&first, &second)); + heartbeat(&[1; 16]).await.unwrap(); + assert!(engine.maps.lock().unwrap().deadline.get(&0, 0).unwrap() > clock()); + disable().await.unwrap(); + assert_eq!(engine.maps.lock().unwrap().deadline.get(&0, 0).unwrap(), 0); + let key = OutKey { + interface: first.outer, + peer: 0x0a00_0601, + }; + let rule = Rule { + local: 0x0a00_0604, + ..Default::default() + }; + engine.edges.lock().unwrap().insert( + "ns_192_c".into(), + PolicyEdge { + key, + rule, + attachment: first, + local: false, + transport_id: 0x20_0000, + }, + ); + engine + .maps + .lock() + .unwrap() + .incoming + .insert(rule.local, rule, 0) + .unwrap(); + assert!(retire("ns_192_c").await.unwrap()); + assert!(!retire("ns_192_c").await.unwrap()); + destroy_all(&handle).await.unwrap(); + assert!(get_link_by_name(&handle.handle, "keep_me").await.is_ok()); + assert!(get_link_by_name(&handle.handle, VXLAN_NAME).await.is_err()); + assert!(get_link_by_name(&handle.handle, APP_NAME).await.is_err()); + } +} diff --git a/members/nullnet-client/src/commands/vxlan.rs b/members/nullnet-client/src/commands/vxlan.rs index fe014ad..8e46be7 100644 --- a/members/nullnet-client/src/commands/vxlan.rs +++ b/members/nullnet-client/src/commands/vxlan.rs @@ -369,6 +369,7 @@ async fn activate(endpoint: Arc, params: &VxlanSetupParams) -> Result< } else { super::native_xfrm::install_port( params.vxlan_id, + 0, params.local_ip, params.remote_ip, key, diff --git a/members/nullnet-client/src/commands/vxlan_environment.rs b/members/nullnet-client/src/commands/vxlan_environment.rs new file mode 100644 index 0000000..18832ae --- /dev/null +++ b/members/nullnet-client/src/commands/vxlan_environment.rs @@ -0,0 +1,109 @@ +//! Reconcile the host-wide guard for the shared metadata VXLAN. +use nullnet_grpc_lib::{ + NullnetGrpcInterface, + nullnet_grpc::{AgentEvent, AgentVxlanEnvironmentFailed, agent_event::Event}, +}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; +use tokio::process::Command; + +static READY: AtomicBool = AtomicBool::new(false); + +pub(super) fn ready() -> bool { + READY.load(Ordering::Acquire) +} + +async fn iptables(args: &[&str]) -> Result { + Command::new("iptables") + .args(["-w", "2"]) + .args(args) + .kill_on_drop(true) + .output() + .await + .map_err(|error| error.to_string()) +} + +async fn checked(args: &[&str]) -> Result<(), String> { + let output = iptables(args).await?; + if output.status.success() { + Ok(()) + } else { + Err(String::from_utf8_lossy(&output.stderr).into_owned()) + } +} + +async fn reconcile() -> Result { + let mut changed = false; + for (chain, direction) in [("INPUT", "in"), ("OUTPUT", "out")] { + let rule = [ + "-p", + "udp", + "--dport", + "4791", + "-m", + "policy", + "--dir", + direction, + "--pol", + "none", + "-m", + "comment", + "--comment", + "nullnet-policy-ciphertext", + "-j", + "DROP", + ]; + let present = iptables(&[&["-C", chain][..], &rule].concat()) + .await? + .status + .success(); + if !present { + checked(&[&["-I", chain, "1"][..], &rule].concat()).await?; + changed = true; + } + } + Ok(changed) +} + +pub(crate) async fn start(grpc: NullnetGrpcInterface) -> Result<(), String> { + reconcile().await?; + READY.store(true, Ordering::Release); + tokio::spawn(async move { + let mut failed = false; + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + match reconcile().await { + Ok(changed) => { + READY.store(true, Ordering::Release); + if changed || failed { + println!("Shared VXLAN plaintext guards restored"); + } + failed = false; + } + Err(error) => { + READY.store(false, Ordering::Release); + if !failed { + let _ = grpc + .report_event(AgentEvent { + event: Some(Event::VxlanEnvironmentFailed( + AgentVxlanEnvironmentFailed { + error_message: error, + }, + )), + }) + .await; + } + failed = true; + } + } + } + }); + Ok(()) +} + +#[cfg(test)] +pub(super) async fn initialize_test() -> Result<(), String> { + reconcile().await?; + READY.store(true, Ordering::Release); + Ok(()) +} diff --git a/members/nullnet-client/src/control_channel.rs b/members/nullnet-client/src/control_channel.rs index 6ae8f6e..4c78f51 100644 --- a/members/nullnet-client/src/control_channel.rs +++ b/members/nullnet-client/src/control_channel.rs @@ -86,9 +86,43 @@ pub(crate) async fn control_channel( ); let lifecycle = lifecycle::LifecycleTasks::default(); - while let Ok(Some(message)) = inbound.message().await.inspect_err(|error| { - eprintln!("Control channel from server failed: {error:?}"); - }) { + let mut heartbeat_watch = tokio::time::interval(std::time::Duration::from_millis(500)); + let mut heartbeat_valid = None; + loop { + let message = tokio::select! { + message = inbound.message() => match message { + Ok(Some(message)) => message, + Ok(None) => break, + Err(error) => { + eprintln!("Control channel from server failed: {error:?}"); + break; + } + }, + _ = heartbeat_watch.tick() => { + let current = crate::commands::policy::heartbeat_valid(); + if heartbeat_valid.is_some() + && current != heartbeat_valid + && let Some(valid) = current + { + if valid { + println!("Policy control heartbeat restored"); + } else { + fire_event( + &server, + AgentEventKind::VxlanEnvironmentFailed( + nullnet_grpc_lib::nullnet_grpc::AgentVxlanEnvironmentFailed { + error_message: + "Policy transport blocked: control heartbeat expired" + .into(), + }, + ), + ); + } + } + heartbeat_valid = current; + continue; + } + }; let rtnetlink_handle = rtnetlink_handle.clone(); let peers = peers.clone(); let outbound = outbound.clone(); @@ -96,6 +130,11 @@ pub(crate) async fn control_channel( let server = server.clone(); let firewall_peers = firewall_peers.clone(); match message.message { + Some(net_message::Message::PolicyHeartbeat(heartbeat)) => { + crate::commands::policy::heartbeat(&heartbeat.server_epoch) + .await + .handle_err(location!())?; + } Some(net_message::Message::VlanSetup(vlan_setup)) => { tokio::spawn(async move { let _ = handle_vlan_setup( @@ -171,7 +210,15 @@ pub(crate) async fn control_channel( Some(net_message::Message::NetReady(ready)) => { let triggers_state = triggers_state.clone(); lifecycle.spawn(ready.net_id, async move { - if let Err(error) = crate::commands::vxlan::ready(ready.net_id).await { + let policy = match crate::commands::policy::ready(ready.net_id).await { + Ok(policy) => policy, + Err(error) => { + eprintln!("Policy readiness for {} failed: {error}", ready.net_id); + return; + } + }; + if !policy && let Err(error) = crate::commands::vxlan::ready(ready.net_id).await + { eprintln!("Endpoint readiness for {} failed: {error:?}", ready.net_id); fire_event( &server, @@ -206,7 +253,7 @@ pub(crate) async fn control_channel( if !published { return; } - if triggers_state.mark_ready(ready.net_id) { + if policy || triggers_state.mark_ready(ready.net_id) { ack_control(&outbound, ready.msg_id, &server, "net_ready").await; } else { fire_event( @@ -249,6 +296,10 @@ pub(crate) async fn control_channel( } } + crate::commands::policy::disable() + .await + .handle_err(location!())?; + fire_event( &server, AgentEventKind::ControlChannelClosed(AgentControlChannelClosed {}), @@ -446,6 +497,7 @@ async fn handle_vxlan_setup( sets: LivenessSets, bridge_cache: BridgeIpCache, ) -> Result<(), Error> { + let policy_message = message.policy_transport.is_some().then(|| message.clone()); let egress_steer = message.egress_steer.unwrap_or(false); let egress_intercept = message.egress_intercept.unwrap_or(false); let msg_id = &message @@ -501,7 +553,9 @@ async fn handle_vxlan_setup( // allow this peer's data-plane (VXLAN underlay) traffic through the host // firewall before the tunnel comes up, so the first packets aren't dropped. - firewall_peers.add(NetId::Vxlan(vxlan_id), remote_ip); + if local_ip != remote_ip { + firewall_peers.add(NetId::Vxlan(vxlan_id), remote_ip); + } // setup VXLAN on this machine (optionally attaching a Docker container) let init_t = std::time::Instant::now(); @@ -526,8 +580,13 @@ async fn handle_vxlan_setup( message.docker_container.clone() }, }; - let setup_result = - crate::commands::vxlan::setup(&rtnetlink_handle, &vxlan_params, &bridge_cache).await; + let setup_result = if let Some(policy_message) = &policy_message { + crate::commands::policy::setup(policy_message, &rtnetlink_handle, &bridge_cache) + .await + .handle_err(location!()) + } else { + crate::commands::vxlan::setup(&rtnetlink_handle, &vxlan_params, &bridge_cache).await + }; let error_code = if setup_result.is_err() { -1 } else { 0 }; if let Err(e) = &setup_result { fire_event( @@ -666,7 +725,9 @@ async fn handle_vxlan_setup( ); return Err("gateway forwarding installation failed").handle_err(location!()); } - } else if let Some(host_mapping) = &message.host_mapping { + } else if policy_message.is_none() + && let Some(host_mapping) = &message.host_mapping + { // Backend DNAT keeps the trigger address so each new flow remains // visible to root conntrack; direct overlay resolution bypasses it. if message.dnat_port.is_none() { @@ -789,9 +850,22 @@ async fn handle_vxlan_teardown( bridge_cache: BridgeIpCache, ) { let ack_id = message.msg_id.clone(); + let policy_cleanup_ok = if message.policy { + match crate::commands::policy::retire(&message.ns_name).await { + Ok(_) => true, + Err(error) => { + eprintln!("Policy revocation failed: {error}"); + false + } + } + } else { + true + }; // reverse egress steering/interception if this was an egress edge - let mut local_cleanup_ok = true; - if let Some(rec) = egress_state.take(message.vxlan_id) { + let mut local_cleanup_ok = policy_cleanup_ok; + if !message.policy + && let Some(rec) = egress_state.take(message.vxlan_id) + { match rec { EgressRecord::Local => { local_cleanup_ok = egress::remove_local_forwards(Some(message.vxlan_id)); @@ -820,8 +894,9 @@ async fn handle_vxlan_teardown( // DNAT — its teardown runs via EgressState below — so skip DNAT removal for // it. Only real backend DNAT ports (>0) go through `dnat::remove`; otherwise // we'd fire a bogus `iptables -D --dport 0` and a false removal-failed event. - if let Some((container, port, overlay_ip, container_ip)) = - triggers_state.remove_by_vxlan(message.vxlan_id) + if !message.policy + && let Some((container, port, overlay_ip, container_ip)) = + triggers_state.remove_by_vxlan(message.vxlan_id) && port != crate::triggers::EGRESS_TRIGGER_PORT { // Second of the two flushes per trigger-edge lifecycle. Without @@ -864,8 +939,11 @@ async fn handle_vxlan_teardown( vxlan_id, ns_name: ns_name.clone(), }; - let teardown_result = - crate::commands::vxlan::teardown(&rtnetlink_handle, &vxlan_params, &bridge_cache).await; + let teardown_result = if message.policy { + Ok(()) + } else { + crate::commands::vxlan::teardown(&rtnetlink_handle, &vxlan_params, &bridge_cache).await + }; if !local_cleanup_ok || teardown_result.is_err() { fire_event( &grpc, diff --git a/members/nullnet-client/src/main.rs b/members/nullnet-client/src/main.rs index 92c26d3..71df86f 100644 --- a/members/nullnet-client/src/main.rs +++ b/members/nullnet-client/src/main.rs @@ -139,6 +139,12 @@ async fn main() -> Result<(), Error> { setup_br0(&rtnetlink_handle).await; } + if net_type.net() == Net::Vxlan { + commands::vxlan_environment::start(grpc_server.clone()) + .await + .handle_err(location!())?; + } + print_info(net_type.net()); // bring up the host-NIC eBPF default-deny firewall. Must happen before the diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index 155a6df..aba480e 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -130,9 +130,21 @@ message NetMessage { // Egress country-policy changed on the server (fire-and-forget) EgressPolicyChanged egress_policy_changed = 7; NetReady net_ready = 8; + PolicyHeartbeat policy_heartbeat = 9; } } +message PolicyHeartbeat { + bytes server_epoch = 1; +} + +message PolicyTransport { + uint32 id = 1; + bytes encryption_key = 2; + bool peer_container = 3; + uint64 generation = 4; +} + // Both endpoints are prepared; the initiator may release held packets. message NetReady { MsgId msg_id = 1; @@ -226,6 +238,7 @@ message VxlanSetup { // `encryption_key` and sets up a bare vxlan/veth link with no XFRM // SA/policy or MACsec (see nullnet-client's commands/vxlan.rs). bool encrypted = 16; + PolicyTransport policy_transport = 17; } message VxlanTeardown { @@ -243,6 +256,7 @@ message VxlanTeardown { // predates this field simply never acks, and the server frees on its grace // timer instead. optional MsgId msg_id = 8; + bool policy = 9; } message MsgId { diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index a6871e6..2177b8c 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -21,7 +21,7 @@ pub struct NetType { } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct NetMessage { - #[prost(oneof = "net_message::Message", tags = "1, 2, 3, 4, 5, 6, 7, 8")] + #[prost(oneof = "net_message::Message", tags = "1, 2, 3, 4, 5, 6, 7, 8, 9")] pub message: ::core::option::Option, } /// Nested message and enum types in `NetMessage`. @@ -48,8 +48,26 @@ pub mod net_message { EgressPolicyChanged(super::EgressPolicyChanged), #[prost(message, tag = "8")] NetReady(super::NetReady), + #[prost(message, tag = "9")] + PolicyHeartbeat(super::PolicyHeartbeat), } } +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PolicyHeartbeat { + #[prost(bytes = "vec", tag = "1")] + pub server_epoch: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PolicyTransport { + #[prost(uint32, tag = "1")] + pub id: u32, + #[prost(bytes = "vec", tag = "2")] + pub encryption_key: ::prost::alloc::vec::Vec, + #[prost(bool, tag = "3")] + pub peer_container: bool, + #[prost(uint64, tag = "4")] + pub generation: u64, +} /// Both endpoints are prepared; the initiator may release held packets. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct NetReady { @@ -176,6 +194,8 @@ pub struct VxlanSetup { /// SA/policy or MACsec (see nullnet-client's commands/vxlan.rs). #[prost(bool, tag = "16")] pub encrypted: bool, + #[prost(message, optional, tag = "17")] + pub policy_transport: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct VxlanTeardown { @@ -201,6 +221,8 @@ pub struct VxlanTeardown { /// timer instead. #[prost(message, optional, tag = "8")] pub msg_id: ::core::option::Option, + #[prost(bool, tag = "9")] + pub policy: bool, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct MsgId { diff --git a/shared/policy.rs b/shared/policy.rs new file mode 100644 index 0000000..0056e55 --- /dev/null +++ b/shared/policy.rs @@ -0,0 +1,38 @@ +//! Wire-independent layouts for policy dataplane maps. +pub const APP_IP: u32 = 0xa9fe_f002; +pub const GATEWAY_IP: u32 = 0xa9fe_f001; +pub const POLICY_PORT: u16 = 4791; +pub const GROUP: u32 = 0x4e70_0000; +pub const MAX_GENERATION: u64 = (1 << 40) - 1; + +#[repr(C)] +#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] +pub struct OutKey { + pub interface: u32, + pub peer: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct Rule { + pub generation: u64, + pub local: u32, + pub peer: u32, + pub underlay_local: u32, + pub underlay_remote: u32, + pub transport: u32, + pub mark: u32, + pub output: u32, + pub enabled: u32, + pub mac: [u8; 6], + pub pad: [u8; 2], +} + +#[repr(C)] +#[derive(Clone, Copy, Default)] +pub struct FlowKey { + pub interface: u32, + pub peer: u32, + pub local_port: u16, + pub remote_port: u16, +} From e79da2454b790ae4470a5a362fad1933097cea0a Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Mon, 19 Oct 2026 18:55:59 +0200 Subject: [PATCH 2/9] feat: multiplex ingress overflow by client IP --- members/nullnet-server/src/net.rs | 14 +- .../nullnet-server/src/nullnet_grpc_impl.rs | 290 ++++++++++++++---- members/nullnet-server/src/orchestrator.rs | 110 ++++++- .../nullnet-server/src/services/changes.rs | 14 +- .../nullnet-server/src/services/clients.rs | 13 +- members/nullnet-server/src/services/edge.rs | 2 + .../src/services/service_info.rs | 11 +- members/nullnet-server/src/tests.rs | 132 ++++++-- 8 files changed, 493 insertions(+), 93 deletions(-) diff --git a/members/nullnet-server/src/net.rs b/members/nullnet-server/src/net.rs index 6a1c4da..97bf2d1 100644 --- a/members/nullnet-server/src/net.rs +++ b/members/nullnet-server/src/net.rs @@ -1,8 +1,8 @@ use crate::net_id_pool::DEFAULT_VXLAN_DSTPORT; use ipnetwork::Ipv4Network; use nullnet_grpc_lib::nullnet_grpc::{ - HostMapping, MsgId, Net, NetMessage, VlanSetup, VlanTeardown, VxlanSetup, VxlanTeardown, - net_message, + HostMapping, MsgId, Net, NetMessage, PolicyTransport, VlanSetup, VlanTeardown, VxlanSetup, + VxlanTeardown, net_message, }; use nullnet_liberror::{ErrorHandler, Location, location}; use std::net::{IpAddr, Ipv4Addr}; @@ -37,6 +37,7 @@ pub(crate) trait NetExt { dstport: Option, encrypted: bool, egress: EgressRole, + policy_transport: Option, ) -> Option<(Ipv4Addr, NetMessage)>; #[allow(clippy::too_many_arguments)] @@ -49,6 +50,7 @@ pub(crate) trait NetExt { remote_ip: IpAddr, dstport: Option, msg_id: String, + policy: bool, ) -> NetMessage; } @@ -66,6 +68,7 @@ impl NetExt for Net { dstport: Option, encrypted: bool, egress: EgressRole, + policy_transport: Option, ) -> Option<(Ipv4Addr, NetMessage)> { match self { Net::Vlan => vlan_setup( @@ -89,6 +92,7 @@ impl NetExt for Net { dstport.unwrap_or(u32::from(DEFAULT_VXLAN_DSTPORT)), encrypted, egress, + policy_transport, ), } } @@ -102,6 +106,7 @@ impl NetExt for Net { remote_ip: IpAddr, dstport: Option, msg_id: String, + policy: bool, ) -> NetMessage { let msg_id = Some(MsgId { id: msg_id }); match self { @@ -121,6 +126,7 @@ impl NetExt for Net { remote_ip: remote_ip.to_string(), dstport: u32::from(dstport.unwrap_or(DEFAULT_VXLAN_DSTPORT)), msg_id, + policy, })), }, } @@ -189,6 +195,7 @@ fn vxlan_setup( dstport: u32, encrypted: bool, egress: EgressRole, + policy_transport: Option, ) -> Option<(Ipv4Addr, NetMessage)> { // Map vxlan_id to a /29 block within 10.0.0.0/8. // Each ID gets 8 IPs (6 usable), with 4 IPs used for ns/br server/client. @@ -269,6 +276,7 @@ fn vxlan_setup( egress_steer, egress_intercept, encrypted, + policy_transport, })), }, )) @@ -323,6 +331,7 @@ mod tests { 4789, false, EgressRole::None, + None, ) .unwrap(); let (client_addr, _) = vxlan_setup( @@ -337,6 +346,7 @@ mod tests { 4789, false, EgressRole::None, + None, ) .unwrap(); diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 927195a..f974f6b 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -67,6 +67,7 @@ pub(crate) struct NullnetGrpcImpl { /// are serialized rather than each building the same chain. See /// [`NullnetGrpcImpl::handle_proxy_request`]. inflight_proxy: Arc>>>>, + dedicated_admissions: Arc>>, /// Fires whenever a config save/delete changes timeout-relevant state; /// `check_timeouts` is its only consumer. config_changed: Arc, @@ -84,6 +85,25 @@ pub(crate) struct NullnetGrpcImpl { /// keys the resulting `Client` entry, so one key ⇔ one proxy session. type ProxyKey = (String, String, IpAddr); +struct DedicatedAdmission { + counts: Arc>>, + key: (String, String), +} + +impl Drop for DedicatedAdmission { + fn drop(&mut self) { + let mut counts = self + .counts + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let count = counts.get_mut(&self.key).expect("admission is registered"); + *count -= 1; + if *count == 0 { + counts.remove(&self.key); + } + } +} + /// Build the live TCP/UDP port→service table from the current `StackMap`. /// `Http` services are excluded — they stay on Host-header routing. /// How long an edge task waits for another task's reservation of the same edge @@ -413,6 +433,7 @@ impl NullnetGrpcImpl { port_mappings: port_mappings_rx, http_routes: http_routes_rx, inflight_proxy: Arc::new(StdMutex::new(HashMap::new())), + dedicated_admissions: Arc::new(StdMutex::new(HashMap::new())), config_changed: config_changed_for_state, port_mappings_changed: port_mappings_changed_for_state, http_routes_changed: http_routes_changed_for_state, @@ -526,6 +547,29 @@ impl NullnetGrpcImpl { result } + fn dedicated_admission( + &self, + stack: &str, + service: &str, + current: usize, + max: u32, + ) -> Option { + let key = (stack.to_string(), service.to_string()); + let mut counts = self + .dedicated_admissions + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let inflight = counts.get(&key).copied().unwrap_or_default(); + if current + inflight >= max as usize { + return None; + } + *counts.entry(key.clone()).or_default() += 1; + Some(DedicatedAdmission { + counts: self.dedicated_admissions.clone(), + key, + }) + } + async fn proxy_request_locked( &self, service_name: &str, @@ -534,26 +578,31 @@ impl NullnetGrpcImpl { ) -> Result { println!("Received proxy request for '{service_name}'"); - let (stack, service_info) = { + let proxy_client = Client::new(client_ip.to_string(), Some(proxy_ip)); + let stack = { let guard = self.services.read().await; - let stack = find_service_stack(&guard, service_name) + find_service_stack(&guard, service_name) .ok_or("Service not found in any stack") .handle_err(location!())? - .to_string(); - let si = guard[&stack][service_name].clone(); - (stack, si) + .to_string() }; - - if service_info.timeout().is_none() { - Err("Service is not a configured entry point").handle_err(location!())?; - } - - let ServiceInfo::Registered(mut registered) = service_info else { - Err("Service is not registered").handle_err(location!())? + let (existing, replica, mut max_networks, mut dedicated_count) = { + let guard = self.services.read().await; + let service_info = &guard[&stack][service_name]; + if service_info.timeout().is_none() { + Err("Service is not a configured entry point").handle_err(location!())?; + } + let ServiceInfo::Registered(reg) = service_info else { + Err("Service is not registered").handle_err(location!())? + }; + ( + reg.is_client_setup(&proxy_client), + reg.client_replica(&proxy_client), + reg.max_networks(), + reg.dedicated_proxy_networks_count(), + ) }; - let proxy_client = Client::new(client_ip.to_string(), Some(proxy_ip)); - // Sticky session: reuse the network this client is already on — but only // while the dependency chain built alongside it is still complete. // @@ -565,8 +614,8 @@ impl NullnetGrpcImpl { // path below rebuild entry and chain together: repairing just the missing // edges here would double-count `active_chains` on the edges still up, // since teardown decrements once per branch per proxy client. - if let Some(upstream) = registered.is_client_setup(&proxy_client) { - let intact = match registered.client_replica(&proxy_client) { + if let Some(upstream) = existing { + let intact = match replica { Some((replica_ip, replica_docker)) => { self.services.read().await.get(&stack).is_some_and(|sm| { dep_chain_intact(service_name, replica_ip, replica_docker.as_deref(), sm) @@ -616,17 +665,33 @@ impl NullnetGrpcImpl { // The eviction dropped this client and may have freed its network, so // the snapshot the max-networks check below reads has to be re-taken. - let guard = self.services.read().await; - let Some(ServiceInfo::Registered(reg)) = - guard.get(&stack).and_then(|sm| sm.get(service_name)) - else { - Err("Service is not registered").handle_err(location!())? + (max_networks, dedicated_count) = { + let guard = self.services.read().await; + let Some(ServiceInfo::Registered(reg)) = + guard.get(&stack).and_then(|sm| sm.get(service_name)) + else { + Err("Service is not registered").handle_err(location!())? + }; + (reg.max_networks(), reg.dedicated_proxy_networks_count()) }; - registered = reg.clone(); } - // Select and attach under one lock so concurrent clients see current usage. - let reused = if registered.max_networks().is_some() { + let policy_eligible = + max_networks.is_some() && *NET_TYPE == Net::Vxlan && *ENCRYPTION_ENABLED; + let mut dedicated_admission = if policy_eligible { + self.dedicated_admission( + &stack, + service_name, + dedicated_count, + max_networks.expect("checked above"), + ) + } else { + None + }; + let mut policy_overflow = policy_eligible && dedicated_admission.is_none(); + + // Non-VXLAN configurations retain the existing bounded reuse behavior. + let reused = if max_networks.is_some() && !policy_eligible { let mut services = self.services.write().await; let reuse = services.get_mut(&stack).and_then(|stack_map| { let ServiceInfo::Registered(reg) = stack_map.get_mut(service_name)? else { @@ -704,11 +769,69 @@ impl NullnetGrpcImpl { // upstream. Bounded at one attempt so a teardown loop cannot spin here. for attempt in 0..2 { match self - .new_proxy_chain(&stack, service_name, proxy_ip, client_ip) + .new_proxy_chain( + &stack, + service_name, + proxy_ip, + client_ip, + policy_overflow, + dedicated_admission.take(), + ) .await { - Ok(Some(response)) => return Ok(response.into_inner()), - Ok(None) if attempt == 0 => {} + Ok(Some(response)) => { + if policy_overflow { + let proxy_client = Client::new(client_ip.to_string(), Some(proxy_ip)); + if let Some(net_id) = self + .services + .read() + .await + .get(&stack) + .and_then(|map| map.get(service_name)) + .and_then(|info| match info { + ServiceInfo::Registered(reg) => reg.client_net_id(&proxy_client), + ServiceInfo::Unregistered(_) => None, + }) + { + self.orchestrator + .events + .emit(Event::max_networks_limit_enforced( + service_name.to_string(), + proxy_ip.to_string(), + net_id, + max_networks.expect("checked above"), + )) + .await; + } + } + return Ok(response.into_inner()); + } + Ok(None) if attempt == 0 => { + if policy_eligible { + let current = self + .services + .read() + .await + .get(&stack) + .and_then(|map| map.get(service_name)) + .and_then(|info| match info { + ServiceInfo::Registered(reg) => { + Some(reg.dedicated_proxy_networks_count()) + } + ServiceInfo::Unregistered(_) => None, + }); + let Some(current) = current else { + break; + }; + dedicated_admission = self.dedicated_admission( + &stack, + service_name, + current, + max_networks.expect("checked above"), + ); + policy_overflow = dedicated_admission.is_none(); + } + } Ok(None) => { self.orchestrator .events @@ -833,12 +956,14 @@ impl NullnetGrpcImpl { })) } - pub(crate) async fn new_proxy_chain( + async fn new_proxy_chain( &self, stack: &str, service_name: &str, proxy_ip: IpAddr, client_ip: &str, + policy: bool, + admission: Option, ) -> Result>, Error> { let guard = self.services.read().await; let stack_map = guard @@ -858,14 +983,15 @@ impl NullnetGrpcImpl { let service_docker = replica.docker_container().map(String::from); drop(guard); + let key = (service_name.to_string(), client_ip.to_string(), proxy_ip); let outcome = self .setup_proxy_chain( stack, - service_name, - proxy_ip, - client_ip, + &key, service_ip, service_docker.as_deref(), + policy, + admission, ) .await?; @@ -929,29 +1055,34 @@ impl NullnetGrpcImpl { Ok(registered.backend_branch(service_name, service_ip, service_docker, port, stack_map)) } - pub(crate) async fn setup_proxy_chain( + async fn setup_proxy_chain( &self, stack: &str, - service_name: &str, - proxy_ip: IpAddr, - client_ip: &str, + key: &ProxyKey, service_ip: IpAddr, service_docker: Option<&str>, + policy: bool, + admission: Option, ) -> Result { + let (service_name, client_ip, proxy_ip) = key; let branches = self .proxy_branches(stack, service_name, service_ip, service_docker) .await?; - let entry = RegisteredEdge::new( - proxy_ip, - Client::new(client_ip.to_string(), Some(proxy_ip)), + let mut entry = RegisteredEdge::new( + *proxy_ip, + Client::new(client_ip.clone(), Some(*proxy_ip)), None, service_ip, Client::new(service_name.to_string(), None), service_docker.map(String::from), ); + entry.policy = policy; - match self.net_chain_setup(stack, Some(entry), branches).await? { + match self + .net_chain_setup_with_admission(stack, entry, branches, admission) + .await? + { ChainOutcome::Built(None) => { Err("No valid upstream IP found after NET chain setup").handle_err(location!()) } @@ -1305,7 +1436,7 @@ impl NullnetGrpcImpl { ); // An invalidated owner unwinds its increments instead of retaining a hold. let built = matches!( - self.net_chain_setup_owned(stack, None, vec![branch], owner) + self.net_chain_setup_owned(stack, None, vec![branch], owner, None) .await?, ChainOutcome::Built(_) ); @@ -1773,13 +1904,14 @@ impl NullnetGrpcImpl { #[allow(clippy::too_many_lines)] /// Detached wrapper around [`run_net_chain_setup`] — see its docs for why /// the chain must not be able to die with its caller. - pub(crate) async fn net_chain_setup( + async fn net_chain_setup_with_admission( &self, stack: &str, - entry: Option, + entry: RegisteredEdge, branches: Vec, + admission: Option, ) -> Result { - self.net_chain_setup_owned(stack, entry, branches, None) + self.net_chain_setup_owned(stack, Some(entry), branches, None, admission) .await } @@ -1789,6 +1921,7 @@ impl NullnetGrpcImpl { entry: Option, branches: Vec, owner: Option<(BackendKey, uuid::Uuid)>, + admission: Option, ) -> Result { let services = self.services.clone(); let orchestrator = self.orchestrator.clone(); @@ -1796,7 +1929,16 @@ impl NullnetGrpcImpl { let (tx, rx) = oneshot::channel(); tokio::spawn(async move { let _ = tx.send( - run_net_chain_setup(services, orchestrator, stack, entry, branches, owner).await, + run_net_chain_setup( + services, + orchestrator, + stack, + entry, + branches, + owner, + admission, + ) + .await, ); }); rx.await.handle_err(location!())? @@ -1909,6 +2051,7 @@ async fn run_net_chain_setup( entry: Option, branches: Vec, owner: Option<(BackendKey, uuid::Uuid)>, + admission: Option, ) -> Result { let mut join_set_outer = JoinSet::new(); @@ -1930,6 +2073,8 @@ async fn run_net_chain_setup( EdgeTarget::Fixed(server_ethernet, edge.server_docker), edge.backend_entry_port, edge.egress, + edge.policy, + admission, ) .await; match outcome { @@ -1980,6 +2125,8 @@ async fn run_net_chain_setup( EdgeTarget::Dependency, entry_port, false, + false, + None, ) .await; match outcome { @@ -2158,6 +2305,8 @@ async fn setup_edge( target: EdgeTarget, backend_entry_port: Option, egress: bool, + policy: bool, + mut admission: Option, ) -> EdgeOutcome { let init_time = std::time::Instant::now(); // Egress edges steer on the initiator (client) side and intercept on the @@ -2208,7 +2357,7 @@ async fn setup_edge( ip, docker.as_deref(), client.clone(), - ClientInfo::reservation(client_ethernet), + ClientInfo::reservation(client_ethernet, policy), ) { return EdgeOutcome::Failed; } @@ -2237,6 +2386,9 @@ async fn setup_edge( let _ = tokio::time::timeout(EDGE_CLAIM_POLL, waiting_on.notified()).await; }; + // The pending client now occupies the dedicated slot in service state. + drop(admission.take()); + // The reservation protects the target while its shared resume completes. if let Some(resume) = server_resume && !resume.wait().await @@ -2281,8 +2433,27 @@ async fn setup_edge( } let encrypted = *ENCRYPTION_ENABLED; - let encryption_key = if encrypted { generate_key() } else { [0u8; 32] }; + let policy_transport = policy.then(|| orchestrator.policy_transport(net_id)); + let encryption_key = if let Some(transport) = &policy_transport { + transport + .encryption_key + .as_slice() + .try_into() + .expect("policy keys are generated as 32 bytes") + } else if encrypted { + generate_key() + } else { + [0u8; 32] + }; let dstport = None; + let server_policy = policy_transport.clone().map(|mut transport| { + transport.peer_container = client_docker.is_some(); + transport + }); + let client_policy = policy_transport.map(|mut transport| { + transport.peer_container = server_docker.is_some(); + transport + }); let orch = orchestrator.clone(); let cd = client_docker.clone(); @@ -2298,6 +2469,7 @@ async fn setup_edge( dstport, encrypted, server_egress, + server_policy, ); let orch2 = orchestrator.clone(); let cd = client_docker.clone(); @@ -2313,6 +2485,7 @@ async fn setup_edge( dstport, encrypted, client_egress, + client_policy, ); let (server_ok, client_ok) = tokio::join!(server_res, client_res); @@ -2320,10 +2493,15 @@ async fn setup_edge( // Local steering must not release packets into an unfinished peer. let activate = *NET_TYPE == Net::Vxlan && (backend_entry_port.is_some() || client_egress == EgressRole::Steer); - if server_ok.is_none() - || client_ok.is_none() - || (activate && !orchestrator.send_net_ready(client_ethernet, net_id).await) - { + let ready = if server_ok.is_none() || client_ok.is_none() { + false + } else if policy { + orchestrator.send_net_ready(server_ethernet, net_id).await + && orchestrator.send_net_ready(client_ethernet, net_id).await + } else { + !activate || orchestrator.send_net_ready(client_ethernet, net_id).await + }; + if !ready { if client.is_proxy().is_some() { orchestrator .events @@ -2332,12 +2510,13 @@ async fn setup_edge( } // rollback orchestrator - .send_net_teardown( + .send_net_teardown_kind( client_ethernet, client_docker.clone(), server_ethernet, server_docker.clone(), net_id, + policy, ) .await; // remove placeholder @@ -2380,7 +2559,7 @@ async fn setup_edge( .pending_notify(&client) .is_some_and(|current| Arc::ptr_eq(¤t, &reservation)) => { - let ci = ClientInfo::new( + let mut ci = ClientInfo::new( client_ethernet, net_ip_client, net_ip_server, @@ -2388,6 +2567,9 @@ async fn setup_edge( setup_ms, client_docker.clone(), ); + if policy { + ci.set_policy(); + } reg.add_client_to_replica( server_ethernet, server_docker.as_deref(), @@ -2418,12 +2600,13 @@ async fn setup_edge( )) .await; orchestrator - .send_net_teardown( + .send_net_teardown_kind( client_ethernet, client_docker, server_ethernet, server_docker, net_id, + policy, ) .await; return EdgeOutcome::Failed; @@ -2493,6 +2676,7 @@ impl NullnetGrpcImpl { port_mappings, http_routes, inflight_proxy: Arc::new(StdMutex::new(HashMap::new())), + dedicated_admissions: Arc::new(StdMutex::new(HashMap::new())), config_changed: Arc::new(Notify::new()), port_mappings_changed: Arc::new(Notify::new()), http_routes_changed: Arc::new(Notify::new()), diff --git a/members/nullnet-server/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index 77e5ad7..7b70efe 100644 --- a/members/nullnet-server/src/orchestrator.rs +++ b/members/nullnet-server/src/orchestrator.rs @@ -9,13 +9,13 @@ use crate::services::input::StackMap; use crate::sessions::SessionStore; use nullnet_grpc_lib::nullnet_grpc::{ ContainerResume, ContainerSuspend, EgressPolicyChanged, MsgId, NetMessage, NetReady, - net_message, + PolicyHeartbeat, PolicyTransport, net_message, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock, mpsc, oneshot}; use tonic::{Request, Status, Streaming}; @@ -162,6 +162,8 @@ pub struct Orchestrator { /// their acks are still outstanding. Lets tests wait for the pool to reach /// steady state instead of racing the detached free task. inflight_teardowns: Arc, + policy_generation: Arc, + policy_epoch: Arc<[u8; 16]>, pub(crate) events: EventStore, pub(crate) sessions: SessionStore, } @@ -178,6 +180,8 @@ impl Orchestrator { backend_sessions: Arc::new(RwLock::new(HashMap::new())), geo: GeoCache::from_env(), inflight_teardowns: Arc::new(AtomicUsize::new(0)), + policy_generation: Arc::new(AtomicU64::new(1)), + policy_epoch: Arc::new(*Uuid::new_v4().as_bytes()), sessions: SessionStore::with_events(events.clone()), events, } @@ -195,7 +199,15 @@ impl Orchestrator { .handle_err(location!())? .ip(); - self.clients.write().await.insert(client_ip, outbound); + outbound + .send(Ok(self.policy_heartbeat())) + .await + .handle_err(location!())?; + self.clients + .write() + .await + .insert(client_ip, outbound.clone()); + self.policy_heartbeats(client_ip, outbound); self.events .emit(Event::node_connected(client_ip.to_string())) .await; @@ -348,6 +360,7 @@ impl Orchestrator { dstport, encrypted, EgressRole::Intercept, + None, ); let init_res = self.send_net_setup( initiator_ip, @@ -360,6 +373,7 @@ impl Orchestrator { dstport, encrypted, EgressRole::Steer, + None, ); let (proxy_ok, init_ok) = tokio::join!(proxy_res, init_res); @@ -971,6 +985,7 @@ impl Orchestrator { dstport: Option, encrypted: bool, egress: EgressRole, + policy_transport: Option, ) -> Option { self.send_control(dest, |msg_id| { NET_TYPE.setup( @@ -985,6 +1000,7 @@ impl Orchestrator { dstport, encrypted, egress, + policy_transport, ) }) .await @@ -1095,6 +1111,51 @@ impl Orchestrator { self.net_id_pool.lock().await.allocate() } + pub(crate) fn policy_transport(&self, net_id: u32) -> PolicyTransport { + PolicyTransport { + id: 0x20_0000 + net_id, + encryption_key: generate_key().to_vec(), + peer_container: false, + generation: self.policy_generation.fetch_add(1, Ordering::Relaxed), + } + } + + fn policy_heartbeat(&self) -> NetMessage { + NetMessage { + message: Some(net_message::Message::PolicyHeartbeat(PolicyHeartbeat { + server_epoch: self.policy_epoch.to_vec(), + })), + } + } + + fn policy_heartbeats(&self, ip: IpAddr, outbound: OutboundStream) { + let clients = self.clients.clone(); + let epoch = self.policy_epoch.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_millis(500)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + interval.tick().await; + if !clients + .read() + .await + .get(&ip) + .is_some_and(|current| current.same_channel(&outbound)) + { + break; + } + let message = NetMessage { + message: Some(net_message::Message::PolicyHeartbeat(PolicyHeartbeat { + server_epoch: epoch.to_vec(), + })), + }; + if outbound.send(Ok(message)).await.is_err() { + break; + } + } + }); + } + /// Release a `net_id` that was allocated but never dispatched to either /// endpoint (e.g. a follow-up allocation failed). No teardown messages /// are sent — nothing was ever set up on either client. @@ -1148,6 +1209,19 @@ impl Orchestrator { server: IpAddr, server_docker: Option, net_id: u32, + ) { + self.send_net_teardown_kind(client, client_docker, server, server_docker, net_id, false) + .await; + } + + pub(crate) async fn send_net_teardown_kind( + &self, + client: IpAddr, + client_docker: Option, + server: IpAddr, + server_docker: Option, + net_id: u32, + policy: bool, ) { let mut acks = Vec::new(); for (dest, remote, side, docker) in [ @@ -1162,8 +1236,16 @@ impl Orchestrator { let msg_id = Uuid::new_v4().to_string(); self.pending.lock().await.insert(msg_id.clone(), tx); - let message = - NET_TYPE.teardown(net_id, side, docker, dest, remote, None, msg_id.clone()); + let message = NET_TYPE.teardown( + net_id, + side, + docker, + dest, + remote, + None, + msg_id.clone(), + policy, + ); acks.push((dest, msg_id, rx, outbound, message)); } @@ -1505,6 +1587,24 @@ mod teardown_ack_tests { } } +#[cfg(test)] +mod policy_group_tests { + use super::*; + + #[test] + fn every_overflow_edge_gets_a_distinct_group_and_key() { + let orchestrator = Orchestrator::new(); + let first = orchestrator.policy_transport(101); + let second = orchestrator.policy_transport(102); + + assert_eq!(first.id, 0x20_0000 + 101); + assert_eq!(second.id, 0x20_0000 + 102); + assert_ne!(first.id, second.id); + assert_ne!(first.encryption_key, second.encryption_key); + assert_ne!(first.generation, second.generation); + } +} + #[cfg(test)] mod egress_liveness_tests { use super::*; diff --git a/members/nullnet-server/src/services/changes.rs b/members/nullnet-server/src/services/changes.rs index 8178614..2daa7d4 100644 --- a/members/nullnet-server/src/services/changes.rs +++ b/members/nullnet-server/src/services/changes.rs @@ -279,12 +279,13 @@ async fn teardown_chain( ci.docker_container().cloned(), replica_ip, replica_docker, + ci.policy(), ) }) .collect(); // For each proxy chain, walk and tear down its dependency edges - for (_, _, _, _, replica_ip, replica_docker) in &proxy_teardowns { + for (_, _, _, _, replica_ip, replica_docker, _) in &proxy_teardowns { teardown_dep_chain( name, *replica_ip, @@ -298,7 +299,7 @@ async fn teardown_chain( // Remove client entries first, then tear down proxy→service networks // only if no other client still shares the same net_id. if let Some(ServiceInfo::Registered(reg)) = services.get_mut(name) { - for (client, _, _, _, _, _) in &proxy_teardowns { + for (client, _, _, _, _, _, _) in &proxy_teardowns { reg.remove_client(client); } } @@ -308,7 +309,7 @@ async fn teardown_chain( // another. The network teardown below is per-net_id and stays deduped; a // row is per-client, and leaving one open would both read as permanently // live and let a later reuse of that net id land on it. - for (client, _, net_id, _, _, _) in &proxy_teardowns { + for (client, _, net_id, _, _, _, _) in &proxy_teardowns { // Keyed on the client's name — the external peer the row was opened // under, not the tunnel's near end (which is the shared proxy host). orchestrator @@ -318,7 +319,9 @@ async fn teardown_chain( } let mut torn_down_net_ids = HashSet::new(); - for (_, client_ip, net_id, client_docker, service_ip, service_docker) in &proxy_teardowns { + for (_, client_ip, net_id, client_docker, service_ip, service_docker, policy) in + &proxy_teardowns + { if !torn_down_net_ids.insert(*net_id) { continue; // already handled this net_id } @@ -329,12 +332,13 @@ async fn teardown_chain( }; if !shared { orchestrator - .send_net_teardown( + .send_net_teardown_kind( *client_ip, client_docker.clone(), *service_ip, service_docker.clone(), *net_id, + *policy, ) .await; orchestrator diff --git a/members/nullnet-server/src/services/clients.rs b/members/nullnet-server/src/services/clients.rs index ad65611..3e91150 100644 --- a/members/nullnet-server/src/services/clients.rs +++ b/members/nullnet-server/src/services/clients.rs @@ -141,6 +141,7 @@ pub(crate) struct ClientInfo { latest: Instant, created_at: SystemTime, docker_container: Option, + policy: bool, /// Set while the edge is reserved but not yet built. Everything that walks /// the graph — teardown, intactness, idle reaping — has to treat a /// reservation as *not an edge*, or it decrements a refcount it never @@ -169,6 +170,7 @@ impl ClientInfo { latest: Instant::now(), created_at: SystemTime::now(), docker_container, + policy: false, pending: None, } } @@ -176,7 +178,7 @@ impl ClientInfo { /// Reserve the slot for an edge that is about to be built. The refcount is /// taken here, not on promotion: between the two the task holds no lock, /// and a slot sitting at zero is one any teardown walk would collect. - pub(crate) fn reservation(client_ip: IpAddr) -> Self { + pub(crate) fn reservation(client_ip: IpAddr, policy: bool) -> Self { Self { client_ip, client_net: Ipv4Addr::UNSPECIFIED, @@ -188,6 +190,7 @@ impl ClientInfo { latest: Instant::now(), created_at: SystemTime::now(), docker_container: None, + policy, pending: Some(Arc::new(Notify::new())), } } @@ -196,6 +199,14 @@ impl ClientInfo { self.pending.is_some() } + pub(crate) fn set_policy(&mut self) { + self.policy = true; + } + + pub(crate) fn policy(&self) -> bool { + self.policy + } + pub(crate) fn client_ip(&self) -> IpAddr { self.client_ip } diff --git a/members/nullnet-server/src/services/edge.rs b/members/nullnet-server/src/services/edge.rs index decbe0a..8d5df79 100644 --- a/members/nullnet-server/src/services/edge.rs +++ b/members/nullnet-server/src/services/edge.rs @@ -15,6 +15,7 @@ pub(crate) struct RegisteredEdge { /// proxy). Drives the `egress_steer`/`egress_intercept` markers on the two /// `VxlanSetup` messages instead of DNAT. pub(crate) egress: bool, + pub(crate) policy: bool, } impl RegisteredEdge { @@ -33,6 +34,7 @@ impl RegisteredEdge { server_docker, backend_entry_port: None, egress: false, + policy: false, } } } diff --git a/members/nullnet-server/src/services/service_info.rs b/members/nullnet-server/src/services/service_info.rs index 45338ab..98c23a7 100644 --- a/members/nullnet-server/src/services/service_info.rs +++ b/members/nullnet-server/src/services/service_info.rs @@ -578,12 +578,13 @@ impl RegisteredServiceInfo { && let Some(ci) = replica.clients.clients_mut().remove(client) { orchestrator - .send_net_teardown( + .send_net_teardown_kind( ci.client_ip(), ci.docker_container().cloned(), replica.ip, replica.docker_container.clone(), ci.net_id(), + ci.policy(), ) .await; } @@ -670,6 +671,14 @@ impl RegisteredServiceInfo { .count() } + pub(crate) fn dedicated_proxy_networks_count(&self) -> usize { + self.replicas + .iter() + .flat_map(|r| r.clients.clients().iter()) + .filter(|(client, info)| client.is_proxy().is_some() && !info.policy()) + .count() + } + /// Find the network with the fewest clients on this proxy. /// Returns its upstream, addresses and replica identity for sharing. #[allow(clippy::type_complexity)] diff --git a/members/nullnet-server/src/tests.rs b/members/nullnet-server/src/tests.rs index 71381a8..53bd926 100644 --- a/members/nullnet-server/src/tests.rs +++ b/members/nullnet-server/src/tests.rs @@ -1782,13 +1782,13 @@ async fn multi_replica_first_step_container_disconnect() { // =========================================================================== // max_networks: A→B, max_networks=1, timeout=1. -// Two proxy clients on the same proxy: second reuses the first's network. +// The first ingress edge is dedicated; later edges use unique policy groups. // =========================================================================== const MAX_NETWORKS: &str = "max_networks"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn max_networks_balances_shared_networks_under_concurrent_requests() { +async fn max_networks_assigns_one_policy_group_per_concurrent_overflow() { let config: ServicesToml = toml::from_str( r#"[[services]] name = "A" @@ -1808,7 +1808,7 @@ proxy_dependencies = [["B"]] 8080, ) .await; - server.orchestrator().register_fake_client(proxy).await; + let proxy_log = server.orchestrator().register_recording_client(proxy).await; for i in 1..=10 { setup_proxy_chain(&server, "A", proxy, &format!("10.0.0.{i}")).await; } @@ -1824,7 +1824,7 @@ proxy_dependencies = [["B"]] while let Some(result) = requests.join_next().await { result.unwrap(); } - assert_net_ids_in_use(&server, 11).await; + assert_net_ids_in_use(&server, 201).await; let guard = server.services().read().await; let ServiceInfo::Registered(reg) = &stack_view(&guard)["A"] else { panic!("A should be registered"); @@ -1835,8 +1835,45 @@ proxy_dependencies = [["B"]] *users.entry(ci.net_id()).or_default() += 1; } } - assert_eq!(users.len(), 10); - assert!(users.values().all(|count| *count == 20), "{users:?}"); + assert_eq!(users.len(), 200); + assert!(users.values().all(|count| *count == 1), "{users:?}"); + let policy_clients = reg + .replicas() + .iter() + .flat_map(|replica| replica.clients().values()) + .filter(|info| info.policy()) + .count(); + assert_eq!(policy_clients, 190); + assert_eq!(reg.dedicated_proxy_networks_count(), 10); + drop(guard); + + let messages = proxy_log.lock().await; + let groups: Vec<_> = messages + .iter() + .filter_map(|message| match &message.message { + Some(net_message::Message::VxlanSetup(setup)) => setup.policy_transport.as_ref(), + _ => None, + }) + .collect(); + assert_eq!(groups.len(), 190); + assert_eq!( + groups + .iter() + .map(|group| group.id) + .collect::>() + .len(), + 190 + ); + assert_eq!( + groups + .iter() + .map(|group| group.encryption_key.clone()) + .collect::>() + .len(), + 190 + ); + drop(messages); + let guard = server.services().read().await; let ServiceInfo::Registered(dep) = &stack_view(&guard)["B"] else { panic!("B should be registered"); }; @@ -1852,11 +1889,58 @@ proxy_dependencies = [["B"]] ); } +#[tokio::test] +async fn max_networks_reuses_one_overflow_group_for_the_same_client_ip() { + let services = load_fixture(MAX_NETWORKS).await; + let server = NullnetGrpcImpl::new_for_test(services); + let proxy = ip(5, 5, 5, 5); + register_services( + &server, + &HashMap::from([("A", ip(1, 1, 1, 1)), ("B", ip(2, 2, 2, 2))]), + 8080, + ) + .await; + let proxy_log = server.orchestrator().register_recording_client(proxy).await; + + setup_proxy_chain(&server, "A", proxy, "10.0.0.1").await; + setup_proxy_chain(&server, "A", proxy, "10.0.0.2").await; + setup_proxy_chain(&server, "A", proxy, "10.0.0.2").await; + + assert_net_ids_in_use(&server, 3).await; + let guard = server.services().read().await; + let ServiceInfo::Registered(reg) = &stack_view(&guard)["A"] else { + panic!("A should be registered"); + }; + assert_eq!(reg.client_count(), 2); + assert_eq!( + reg.replicas() + .iter() + .flat_map(|replica| replica.clients().values()) + .filter(|info| info.policy()) + .count(), + 1 + ); + drop(guard); + + let messages = proxy_log.lock().await; + assert_eq!( + messages + .iter() + .filter(|message| matches!( + &message.message, + Some(net_message::Message::VxlanSetup(setup)) + if setup.policy_transport.is_some() + )) + .count(), + 1 + ); +} + /// Full lifecycle: /// 1. First client creates network (2 net IDs: proxy→A, A→B) -/// 2. Second client reuses (still 2 net IDs, but A has 2 proxy clients) -/// 3. First client times out (net stays up, A has 1 proxy client) -/// 4. Second client times out (net torn down, 0 net IDs) +/// 2. Second client gets a policy group (3 IDs including the dependency) +/// 3. First client times out (policy edge and dependency survive) +/// 4. Second client times out (all edges are torn down) #[tokio::test] async fn max_networks_reuse_lifecycle() { let services = load_fixture(MAX_NETWORKS).await; @@ -1884,14 +1968,14 @@ async fn max_networks_reuse_lifecycle() { assert_eq!(reg_b.client_count(), 1, "B should have 1 dep client (A→B)"); } - // 2. Second proxy client on same proxy — should reuse (max_networks=1) + // 2. Second proxy client on the same proxy — policy overflow. // Separate the two clients by 2s (>> the 1s timeout) so at step 3 C1 is // comfortably expired while C2 is comfortably alive. Timeouts use real // `Instant` (not tokio's pausable clock), so wide margins are needed to // survive wall-clock jitter on loaded CI runners. tokio::time::sleep(std::time::Duration::from_millis(2000)).await; setup_proxy_chain(&server, "A", proxy1, "10.0.0.2").await; - assert_net_ids_in_use(&server, 2).await; // no new net IDs allocated + assert_net_ids_in_use(&server, 3).await; { let guard = server.services().read().await; @@ -1900,7 +1984,7 @@ async fn max_networks_reuse_lifecycle() { panic!("A should be registered"); }; assert_eq!(reg_a.client_count(), 2, "A should have 2 proxy clients"); - // Both clients share the same net_id + // Every overflow connection is its own policy group and net id. let net_ids: HashSet = reg_a .replicas() .iter() @@ -1908,8 +1992,8 @@ async fn max_networks_reuse_lifecycle() { .collect(); assert_eq!( net_ids.len(), - 1, - "both clients should share the same net_id" + 2, + "overflow clients must not share a policy group" ); let ServiceInfo::Registered(reg_b) = &stack_view(&guard)["B"] else { @@ -1950,7 +2034,7 @@ async fn max_networks_reuse_lifecycle() { "B dep edge should survive (active_chains > 0)" ); } - // Net IDs still in use (shared network not torn down) + // The policy ingress edge and shared dependency remain. assert_net_ids_in_use(&server, 2).await; // 4. Second client times out — network should be torn down. @@ -1982,8 +2066,7 @@ async fn max_networks_reuse_lifecycle() { assert_net_ids_in_use(&server, 0).await; } -/// Proxy disconnect tears down both clients sharing a net_id at once. -/// The shared network should be torn down exactly once (dedup). +/// Proxy disconnect tears down both the dedicated and policy ingress edges. #[tokio::test] async fn max_networks_proxy_disconnect() { let services = load_fixture(MAX_NETWORKS).await; @@ -1996,7 +2079,7 @@ async fn max_networks_proxy_disconnect() { setup_proxy_chain(&server, "A", proxy1, "10.0.0.1").await; setup_proxy_chain(&server, "A", proxy1, "10.0.0.2").await; - assert_net_ids_in_use(&server, 2).await; + assert_net_ids_in_use(&server, 3).await; { let guard = server.services().read().await; @@ -4421,12 +4504,10 @@ async fn an_ingress_session_opens_and_closes_a_row() { assert!(rows[0].ended_at.is_some(), "should have been closed"); } -/// `max_networks` multiplexes several external clients onto one net_id. Each is -/// its own session, so each needs its own row closed — the teardown dedupes by -/// net_id and skips while the id is still shared, which for a while closed only -/// one of them and left the rest reading as live forever. +/// The dedicated client and each overflow policy group have separate sessions. +/// Expiring either must close exactly its own row. #[tokio::test] -async fn clients_sharing_a_net_id_each_close_their_own_row() { +async fn max_networks_overflow_closes_each_client_session() { let services = load_fixture(MAX_NETWORKS).await; let server = NullnetGrpcImpl::new_for_test(services); let db = attach_session_db(&server).await; @@ -4446,11 +4527,10 @@ async fn clients_sharing_a_net_id_each_close_their_own_row() { let rows = session_rows(&server, &db).await; assert_eq!(rows.len(), 2, "one row per client, got {rows:?}"); let net_ids: HashSet = rows.iter().map(|r| r.net_id).collect(); - assert_eq!(net_ids.len(), 1, "both clients share one net_id"); + assert_eq!(net_ids.len(), 2, "overflow has its own policy group"); assert!(rows.iter().all(|r| r.ended_at.is_none())); - // First client expires; the network stays up for the second, so the - // per-net_id teardown is skipped — the row must close regardless. + // First client expires while the overflow client remains active. tokio::time::sleep(std::time::Duration::from_millis(200)).await; crate::timeout::reap_ingress_timeouts(server.services(), server.orchestrator()).await; From 2b70ecffc3991c181d8edd66b04425c07a1e7043 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Mon, 19 Oct 2026 18:56:01 +0200 Subject: [PATCH 3/9] docs: describe ingress overflow multiplexing --- CHANGELOG.md | 1 + SETUP.md | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db305b..4f23733 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All Nullnet releases with the relative changes are documented in this file. - Per-service egress/ingress traffic filters: arbitrary AND/OR/group combinations of Country, Organization, Src IP (ingress), and Dst IP (egress) conditions, evaluated via `rpn-predicate-interpreter` — replaces the country-only egress/ingress policy ([#171](https://github.com/NullNet-ai/nullnet/pull/171) — fixes [#143](https://github.com/NullNet-ai/nullnet/issues/143)) - Persist ingress and egress sessions to SQLite and show the full history on the Sessions page, filterable by status, service, direction, and policy verdict, with its own retention window ([#170](https://github.com/NullNet-ai/nullnet/pull/170) — fixes [#156](https://github.com/NullNet-ai/nullnet/issues/156)) ### Changed +- Multiplex encrypted ingress overflow by client-IP policy group, with a unique key per group and direct same-host isolation. - Keep routine successful proxy requests out of Events, isolate diagnostic event RPC admission from routing, and group event filters by scope ([#204](https://github.com/NullNet-ai/nullnet/pull/204)). - Reduce fresh-edge cleanup work, batch conntrack scans, and share TC classifier infrastructure while preserving dedicated encrypted endpoints ([#201](https://github.com/NullNet-ai/nullnet/pull/201)). - Keep active backend chains visible to conntrack so liveness tracking does not release endpoints that still carry traffic ([#201](https://github.com/NullNet-ai/nullnet/pull/201)). diff --git a/SETUP.md b/SETUP.md index 588c56a..ca0e8dc 100644 --- a/SETUP.md +++ b/SETUP.md @@ -256,6 +256,12 @@ Run the setup scripts as root. The supplied systemd services also run as root; p - `timeout` controls proxy-reachability: when present the service is a proxy-reachable entry point with that per-client idle timeout in seconds (`0` disables the timeout); omit it to keep the service off the proxy (backend-only) +- `max_networks` caps dedicated ingress networks for a proxy-reachable service. Below the cap, + existing dedicated VXLAN behavior is unchanged. Encrypted VXLAN overflow multiplexes one policy + group and encryption key per client IP over a shared VXLAN; repeat connections from that IP reuse + its group. Same-host overflow uses the same isolation policy with direct eBPF redirection and no + VXLAN hairpin. Keep this cap low when cold-connection throughput matters: a high cap retains the + dedicated device lifecycle before multiplexing starts. - `proxy_dependencies` is a list of independent dep chains walked when the service is reached via a `Proxy` RPC from nullnet-proxy; each inner array is one linear branch and all branches are brought up in parallel From 9733c6c059ef6bf25d09dfcf538266f34ee5e927 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Tue, 20 Oct 2026 15:37:28 +0200 Subject: [PATCH 4/9] feat: adapt ingress multiplexing to congestion --- CHANGELOG.md | 1 + SETUP.md | 15 +- .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 5 + members/nullnet-grpc-lib/src/lib.rs | 105 ++- .../src/proto/nullnet_grpc.rs | 10 + members/nullnet-proxy/src/main.rs | 1 + members/nullnet-proxy/src/tcp_relay.rs | 1 + members/nullnet-proxy/src/udp_relay.rs | 1 + members/nullnet-server/src/congestion.rs | 690 ++++++++++++++++++ members/nullnet-server/src/events.rs | 16 + members/nullnet-server/src/main.rs | 1 + .../nullnet-server/src/nullnet_grpc_impl.rs | 176 +++-- .../nullnet-server/ui/src/lib/eventKinds.ts | 1 + .../nullnet-server/ui/src/pages/Events.tsx | 4 + members/nullnet-server/ui/src/types.ts | 1 + 15 files changed, 955 insertions(+), 73 deletions(-) create mode 100644 members/nullnet-server/src/congestion.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f23733..8afe534 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ All Nullnet releases with the relative changes are documented in this file. - Per-service egress/ingress traffic filters: arbitrary AND/OR/group combinations of Country, Organization, Src IP (ingress), and Dst IP (egress) conditions, evaluated via `rpn-predicate-interpreter` — replaces the country-only egress/ingress policy ([#171](https://github.com/NullNet-ai/nullnet/pull/171) — fixes [#143](https://github.com/NullNet-ai/nullnet/issues/143)) - Persist ingress and egress sessions to SQLite and show the full history on the Sessions page, filterable by status, service, direction, and policy verdict, with its own retention window ([#170](https://github.com/NullNet-ai/nullnet/pull/170) — fixes [#156](https://github.com/NullNet-ai/nullnet/issues/156)) ### Changed +- Adapt encrypted ingress multiplexing, with or without a configured hard cap, to sustained RPC admission congestion by pausing dedicated setups and additively probing recovery per host. - Multiplex encrypted ingress overflow by client-IP policy group, with a unique key per group and direct same-host isolation. - Keep routine successful proxy requests out of Events, isolate diagnostic event RPC admission from routing, and group event filters by scope ([#204](https://github.com/NullNet-ai/nullnet/pull/204)). - Reduce fresh-edge cleanup work, batch conntrack scans, and share TC classifier infrastructure while preserving dedicated encrypted endpoints ([#201](https://github.com/NullNet-ai/nullnet/pull/201)). diff --git a/SETUP.md b/SETUP.md index ca0e8dc..edfb523 100644 --- a/SETUP.md +++ b/SETUP.md @@ -256,12 +256,15 @@ Run the setup scripts as root. The supplied systemd services also run as root; p - `timeout` controls proxy-reachability: when present the service is a proxy-reachable entry point with that per-client idle timeout in seconds (`0` disables the timeout); omit it to keep the service off the proxy (backend-only) -- `max_networks` caps dedicated ingress networks for a proxy-reachable service. Below the cap, - existing dedicated VXLAN behavior is unchanged. Encrypted VXLAN overflow multiplexes one policy - group and encryption key per client IP over a shared VXLAN; repeat connections from that IP reuse - its group. Same-host overflow uses the same isolation policy with direct eBPF redirection and no - VXLAN hairpin. Keep this cap low when cold-connection throughput matters: a high cap retains the - dedicated device lifecycle before multiplexing starts. +- `max_networks` is an optional hard cap on dedicated ingress networks for a proxy-reachable + service. Independently of that setting, encrypted VXLAN ingress switches new clients to + multiplexing when the proxy's RPC backlog and slot-wait time grow for consecutive windows. A + host-scoped controller first pauses dedicated setups, then additively admits probes while the RPC + path recovers; renewed congestion immediately returns the rate to zero. Stable queues do not + activate it, and the limit is removed after sustained recovery. Multiplexed clients get one policy + group and encryption key per client IP over a shared VXLAN, while repeat connections reuse that + group. Same-host multiplexing uses the same isolation policy with direct eBPF redirection and no + VXLAN hairpin. - `proxy_dependencies` is a list of independent dep chains walked when the service is reached via a `Proxy` RPC from nullnet-proxy; each inner array is one linear branch and all branches are brought up in parallel diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index aba480e..35c5533 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -309,6 +309,11 @@ message HostMapping { message ProxyRequest { string client_ip = 1; string service_name = 2; + uint64 routing_arrivals = 3; + uint64 routing_completions = 4; + uint32 routing_waiters = 5; + uint64 routing_wait_us = 6; + uint64 routing_epoch = 7; } message Upstream { diff --git a/members/nullnet-grpc-lib/src/lib.rs b/members/nullnet-grpc-lib/src/lib.rs index 11956e8..f00e17b 100644 --- a/members/nullnet-grpc-lib/src/lib.rs +++ b/members/nullnet-grpc-lib/src/lib.rs @@ -11,7 +11,14 @@ use crate::nullnet_grpc::{ ProxyConnectionEnd, ProxyRequest, ServiceReport, ServicesListResponse, Upstream, }; pub use proto::*; -use std::{future::Future, path::Path, sync::Arc}; +use std::{ + future::Future, + path::Path, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, +}; use tokio::sync::{Semaphore, mpsc}; use tonic::Request; pub use tonic::Streaming; @@ -43,6 +50,48 @@ pub struct NullnetGrpcInterface { lifecycle_slots: Arc, policy_slots: Arc, event_slots: Arc, + proxy_metrics: Arc, +} + +struct ProxyMetrics { + epoch: u64, + arrivals: AtomicU64, + completions: AtomicU64, + waiters: AtomicU64, +} + +impl Default for ProxyMetrics { + fn default() -> Self { + static NEXT_EPOCH: AtomicU64 = AtomicU64::new(0); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + Self { + epoch: u64::try_from(timestamp) + .unwrap_or(u64::MAX) + .wrapping_add(NEXT_EPOCH.fetch_add(1, Ordering::Relaxed)), + arrivals: AtomicU64::new(0), + completions: AtomicU64::new(0), + waiters: AtomicU64::new(0), + } + } +} + +struct CounterGuard<'a>(&'a AtomicU64); + +impl Drop for CounterGuard<'_> { + fn drop(&mut self) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +struct WaiterGuard<'a>(&'a AtomicU64); + +impl Drop for WaiterGuard<'_> { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } } impl NullnetGrpcInterface { @@ -76,6 +125,7 @@ impl NullnetGrpcInterface { lifecycle_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_LIFECYCLE)), policy_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_POLICY)), event_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_EVENTS)), + proxy_metrics: Arc::new(ProxyMetrics::default()), }); } @@ -136,17 +186,33 @@ impl NullnetGrpcInterface { } #[allow(clippy::missing_errors_doc)] - pub async fn proxy(&self, message: ProxyRequest) -> Result { - self.unary(self.client.clone().proxy(Request::new(message))) + pub async fn proxy(&self, mut message: ProxyRequest) -> Result { + self.proxy_metrics.arrivals.fetch_add(1, Ordering::Relaxed); + let _completed = CounterGuard(&self.proxy_metrics.completions); + self.proxy_metrics.waiters.fetch_add(1, Ordering::Relaxed); + let waiting = WaiterGuard(&self.proxy_metrics.waiters); + let started = std::time::Instant::now(); + let permit = self + .unary_slots + .acquire() .await - .map(tonic::Response::into_inner) - .map_err(|e| { - if e.code() == tonic::Code::NotFound { - ProxyLookupError::UnknownService - } else { - ProxyLookupError::Failed(e.to_string()) - } - }) + .expect("RPC limiter stays open"); + drop(waiting); + let remaining = self.proxy_metrics.waiters.load(Ordering::Relaxed); + message.routing_arrivals = self.proxy_metrics.arrivals.load(Ordering::Relaxed); + message.routing_completions = self.proxy_metrics.completions.load(Ordering::Relaxed); + message.routing_waiters = u32::try_from(remaining).unwrap_or(u32::MAX); + message.routing_wait_us = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX); + message.routing_epoch = self.proxy_metrics.epoch; + let result = self.client.clone().proxy(Request::new(message)).await; + drop(permit); + result.map(tonic::Response::into_inner).map_err(|e| { + if e.code() == tonic::Code::NotFound { + ProxyLookupError::UnknownService + } else { + ProxyLookupError::Failed(e.to_string()) + } + }) } /// Report that a front connection through the proxy has closed. @@ -387,6 +453,19 @@ impl NullnetGrpcInterface { mod admission_tests { use super::*; + #[test] + fn proxy_metric_guards_account_for_cancellation() { + let metrics = ProxyMetrics::default(); + metrics.waiters.store(1, Ordering::Relaxed); + { + let _completed = CounterGuard(&metrics.completions); + let _waiting = WaiterGuard(&metrics.waiters); + } + assert_eq!(metrics.waiters.load(Ordering::Relaxed), 0); + assert_eq!(metrics.completions.load(Ordering::Relaxed), 1); + assert_ne!(metrics.epoch, ProxyMetrics::default().epoch); + } + #[tokio::test] async fn liveness_progresses_when_setup_admission_is_full() { let interface = NullnetGrpcInterface { @@ -397,6 +476,7 @@ mod admission_tests { lifecycle_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_LIFECYCLE)), policy_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_POLICY)), event_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_EVENTS)), + proxy_metrics: Arc::new(ProxyMetrics::default()), }; let _busy = interface .unary_slots @@ -428,6 +508,7 @@ mod admission_tests { lifecycle_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_LIFECYCLE)), policy_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_POLICY)), event_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_EVENTS)), + proxy_metrics: Arc::new(ProxyMetrics::default()), }; let _busy = interface .unary_slots @@ -459,6 +540,7 @@ mod admission_tests { lifecycle_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_LIFECYCLE)), policy_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_POLICY)), event_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_EVENTS)), + proxy_metrics: Arc::new(ProxyMetrics::default()), }; let (entered, mut received) = mpsc::unbounded_channel(); let mut calls = tokio::task::JoinSet::new(); @@ -497,6 +579,7 @@ mod admission_tests { lifecycle_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_LIFECYCLE)), policy_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_POLICY)), event_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_EVENTS)), + proxy_metrics: Arc::new(ProxyMetrics::default()), }; let _busy = interface .unary_slots diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index 2177b8c..2f63a24 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -293,6 +293,16 @@ pub struct ProxyRequest { pub client_ip: ::prost::alloc::string::String, #[prost(string, tag = "2")] pub service_name: ::prost::alloc::string::String, + #[prost(uint64, tag = "3")] + pub routing_arrivals: u64, + #[prost(uint64, tag = "4")] + pub routing_completions: u64, + #[prost(uint32, tag = "5")] + pub routing_waiters: u32, + #[prost(uint64, tag = "6")] + pub routing_wait_us: u64, + #[prost(uint64, tag = "7")] + pub routing_epoch: u64, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct Upstream { diff --git a/members/nullnet-proxy/src/main.rs b/members/nullnet-proxy/src/main.rs index 4bf69a9..048a545 100644 --- a/members/nullnet-proxy/src/main.rs +++ b/members/nullnet-proxy/src/main.rs @@ -318,6 +318,7 @@ impl ProxyHttp for NullnetProxy { let proxy_req = ProxyRequest { client_ip: client_ip.clone(), service_name: service_name.clone(), + ..Default::default() }; println!("{proxy_req:?}"); let upstream = match self.get_or_add_upstream(proxy_req).await { diff --git a/members/nullnet-proxy/src/tcp_relay.rs b/members/nullnet-proxy/src/tcp_relay.rs index 5039736..50d5a26 100644 --- a/members/nullnet-proxy/src/tcp_relay.rs +++ b/members/nullnet-proxy/src/tcp_relay.rs @@ -72,6 +72,7 @@ async fn handle_connection( let proxy_req = ProxyRequest { client_ip: client_ip.clone(), service_name: entry.service_name.clone(), + ..Default::default() }; // Ingress country policy: an explicit deny closes the connection. A check // error is logged and allowed through (upstream lookup fails anyway if the diff --git a/members/nullnet-proxy/src/udp_relay.rs b/members/nullnet-proxy/src/udp_relay.rs index be80fef..046e059 100644 --- a/members/nullnet-proxy/src/udp_relay.rs +++ b/members/nullnet-proxy/src/udp_relay.rs @@ -108,6 +108,7 @@ async fn handle_datagram( let proxy_req = ProxyRequest { client_ip: src.ip().to_string(), service_name: entry.service_name.clone(), + ..Default::default() }; // Ingress country policy: an explicit deny drops the datagram (no session). // A check error is logged and allowed through. diff --git a/members/nullnet-server/src/congestion.rs b/members/nullnet-server/src/congestion.rs new file mode 100644 index 0000000..bd9c70f --- /dev/null +++ b/members/nullnet-server/src/congestion.rs @@ -0,0 +1,690 @@ +use std::collections::{HashMap, VecDeque}; +use std::net::IpAddr; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const PRESSURE_WINDOW: Duration = Duration::from_secs(1); +const COMPLETION_HISTORY: Duration = Duration::from_secs(5); +const RECOVERY_QUIET: Duration = Duration::from_secs(5); +const CONGESTED_WINDOWS: u8 = 2; +const RECOVERY_WINDOWS: u8 = 2; +const BUCKET_BURST: f64 = 32.0; + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ProxyLoad { + pub(crate) epoch: u64, + pub(crate) arrivals: u64, + pub(crate) completions: u64, + pub(crate) waiters: u32, + pub(crate) wait_us: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Trend { + Congested, + Healthy, + Neutral, +} + +#[derive(Clone, Copy)] +struct PressureView { + sequence: u64, + trend: Trend, +} + +struct ProxyPressure { + window_started: Instant, + start: ProxyLoad, + last: ProxyLoad, + wait_sum: u128, + samples: u64, + previous_wait_mean: Option, + congested_windows: u8, + sequence: u64, + trend: Trend, +} + +impl ProxyPressure { + fn new(now: Instant, sample: ProxyLoad) -> Self { + Self { + window_started: now, + start: sample, + last: sample, + wait_sum: u128::from(sample.wait_us), + samples: 1, + previous_wait_mean: None, + congested_windows: 0, + sequence: 0, + trend: Trend::Neutral, + } + } + + fn reset(&mut self, now: Instant, sample: ProxyLoad) { + let sequence = self.sequence.saturating_add(1); + *self = Self::new(now, sample); + self.sequence = sequence; + } + + fn observe(&mut self, now: Instant, sample: ProxyLoad) { + if sample.epoch != self.last.epoch { + self.reset(now, sample); + return; + } + if sample.arrivals < self.last.arrivals || sample.completions < self.last.completions { + return; + } + self.last = sample; + self.wait_sum += u128::from(sample.wait_us); + self.samples += 1; + if now.duration_since(self.window_started) < PRESSURE_WINDOW { + return; + } + + let arrivals = self.last.arrivals - self.start.arrivals; + let completions = self.last.completions - self.start.completions; + let wait_mean = u64::try_from(self.wait_sum / u128::from(self.samples)).unwrap_or(u64::MAX); + let wait_rising = self + .previous_wait_mean + .is_none_or(|previous| wait_mean > previous); + let wait_not_rising = self.previous_wait_mean.is_none_or(|previous| { + wait_mean <= previous.saturating_add(previous / 10).saturating_add(100) + }); + let growing = + arrivals > completions && self.last.waiters > self.start.waiters && wait_rising; + let healthy = + arrivals <= completions && self.last.waiters <= self.start.waiters && wait_not_rising; + + self.trend = if growing { + self.congested_windows = self.congested_windows.saturating_add(1); + if self.congested_windows >= CONGESTED_WINDOWS { + self.congested_windows = 0; + Trend::Congested + } else { + Trend::Neutral + } + } else if healthy { + self.congested_windows = 0; + Trend::Healthy + } else { + self.congested_windows = 0; + Trend::Neutral + }; + self.sequence += 1; + self.previous_wait_mean = Some(wait_mean); + self.window_started = now; + self.start = sample; + self.last = sample; + self.wait_sum = u128::from(sample.wait_us); + self.samples = 1; + } + + fn view(&self) -> PressureView { + PressureView { + sequence: self.sequence, + trend: self.trend, + } + } +} + +struct HostRate { + rate: Option, + additive_step: f64, + tokens: f64, + last_refill: Instant, + last_denied: Option, + completions: VecDeque, + pressure_sequences: HashMap, + healthy_windows: u8, +} + +#[derive(Debug, PartialEq)] +pub(crate) enum RateTransition { + Activated { host: IpAddr }, + Recovered { host: IpAddr }, +} + +pub(crate) struct AdmissionDecision { + pub(crate) dedicated: bool, + pub(crate) transitions: Vec, +} + +impl HostRate { + fn new(now: Instant) -> Self { + Self { + rate: None, + additive_step: 1.0, + tokens: 0.0, + last_refill: now, + last_denied: None, + completions: VecDeque::new(), + pressure_sequences: HashMap::new(), + healthy_windows: 0, + } + } + + fn trim_completions(&mut self, now: Instant) { + while self + .completions + .front() + .is_some_and(|at| now.duration_since(*at) > COMPLETION_HISTORY) + { + self.completions.pop_front(); + } + } + + fn completion_rate(&mut self, now: Instant) -> f64 { + self.trim_completions(now); + let elapsed = self + .completions + .front() + .map_or(Duration::from_secs(1), |first| { + now.duration_since(*first).max(Duration::from_secs(1)) + }) + .min(COMPLETION_HISTORY); + self.completions.len() as f64 / elapsed.as_secs_f64() + } + + fn apply( + &mut self, + host: IpAddr, + proxy: IpAddr, + pressure: PressureView, + now: Instant, + ) -> Option { + if pressure.sequence + <= self + .pressure_sequences + .get(&proxy) + .copied() + .unwrap_or_default() + { + return None; + } + self.pressure_sequences.insert(proxy, pressure.sequence); + match pressure.trend { + Trend::Congested => { + let activating = self.rate.is_none(); + if activating { + let measured = self.completion_rate(now); + self.additive_step = (measured * 0.05).max(1.0); + } + self.rate = Some(0.0); + self.tokens = 0.0; + self.last_refill = now; + self.last_denied = Some(now); + self.healthy_windows = 0; + if activating { + return Some(RateTransition::Activated { host }); + } + } + Trend::Healthy => { + if let Some(rate) = &mut self.rate { + self.healthy_windows = self.healthy_windows.saturating_add(1); + if self.healthy_windows < RECOVERY_WINDOWS { + return None; + } + *rate += self.additive_step; + if self + .last_denied + .is_some_and(|last| now.duration_since(last) >= RECOVERY_QUIET) + && self.tokens >= BUCKET_BURST + { + self.rate = None; + self.tokens = 0.0; + return Some(RateTransition::Recovered { host }); + } + } + } + Trend::Neutral => { + if self.rate == Some(0.0) { + self.healthy_windows = 0; + } + } + } + None + } + + fn refill(&mut self, now: Instant) { + let Some(rate) = self.rate else { + self.last_refill = now; + return; + }; + self.tokens = (self.tokens + now.duration_since(self.last_refill).as_secs_f64() * rate) + .min(BUCKET_BURST); + self.last_refill = now; + } +} + +#[derive(Default)] +struct State { + proxies: HashMap, + hosts: HashMap, +} + +#[derive(Default)] +pub(crate) struct CongestionController { + state: Mutex, +} + +impl CongestionController { + pub(crate) fn observe_proxy(&self, proxy: IpAddr, sample: ProxyLoad) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Self::observe_proxy_locked(&mut state, proxy, sample, Instant::now()); + } + + fn observe_proxy_locked(state: &mut State, proxy: IpAddr, sample: ProxyLoad, now: Instant) { + state + .proxies + .entry(proxy) + .and_modify(|pressure| pressure.observe(now, sample)) + .or_insert_with(|| ProxyPressure::new(now, sample)); + } + + pub(crate) fn allow_dedicated(&self, proxy: IpAddr, destination: IpAddr) -> AdmissionDecision { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Self::allow_dedicated_locked(&mut state, proxy, destination, Instant::now()) + } + + fn allow_dedicated_locked( + state: &mut State, + proxy: IpAddr, + destination: IpAddr, + now: Instant, + ) -> AdmissionDecision { + let pressure = state.proxies.get(&proxy).map(ProxyPressure::view); + let hosts = [proxy, destination]; + let host_count = if proxy == destination { 1 } else { 2 }; + let mut transitions = Vec::new(); + for host in &hosts[..host_count] { + let rate = state + .hosts + .entry(*host) + .or_insert_with(|| HostRate::new(now)); + rate.refill(now); + if let Some(pressure) = pressure + && let Some(transition) = rate.apply(*host, proxy, pressure, now) + { + transitions.push(transition); + } + } + + let allowed = hosts[..host_count].iter().all(|host| { + state.hosts[host] + .rate + .is_none_or(|_| state.hosts[host].tokens >= 1.0) + }); + if allowed { + for host in &hosts[..host_count] { + let rate = state.hosts.get_mut(host).expect("host rate exists"); + if rate.rate.is_some() { + rate.tokens -= 1.0; + } + } + } else { + for host in &hosts[..host_count] { + let rate = state.hosts.get_mut(host).expect("host rate exists"); + if rate.rate.is_some() && rate.tokens < 1.0 { + rate.last_denied = Some(now); + } + } + } + AdmissionDecision { + dedicated: allowed, + transitions, + } + } + + pub(crate) fn record_dedicated_completion(&self, proxy: IpAddr, destination: IpAddr) { + let mut state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + Self::record_dedicated_completion_locked(&mut state, proxy, destination, Instant::now()); + } + + fn record_dedicated_completion_locked( + state: &mut State, + proxy: IpAddr, + destination: IpAddr, + now: Instant, + ) { + for host in [proxy, destination] { + let rate = state + .hosts + .entry(host) + .or_insert_with(|| HostRate::new(now)); + rate.completions.push_back(now); + rate.trim_completions(now); + if proxy == destination { + break; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ip(last: u8) -> IpAddr { + IpAddr::from([192, 0, 2, last]) + } + + fn sample(arrivals: u64, completions: u64, waiters: u32, wait_us: u64) -> ProxyLoad { + ProxyLoad { + epoch: 1, + arrivals, + completions, + waiters, + wait_us, + } + } + + #[test] + fn stable_waiters_do_not_activate_control() { + let controller = CongestionController::default(); + let start = Instant::now(); + let mut state = controller.state.lock().unwrap(); + CongestionController::observe_proxy_locked(&mut state, ip(1), sample(0, 0, 4, 500), start); + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + sample(100, 100, 4, 500), + start + Duration::from_secs(1), + ); + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + sample(200, 200, 4, 500), + start + Duration::from_secs(2), + ); + assert!( + CongestionController::allow_dedicated_locked( + &mut state, + ip(1), + ip(2), + start + Duration::from_secs(2) + ) + .dedicated + ); + } + + #[test] + fn growing_backlog_activates_rate_control() { + let controller = CongestionController::default(); + let start = Instant::now(); + let mut state = controller.state.lock().unwrap(); + for i in 0..100 { + CongestionController::record_dedicated_completion_locked( + &mut state, + ip(1), + ip(2), + start + Duration::from_millis(i * 10), + ); + } + CongestionController::observe_proxy_locked(&mut state, ip(1), sample(0, 0, 0, 0), start); + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + sample(100, 70, 30, 1_000), + start + Duration::from_secs(1), + ); + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + sample(200, 140, 60, 2_000), + start + Duration::from_secs(2), + ); + let first = CongestionController::allow_dedicated_locked( + &mut state, + ip(1), + ip(2), + start + Duration::from_secs(2), + ); + assert!(!first.dedicated); + assert_eq!(first.transitions.len(), 2); + assert!( + first + .transitions + .iter() + .all(|change| matches!(change, RateTransition::Activated { .. })) + ); + assert!( + !CongestionController::allow_dedicated_locked( + &mut state, + ip(1), + ip(2), + start + Duration::from_secs(2) + ) + .dedicated + ); + } + + #[test] + fn new_proxy_epoch_discards_old_pressure_and_can_reactivate() { + let controller = CongestionController::default(); + let start = Instant::now(); + let mut state = controller.state.lock().unwrap(); + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + sample(100, 50, 50, 1_000), + start, + ); + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + sample(200, 100, 100, 2_000), + start + Duration::from_secs(1), + ); + assert!( + CongestionController::allow_dedicated_locked( + &mut state, + ip(1), + ip(2), + start + Duration::from_secs(1) + ) + .dedicated + ); + let mut reset = sample(1, 0, 0, 0); + reset.epoch = 2; + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + reset, + start + Duration::from_secs(2), + ); + let mut growing = sample(101, 70, 30, 1_000); + growing.epoch = 2; + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + growing, + start + Duration::from_secs(3), + ); + growing = sample(201, 140, 60, 2_000); + growing.epoch = 2; + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + growing, + start + Duration::from_secs(4), + ); + let decision = CongestionController::allow_dedicated_locked( + &mut state, + ip(1), + ip(2), + start + Duration::from_secs(4), + ); + assert_eq!(decision.transitions.len(), 2); + } + + #[test] + fn out_of_order_sample_does_not_reset_pressure() { + let controller = CongestionController::default(); + let start = Instant::now(); + let mut state = controller.state.lock().unwrap(); + CongestionController::observe_proxy_locked(&mut state, ip(1), sample(0, 0, 0, 0), start); + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + sample(100, 70, 30, 1_000), + start + Duration::from_secs(1), + ); + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + sample(50, 40, 10, 500), + start + Duration::from_millis(1_500), + ); + CongestionController::observe_proxy_locked( + &mut state, + ip(1), + sample(200, 140, 60, 2_000), + start + Duration::from_secs(2), + ); + let decision = CongestionController::allow_dedicated_locked( + &mut state, + ip(1), + ip(2), + start + Duration::from_secs(2), + ); + assert_eq!(decision.transitions.len(), 2); + } + + #[test] + fn congestion_pauses_dedicated_setups_then_additively_probes() { + let start = Instant::now(); + let mut rate = HostRate::new(start); + for i in 0..100 { + rate.completions + .push_back(start + Duration::from_millis(i * 10)); + } + let proxy = ip(1); + assert_eq!( + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 1, + trend: Trend::Congested, + }, + start + Duration::from_secs(1), + ), + Some(RateTransition::Activated { host: ip(2) }) + ); + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 2, + trend: Trend::Congested, + }, + start + Duration::from_secs(2), + ); + assert_eq!(rate.rate, Some(0.0)); + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 3, + trend: Trend::Healthy, + }, + start + Duration::from_secs(3), + ); + assert_eq!(rate.rate, Some(0.0)); + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 4, + trend: Trend::Neutral, + }, + start + Duration::from_secs(4), + ); + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 5, + trend: Trend::Healthy, + }, + start + Duration::from_secs(5), + ); + assert_eq!(rate.rate, Some(0.0)); + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 6, + trend: Trend::Healthy, + }, + start + Duration::from_secs(6), + ); + assert_eq!(rate.rate, Some(5.0)); + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 7, + trend: Trend::Congested, + }, + start + Duration::from_secs(7), + ); + assert_eq!(rate.rate, Some(0.0)); + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 8, + trend: Trend::Healthy, + }, + start + Duration::from_secs(8), + ); + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 9, + trend: Trend::Healthy, + }, + start + Duration::from_secs(9), + ); + assert_eq!(rate.rate, Some(5.0)); + rate.tokens = 1.0; + rate.last_refill = start + Duration::from_secs(12); + assert_eq!( + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 10, + trend: Trend::Healthy, + }, + start + Duration::from_secs(12), + ), + None + ); + assert_eq!(rate.rate, Some(10.0)); + rate.tokens = BUCKET_BURST; + assert_eq!( + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 11, + trend: Trend::Healthy, + }, + start + Duration::from_secs(13), + ), + Some(RateTransition::Recovered { host: ip(2) }) + ); + assert_eq!(rate.rate, None); + } +} diff --git a/members/nullnet-server/src/events.rs b/members/nullnet-server/src/events.rs index f9c8220..eab3978 100644 --- a/members/nullnet-server/src/events.rs +++ b/members/nullnet-server/src/events.rs @@ -233,6 +233,11 @@ pub(crate) enum Event { limit: u32, timestamp: u64, }, + IngressCongestionControlChanged { + node_ip: String, + active: bool, + timestamp: u64, + }, NetIdPoolExhausted { service: String, client_ip: String, @@ -563,6 +568,7 @@ impl Event { Self::ProxyClientTimedOut { .. } => "proxy_client_timed_out", Self::StaleSessionEvicted { .. } => "stale_session_evicted", Self::MaxNetworksLimitEnforced { .. } => "max_networks_limit_enforced", + Self::IngressCongestionControlChanged { .. } => "ingress_congestion_control_changed", Self::NetIdPoolExhausted { .. } => "net_id_pool_exhausted", Self::ProxyChainSetupFailed { .. } => "proxy_chain_setup_failed", Self::BackendTriggerSetupBailed { .. } => "backend_trigger_setup_bailed", @@ -628,6 +634,8 @@ impl Event { Self::DeviceEventBypassChanged { available: false, .. } => Severity::Warning, + Self::IngressCongestionControlChanged { active: true, .. } => Severity::Warning, + Self::IngressCongestionControlChanged { active: false, .. } => Severity::Info, Self::UiTlsCertificateUnavailable { using_self_signed, .. } => { @@ -957,6 +965,14 @@ impl Event { } } + pub(crate) fn ingress_congestion_control_changed(node_ip: String, active: bool) -> Self { + Self::IngressCongestionControlChanged { + node_ip, + active, + timestamp: now_secs(), + } + } + pub(crate) fn net_id_pool_exhausted(service: String, client_ip: String) -> Self { Self::NetIdPoolExhausted { service, diff --git a/members/nullnet-server/src/main.rs b/members/nullnet-server/src/main.rs index 253f512..e3b477a 100644 --- a/members/nullnet-server/src/main.rs +++ b/members/nullnet-server/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod cert; mod cert_renewal; mod certs; +mod congestion; mod crypto; mod db; mod env; diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index f974f6b..b134635 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -1,3 +1,4 @@ +use crate::congestion::{CongestionController, ProxyLoad, RateTransition}; use crate::env::{ EGRESS_ALLOW_TCP_PORTS, EGRESS_ALLOW_UDP_PORTS, ENCRYPTION_ENABLED, INGRESS_ALLOW_TCP_PORTS, INGRESS_ALLOW_UDP_PORTS, NET_TYPE, PROXY_IP, @@ -68,6 +69,7 @@ pub(crate) struct NullnetGrpcImpl { /// [`NullnetGrpcImpl::handle_proxy_request`]. inflight_proxy: Arc>>>>, dedicated_admissions: Arc>>, + congestion: Arc, /// Fires whenever a config save/delete changes timeout-relevant state; /// `check_timeouts` is its only consumer. config_changed: Arc, @@ -90,6 +92,21 @@ struct DedicatedAdmission { key: (String, String), } +struct ProxyReplica { + ip: IpAddr, + port: u16, + docker: Option, +} + +struct ProxyChainRequest<'a> { + stack: &'a str, + service_name: &'a str, + proxy_ip: IpAddr, + client_ip: &'a str, + replica: &'a ProxyReplica, + policy: bool, +} + impl Drop for DedicatedAdmission { fn drop(&mut self) { let mut counts = self @@ -434,6 +451,7 @@ impl NullnetGrpcImpl { http_routes: http_routes_rx, inflight_proxy: Arc::new(StdMutex::new(HashMap::new())), dedicated_admissions: Arc::new(StdMutex::new(HashMap::new())), + congestion: Arc::new(CongestionController::default()), config_changed: config_changed_for_state, port_mappings_changed: port_mappings_changed_for_state, http_routes_changed: http_routes_changed_for_state, @@ -470,6 +488,18 @@ impl NullnetGrpcImpl { .ip(); let req = request.into_inner(); + if req.routing_arrivals > 0 { + self.congestion.observe_proxy( + proxy_ip, + ProxyLoad { + epoch: req.routing_epoch, + arrivals: req.routing_arrivals, + completions: req.routing_completions, + waiters: req.routing_waiters, + wait_us: req.routing_wait_us, + }, + ); + } let client_ip: IpAddr = req.client_ip.parse().handle_err(location!())?; let service_name = req.service_name; @@ -676,20 +706,7 @@ impl NullnetGrpcImpl { }; } - let policy_eligible = - max_networks.is_some() && *NET_TYPE == Net::Vxlan && *ENCRYPTION_ENABLED; - let mut dedicated_admission = if policy_eligible { - self.dedicated_admission( - &stack, - service_name, - dedicated_count, - max_networks.expect("checked above"), - ) - } else { - None - }; - let mut policy_overflow = policy_eligible && dedicated_admission.is_none(); - + let policy_eligible = *NET_TYPE == Net::Vxlan && *ENCRYPTION_ENABLED; // Non-VXLAN configurations retain the existing bounded reuse behavior. let reused = if max_networks.is_some() && !policy_eligible { let mut services = self.services.write().await; @@ -768,19 +785,64 @@ impl NullnetGrpcImpl { // repair — and the request that triggered it still deserves an // upstream. Bounded at one attempt so a teardown loop cannot spin here. for attempt in 0..2 { + let replica = self.pick_proxy_replica(&stack, service_name).await?; + let mut dedicated_admission = max_networks.and_then(|max| { + self.dedicated_admission(&stack, service_name, dedicated_count, max) + }); + let hard_cap_allows_dedicated = max_networks.is_none() || dedicated_admission.is_some(); + let adaptive_decision = (policy_eligible && hard_cap_allows_dedicated) + .then(|| self.congestion.allow_dedicated(proxy_ip, replica.ip)); + if let Some(decision) = &adaptive_decision { + for transition in &decision.transitions { + let (host, active) = match transition { + RateTransition::Activated { host } => { + println!( + "Ingress congestion control activated for {host}: new encrypted ingress multiplexed" + ); + (*host, true) + } + RateTransition::Recovered { host } => { + println!( + "Ingress congestion control recovered for {host}: dedicated setup rate unrestricted" + ); + (*host, false) + } + }; + self.orchestrator + .events + .emit(Event::ingress_congestion_control_changed( + host.to_string(), + active, + )) + .await; + } + } + let adaptive_overflow = adaptive_decision.is_some_and(|decision| !decision.dedicated); + if adaptive_overflow { + drop(dedicated_admission.take()); + } + let policy_overflow = + policy_eligible && (!hard_cap_allows_dedicated || adaptive_overflow); match self .new_proxy_chain( - &stack, - service_name, - proxy_ip, - client_ip, - policy_overflow, + ProxyChainRequest { + stack: &stack, + service_name, + proxy_ip, + client_ip, + replica: &replica, + policy: policy_overflow, + }, dedicated_admission.take(), ) .await { Ok(Some(response)) => { - if policy_overflow { + if policy_eligible && !policy_overflow { + self.congestion + .record_dedicated_completion(proxy_ip, replica.ip); + } + if policy_overflow && !adaptive_overflow { let proxy_client = Client::new(client_ip.to_string(), Some(proxy_ip)); if let Some(net_id) = self .services @@ -823,13 +885,7 @@ impl NullnetGrpcImpl { let Some(current) = current else { break; }; - dedicated_admission = self.dedicated_admission( - &stack, - service_name, - current, - max_networks.expect("checked above"), - ); - policy_overflow = dedicated_admission.is_none(); + dedicated_count = current; } } Ok(None) => { @@ -958,39 +1014,21 @@ impl NullnetGrpcImpl { async fn new_proxy_chain( &self, - stack: &str, - service_name: &str, - proxy_ip: IpAddr, - client_ip: &str, - policy: bool, + request: ProxyChainRequest<'_>, admission: Option, ) -> Result>, Error> { - let guard = self.services.read().await; - let stack_map = guard - .get(stack) - .ok_or("Stack not found") - .handle_err(location!())?; - let reg = match stack_map.get(service_name) { - Some(ServiceInfo::Registered(reg)) => reg, - _ => Err("Service is not registered").handle_err(location!())?, - }; - let replica = reg - .pick_replica_least_clients() - .ok_or("Service has no replicas") - .handle_err(location!())?; - let service_ip = replica.ip(); - let service_port = replica.port(); - let service_docker = replica.docker_container().map(String::from); - drop(guard); - - let key = (service_name.to_string(), client_ip.to_string(), proxy_ip); + let key = ( + request.service_name.to_string(), + request.client_ip.to_string(), + request.proxy_ip, + ); let outcome = self .setup_proxy_chain( - stack, + request.stack, &key, - service_ip, - service_docker.as_deref(), - policy, + request.replica.ip, + request.replica.docker.as_deref(), + request.policy, admission, ) .await?; @@ -1005,10 +1043,35 @@ impl NullnetGrpcImpl { Ok(Some(Response::new(Upstream { ip: upstream_ip.to_string(), - port: u32::from(service_port), + port: u32::from(request.replica.port), }))) } + async fn pick_proxy_replica( + &self, + stack: &str, + service_name: &str, + ) -> Result { + let guard = self.services.read().await; + let stack_map = guard + .get(stack) + .ok_or("Stack not found") + .handle_err(location!())?; + let reg = match stack_map.get(service_name) { + Some(ServiceInfo::Registered(reg)) => reg, + _ => Err("Service is not registered").handle_err(location!())?, + }; + let replica = reg + .pick_replica_least_clients() + .ok_or("Service has no replicas") + .handle_err(location!())?; + Ok(ProxyReplica { + ip: replica.ip(), + port: replica.port(), + docker: replica.docker_container().map(String::from), + }) + } + async fn proxy_branches( &self, stack: &str, @@ -2677,6 +2740,7 @@ impl NullnetGrpcImpl { http_routes, inflight_proxy: Arc::new(StdMutex::new(HashMap::new())), dedicated_admissions: Arc::new(StdMutex::new(HashMap::new())), + congestion: Arc::new(CongestionController::default()), config_changed: Arc::new(Notify::new()), port_mappings_changed: Arc::new(Notify::new()), http_routes_changed: Arc::new(Notify::new()), diff --git a/members/nullnet-server/ui/src/lib/eventKinds.ts b/members/nullnet-server/ui/src/lib/eventKinds.ts index 8d4206d..b2967f4 100644 --- a/members/nullnet-server/ui/src/lib/eventKinds.ts +++ b/members/nullnet-server/ui/src/lib/eventKinds.ts @@ -37,6 +37,7 @@ export const EVENT_KIND_GROUPS = defineEventKindGroups([ 'edge_promotion_lost', 'egress_reservation_reclaimed', 'file_watch_failed', + 'ingress_congestion_control_changed', 'max_networks_limit_enforced', 'net_id_pool_exhausted', 'net_teardown_unconfirmed', diff --git a/members/nullnet-server/ui/src/pages/Events.tsx b/members/nullnet-server/ui/src/pages/Events.tsx index 561208c..183082f 100644 --- a/members/nullnet-server/ui/src/pages/Events.tsx +++ b/members/nullnet-server/ui/src/pages/Events.tsx @@ -72,6 +72,10 @@ function eventDetail(e: EventJson): string { return `${e.service} · ${e.client_ip} via ${e.proxy_ip}`; case 'max_networks_limit_enforced': return `${e.service} · proxy ${e.proxy_ip} · net ${e.net_id} · limit ${e.limit}`; + case 'ingress_congestion_control_changed': + return e.active + ? `${e.node_ip} · congestion multiplexing activated` + : `${e.node_ip} · congestion multiplexing deactivated`; case 'net_id_pool_exhausted': case 'proxy_chain_setup_failed': return `${e.service} · ${e.client_ip}`; diff --git a/members/nullnet-server/ui/src/types.ts b/members/nullnet-server/ui/src/types.ts index 73edcb2..686e092 100644 --- a/members/nullnet-server/ui/src/types.ts +++ b/members/nullnet-server/ui/src/types.ts @@ -170,6 +170,7 @@ export type EventJson = | WithSeverity & { type: 'proxy_client_timed_out'; service: string; client_ip: string } | WithSeverity & { type: 'stale_session_evicted'; service: string; client_ip: string; proxy_ip: string } | WithSeverity & { type: 'max_networks_limit_enforced'; service: string; proxy_ip: string; net_id: number; limit: number } + | WithSeverity & { type: 'ingress_congestion_control_changed'; node_ip: string; active: boolean } | WithSeverity & { type: 'net_id_pool_exhausted'; service: string; client_ip: string } | WithSeverity & { type: 'proxy_chain_setup_failed'; service: string; client_ip: string } | WithSeverity & { type: 'backend_trigger_setup_bailed'; service: string; port: number } From 6cf45bb67a2de985715e6f7e0da6069bb1ce4177 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Tue, 20 Oct 2026 18:12:15 +0200 Subject: [PATCH 5/9] perf: share encrypted host-pair transports --- ebpf/src/main.rs | 13 + ebpf/src/policy.rs | 4 +- members/nullnet-client/src/commands/mod.rs | 19 ++ .../src/commands/native_xfrm.rs | 294 ++++++++++++++---- members/nullnet-client/src/commands/policy.rs | 77 +++-- members/nullnet-client/src/commands/vxlan.rs | 11 +- .../src/commands/vxlan_environment.rs | 24 ++ .../src/commands/xfrm_shards.rs | 217 +++++++++++++ members/nullnet-client/src/control_channel.rs | 1 + members/nullnet-client/src/main.rs | 1 + .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 16 +- .../src/proto/nullnet_grpc.rs | 17 +- members/nullnet-server/src/net.rs | 7 + .../nullnet-server/src/nullnet_grpc_impl.rs | 11 +- members/nullnet-server/src/orchestrator.rs | 79 ++++- members/nullnet-server/src/tests.rs | 14 +- shared/policy.rs | 1 + 17 files changed, 668 insertions(+), 138 deletions(-) create mode 100644 members/nullnet-client/src/commands/xfrm_shards.rs diff --git a/ebpf/src/main.rs b/ebpf/src/main.rs index 729ee9f..9f9caaa 100644 --- a/ebpf/src/main.rs +++ b/ebpf/src/main.rs @@ -49,6 +49,9 @@ use network_types::{ const VXLAN_PORT: u16 = 47890; const FORWARD_PORT: u16 = 9999; +const POLICY_PORT: u16 = 4791; +const POLICY_MARK: u32 = 0x4e80_0000; +const POLICY_MARK_MASK: u32 = 0xffc0_0000; const PROTO_TCP: u8 = 6; const PROTO_UDP: u8 = 17; @@ -160,6 +163,16 @@ fn try_firewall(ctx: &TcContext, is_egress: bool) -> Result { _ => return Ok(TC_ACT_SHOT), }; + let mark = unsafe { (*ctx.skb.skb).mark }; + if !is_egress + && proto == PROTO_UDP + && dst_port == POLICY_PORT + && mark & POLICY_MARK_MASK == POLICY_MARK + && (is_peer(src) || is_peer(dst)) + { + return Ok(TC_ACT_OK); + } + let key = ct_key(src, dst, src_port, dst_port, proto); if ct_hit(&key) || base_allow(is_egress, proto, src, dst, src_port, dst_port) { ct_insert(&key); diff --git a/ebpf/src/policy.rs b/ebpf/src/policy.rs index 5497712..ec09288 100644 --- a/ebpf/src/policy.rs +++ b/ebpf/src/policy.rs @@ -15,7 +15,7 @@ use core::mem::{size_of, zeroed}; #[path = "../../shared/policy.rs"] #[allow(dead_code)] mod types; -use types::{APP_IP, FlowKey, OutKey, Rule}; +use types::{APP_IP, FlowKey, OutKey, Rule, XFRM_ROUTE_BIT}; #[map] static POLICY_OUT: HashMap = HashMap::with_max_entries(65536, 0); @@ -137,7 +137,7 @@ fn outgoing(ctx: &TcContext) -> Result { { return Err(-1); } - ctx.set_mark(rule.mark); + ctx.set_mark(rule.mark | XFRM_ROUTE_BIT); Ok(unsafe { bpf_redirect(rule.transport, 0) } as i32) } diff --git a/members/nullnet-client/src/commands/mod.rs b/members/nullnet-client/src/commands/mod.rs index 3d029a3..b2dd2d5 100644 --- a/members/nullnet-client/src/commands/mod.rs +++ b/members/nullnet-client/src/commands/mod.rs @@ -21,6 +21,7 @@ pub(crate) mod vxlan; mod vxlan_cleanup; pub(crate) mod vxlan_environment; mod vxlan_recovery; +mod xfrm_shards; pub(crate) async fn setup_br0(rtnetlink_handle: &RtNetLinkHandle) { // create the bridge @@ -116,6 +117,21 @@ pub(crate) fn find_ethernet_interface(ip: Ipv4Addr) -> Option { }) } +pub(crate) async fn initialize_policy_shards( + rtnetlink_handle: &RtNetLinkHandle, +) -> Result<(), Error> { + let ip = find_ethernet_ip(rtnetlink_handle) + .await + .ok_or("could not find the local ethernet IP") + .handle_err(location!())?; + let interface = find_ethernet_interface(ip) + .ok_or("could not find the local ethernet interface") + .handle_err(location!())?; + xfrm_shards::initialize(&rtnetlink_handle.handle, &interface) + .await + .handle_err(location!()) +} + #[derive(Clone)] pub(crate) struct RtNetLinkHandle { handle: Handle, @@ -153,6 +169,9 @@ pub(crate) async fn cleanup_network( egress::init(); install_overlay_forwarding().handle_err(location!())?; let mss_error = install_mss_clamp(); + xfrm_shards::recover(&rtnetlink_handle.handle) + .await + .handle_err(location!())?; policy::recover(&rtnetlink_handle.handle) .await .handle_err(location!())?; diff --git a/members/nullnet-client/src/commands/native_xfrm.rs b/members/nullnet-client/src/commands/native_xfrm.rs index 6523803..83dbfac 100644 --- a/members/nullnet-client/src/commands/native_xfrm.rs +++ b/members/nullnet-client/src/commands/native_xfrm.rs @@ -1,5 +1,6 @@ -//! Dedicated endpoint IPsec associations sharing a VXLAN UDP socket. +//! IPsec associations for dedicated endpoints and multiplexed host pairs. use super::native_netlink::{CREATE, NativeSocket, REQUEST, attr, attrs, blocking}; +use super::policy::types::XFRM_ROUTE_BIT; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; use std::io; @@ -23,6 +24,7 @@ struct Edge { local: Ipv4Addr, remote: Ipv4Addr, port: u16, + if_id: u32, } impl Edge { fn mark(self) -> u32 { @@ -31,6 +33,12 @@ impl Edge { fn direction(self) -> u32 { u32::from(self.local > self.remote) } + fn multiplexed(self) -> bool { + self.generation != 0 + } + fn route_mark(self) -> u32 { + self.mark() | XFRM_ROUTE_BIT + } fn spi(self, direction: u32) -> u32 { SPI_PREFIX | (self.id << 1) | direction } @@ -61,14 +69,28 @@ impl Edge { fn address(ip: Ipv4Addr) -> Vec { [ip.octets().as_slice(), &[0; 12]].concat() } -fn selector(source: Ipv4Addr, destination: Ipv4Addr, port: u16) -> Vec { + +fn transport_owned_by( + owners: &HashSet, + edges: &HashMap, + id: u32, + except: Option, +) -> bool { + owners + .iter() + .any(|owner| Some(*owner) != except && edges.get(owner).is_some_and(|edge| edge.id == id)) +} + +fn selector(source: Ipv4Addr, destination: Ipv4Addr, port: u16, inexact: bool) -> Vec { let mut body = vec![0; 56]; - body[..16].copy_from_slice(&address(destination)); - body[16..32].copy_from_slice(&address(source)); + if !inexact { + body[..16].copy_from_slice(&address(destination)); + body[16..32].copy_from_slice(&address(source)); + } body[32..34].copy_from_slice(&port.to_be_bytes()); body[34..36].copy_from_slice(&u16::MAX.to_be_bytes()); body[40..42].copy_from_slice(&(libc::AF_INET as u16).to_ne_bytes()); - body[42..45].copy_from_slice(&[32, 32, 17]); + body[42..45].copy_from_slice(&[u8::from(!inexact) * 32, u8::from(!inexact) * 32, 17]); body } fn lifetime() -> Vec { @@ -100,7 +122,14 @@ fn state_message(edge: Edge, direction: u32, key: &[u8; 32], delete: bool) -> (u body.extend((libc::AF_INET as u16).to_ne_bytes()); body.extend([50, 0]); if !inbound { - body.extend(mark_attribute(edge.mark())); + body.extend(mark_attribute(if edge.multiplexed() { + edge.route_mark() + } else { + edge.mark() + })); + if edge.multiplexed() { + body.extend(attr(31, &edge.if_id.to_ne_bytes())); + } } return (17, REQUEST, body); } @@ -110,6 +139,7 @@ fn state_message(edge: Edge, direction: u32, key: &[u8; 32], delete: bool) -> (u body[96..160].copy_from_slice(&lifetime()); body[208..212].copy_from_slice(&edge.mark().to_ne_bytes()); body[212..214].copy_from_slice(&(libc::AF_INET as u16).to_ne_bytes()); + body[214] = u8::from(edge.multiplexed()); let hex: String = key.iter().map(|b| format!("{b:02x}")).collect(); let derived = Sha256::digest(format!("{hex}{direction}").as_bytes()); let derived_hex: String = derived.iter().map(|b| format!("{b:02x}")).collect(); @@ -126,11 +156,19 @@ fn state_message(edge: Edge, direction: u32, key: &[u8; 32], delete: bool) -> (u replay[..4].copy_from_slice(&words.to_ne_bytes()); replay[20..24].copy_from_slice(&REPLAY_WINDOW.to_ne_bytes()); body.extend(attr(23, &replay)); - if inbound { + if inbound || edge.multiplexed() { body.extend(attr(29, &edge.mark().to_ne_bytes())); body.extend(attr(30, &u32::MAX.to_ne_bytes())); - } else { - body.extend(mark_attribute(edge.mark())); + } + if !inbound { + body.extend(mark_attribute(if edge.multiplexed() { + edge.route_mark() + } else { + edge.mark() + })); + if edge.multiplexed() { + body.extend(attr(31, &edge.if_id.to_ne_bytes())); + } } (16, CREATE, body) } @@ -142,7 +180,11 @@ fn policy_mark(edge: Edge, inbound: bool) -> Vec { &[MARK_PREFIX.to_ne_bytes(), 0xffc0_0000u32.to_ne_bytes()].concat(), ) } else { - mark_attribute(edge.mark()) + mark_attribute(if edge.multiplexed() { + edge.route_mark() + } else { + edge.mark() + }) } } @@ -153,11 +195,19 @@ fn policy_message(edge: Edge, inbound: bool, delete: bool) -> (u16, u16, Vec (edge.local, edge.remote) }; let direction = u8::from(!inbound); - let mut body = selector(source, destination, edge.port); + let mut body = selector( + source, + destination, + edge.port, + !inbound && edge.multiplexed(), + ); if delete { body.extend(if inbound { 0 } else { edge.policy_index() }.to_ne_bytes()); body.extend([direction, 0, 0, 0]); body.extend(policy_mark(edge, inbound)); + if !inbound && edge.multiplexed() { + body.extend(attr(31, &edge.if_id.to_ne_bytes())); + } return (20, REQUEST, body); } body.extend(lifetime()); @@ -181,8 +231,12 @@ fn policy_message(edge: Edge, inbound: bool, delete: bool) -> (u16, u16, Vec template.extend((if inbound { 0 } else { edge.mark() }).to_ne_bytes()); template.extend([0; 4]); template.extend([255; 12]); + template[48] = u8::from(edge.multiplexed()); body.extend(attr(5, &template)); body.extend(policy_mark(edge, inbound)); + if !inbound && edge.multiplexed() { + body.extend(attr(31, &edge.if_id.to_ne_bytes())); + } (19, CREATE, body) } @@ -221,24 +275,30 @@ pub(super) fn mark(id: u32) -> u32 { } pub(super) async fn install_port( + owner: u32, id: u32, generation: u32, - local: Ipv4Addr, - remote: Ipv4Addr, + hosts: (Ipv4Addr, Ipv4Addr), key: [u8; 32], port: u16, + if_id: u32, ) -> io::Result<()> { let edge = Edge { id, generation, - local, - remote, + local: hosts.0, + remote: hosts.1, port, + if_id, }; - EDGES.lock().unwrap().insert(id, edge); let owners = edge.owners(); let mut owners = owners.lock().await; - if owners.is_empty() { + let first_peer = owners.is_empty(); + let first_transport = { + let edges = EDGES.lock().unwrap(); + !transport_owned_by(&owners, &edges, id, None) + }; + if first_peer { blocking(move || { policy( &mut NativeSocket::new(libc::NETLINK_XFRM as _)?, @@ -249,45 +309,72 @@ pub(super) async fn install_port( }) .await?; } - owners.insert(id); - drop(owners); - blocking(move || { - let mut socket = NativeSocket::new(libc::NETLINK_XFRM as _)?; - batch( - &mut socket, - &[ - state_message(edge, edge.direction(), &key, false), - state_message(edge, 1 - edge.direction(), &key, false), - policy_message(edge, false, false), - ], - false, - ) - }) - .await + let result = if first_transport { + blocking(move || { + let mut socket = NativeSocket::new(libc::NETLINK_XFRM as _)?; + batch( + &mut socket, + &[ + state_message(edge, edge.direction(), &key, false), + state_message(edge, 1 - edge.direction(), &key, false), + policy_message(edge, false, false), + ], + false, + ) + }) + .await + } else { + Ok(()) + }; + if let Err(error) = result { + if first_peer { + let _ = blocking(move || { + policy( + &mut NativeSocket::new(libc::NETLINK_XFRM as _)?, + edge, + true, + true, + ) + }) + .await; + } + return Err(error); + } + EDGES.lock().unwrap().insert(owner, edge); + owners.insert(owner); + Ok(()) } -pub(super) async fn remove(id: u32) -> io::Result<()> { - let edge = EDGES.lock().unwrap().get(&id).copied(); +pub(super) async fn remove(owner: u32) -> io::Result<()> { + let edge = EDGES.lock().unwrap().get(&owner).copied(); let Some(edge) = edge else { return Ok(()); }; - let mut first_error = blocking(move || { - let mut socket = NativeSocket::new(libc::NETLINK_XFRM as _)?; - Ok(batch( - &mut socket, - &[ - policy_message(edge, false, true), - state_message(edge, edge.direction(), &[0; 32], true), - state_message(edge, 1 - edge.direction(), &[0; 32], true), - ], - true, - ) - .err()) - }) - .await?; let owners = edge.owners(); let mut owners = owners.lock().await; - if owners.contains(&id) + let last_transport = { + let edges = EDGES.lock().unwrap(); + !transport_owned_by(&owners, &edges, edge.id, Some(owner)) + }; + let mut first_error = if last_transport { + blocking(move || { + let mut socket = NativeSocket::new(libc::NETLINK_XFRM as _)?; + Ok(batch( + &mut socket, + &[ + policy_message(edge, false, true), + state_message(edge, edge.direction(), &[0; 32], true), + state_message(edge, 1 - edge.direction(), &[0; 32], true), + ], + true, + ) + .err()) + }) + .await? + } else { + None + }; + if owners.contains(&owner) && owners.len() == 1 && let Err(error) = blocking(move || { policy( @@ -304,8 +391,8 @@ pub(super) async fn remove(id: u32) -> io::Result<()> { if let Some(error) = first_error { Err(error) } else { - owners.remove(&id); - EDGES.lock().unwrap().remove(&id); + owners.remove(&owner); + EDGES.lock().unwrap().remove(&owner); Ok(()) } } @@ -322,6 +409,14 @@ fn missing_ok(result: io::Result>) -> io::Result<()> { mod tests { use super::*; + fn kinds(body: &[u8], offset: usize) -> Vec { + attrs(&body[offset..]) + .unwrap() + .into_iter() + .map(|(kind, _)| kind) + .collect() + } + #[test] fn both_hosts_derive_the_same_directional_identity() { let a = Edge { @@ -330,6 +425,7 @@ mod tests { port: 4790, local: Ipv4Addr::new(192, 0, 2, 1), remote: Ipv4Addr::new(192, 0, 2, 2), + if_id: 0x4e71_0001, }; let b = Edge { local: a.remote, @@ -342,14 +438,102 @@ mod tests { assert_eq!(a.policy_index() & 7, 1); assert_eq!(a.policy_index() & 0xfe00_0000, POLICY_INDEX_PREFIX); assert_eq!(a.policy_priority(), u32::MAX - 7); - assert_eq!(selector(a.local, a.remote, a.port).len(), 56); + assert_eq!(selector(a.local, a.remote, a.port, false).len(), 56); assert_eq!( - &selector(a.local, a.remote, a.port)[32..36], + &selector(a.local, a.remote, a.port, false)[32..36], &[0x12, 0xb6, 0xff, 0xff] ); + assert_eq!(&selector(a.local, a.remote, a.port, true)[42..44], &[0, 0]); + assert_eq!(a.route_mark(), a.mark() | XFRM_ROUTE_BIT); assert_eq!(Edge { generation: 0, ..a }.policy_index(), 0); assert_eq!(Edge { generation: 0, ..a }.policy_priority(), 0); } + + #[test] + fn multiplexed_messages_use_tunnel_mode_shard_and_routing_mark() { + let edge = Edge { + id: 0x20_002a, + generation: 9, + port: 4791, + local: Ipv4Addr::new(192, 0, 2, 1), + remote: Ipv4Addr::new(192, 0, 2, 2), + if_id: 0x4e71_002b, + }; + let (_, _, outbound_state) = state_message(edge, edge.direction(), &[7; 32], false); + let (_, _, inbound_state) = state_message(edge, 1 - edge.direction(), &[7; 32], false); + let (_, _, outbound_policy) = policy_message(edge, false, false); + let (_, _, inbound_policy) = policy_message(edge, true, false); + + assert_eq!(outbound_state[214], 1); + assert_eq!(inbound_state[214], 1); + assert_eq!(kinds(&outbound_state, 224), vec![18, 23, 29, 30, 21, 31]); + assert_eq!(kinds(&inbound_state, 224), vec![18, 23, 29, 30]); + assert_eq!(&outbound_policy[42..44], &[0, 0]); + assert_eq!(&inbound_policy[42..44], &[32, 32]); + assert_eq!(kinds(&outbound_policy, 168), vec![5, 21, 31]); + assert_eq!(kinds(&inbound_policy, 168), vec![5, 21]); + let state_attributes = attrs(&outbound_state[224..]).unwrap(); + let mark = state_attributes + .iter() + .find(|(kind, _)| *kind == 21) + .unwrap() + .1; + assert_eq!( + u32::from_ne_bytes(mark[..4].try_into().unwrap()), + edge.route_mark() + ); + let output_mark = state_attributes + .iter() + .find(|(kind, _)| *kind == 29) + .unwrap() + .1; + assert_eq!( + u32::from_ne_bytes(output_mark.try_into().unwrap()), + edge.mark() + ); + } + + #[test] + fn dedicated_messages_remain_exact_transport_mode_without_shards() { + let edge = Edge { + id: 42, + generation: 0, + port: 4790, + local: Ipv4Addr::new(192, 0, 2, 1), + remote: Ipv4Addr::new(192, 0, 2, 2), + if_id: 0, + }; + let (_, _, outbound_state) = state_message(edge, edge.direction(), &[7; 32], false); + let (_, _, outbound_policy) = policy_message(edge, false, false); + + assert_eq!(outbound_state[214], 0); + assert_eq!(kinds(&outbound_state, 224), vec![18, 23, 21]); + assert_eq!(&outbound_policy[42..44], &[32, 32]); + assert_eq!(kinds(&outbound_policy, 168), vec![5, 21]); + } + + #[test] + fn shared_transport_lives_until_its_last_owner() { + let first = Edge { + id: 0x20_0000, + generation: 0, + port: 47890, + local: Ipv4Addr::new(192, 0, 2, 1), + remote: Ipv4Addr::new(192, 0, 2, 2), + if_id: 0, + }; + let second = Edge { ..first }; + let mut edges = HashMap::new(); + edges.insert(101, first); + edges.insert(102, second); + let owners = HashSet::from([101, 102]); + + assert!(transport_owned_by(&owners, &edges, first.id, None)); + assert!(transport_owned_by(&owners, &edges, first.id, Some(101))); + assert!(transport_owned_by(&owners, &edges, first.id, Some(102))); + let last = HashSet::from([102]); + assert!(!transport_owned_by(&last, &edges, first.id, Some(102))); + } } pub(super) async fn purge() -> io::Result<()> { @@ -383,7 +567,7 @@ pub(super) async fn purge() -> io::Result<()> { body.extend_from_slice(&message[156..161]); body.extend([0; 3]); for (kind, value) in attributes { - if kind == 21 { + if matches!(kind, 21 | 31) { body.extend(attr(kind, value)); } } @@ -402,7 +586,7 @@ pub(super) async fn purge() -> io::Result<()> { body.extend_from_slice(&message[212..214]); body.extend([50, 0]); for (kind, value) in attrs(&message[224..])? { - if kind == 21 { + if matches!(kind, 21 | 31) { body.extend(attr(kind, value)); } } diff --git a/members/nullnet-client/src/commands/policy.rs b/members/nullnet-client/src/commands/policy.rs index a2678cf..cde49dd 100644 --- a/members/nullnet-client/src/commands/policy.rs +++ b/members/nullnet-client/src/commands/policy.rs @@ -72,7 +72,6 @@ struct PolicyEdge { rule: Rule, attachment: Arc, local: bool, - transport_id: u32, } impl PolicyEdge { fn output_ready(&self) -> bool { @@ -224,13 +223,17 @@ impl Engine { .or_default() .clone(); cell.get_or_try_init(|| async { + let if_id = super::xfrm_shards::ensure(transport.id) + .await + .map_err(error)?; let result = native_xfrm::install_port( + transport.id, transport.id, transport.generation as u32, - local, - remote, + (local, remote), key, POLICY_PORT, + if_id, ) .await; if result.is_err() { @@ -575,7 +578,6 @@ pub(crate) async fn setup( rule: local_rule, attachment: output.clone(), local: true, - transport_id: transport.id, }, ); } else { @@ -587,7 +589,6 @@ pub(crate) async fn setup( rule, attachment: output.clone(), local: true, - transport_id: transport.id, }, ); } @@ -604,20 +605,13 @@ pub(crate) async fn setup( rule, attachment: output.clone(), local: false, - transport_id: transport.id, }, ); } Ok(()) })() }; - if let Err(map_error) = map_result { - if !local_transport { - engine.peers.lock().await.remove(&transport.id); - native_xfrm::remove(transport.id).await.map_err(error)?; - } - return Err(map_error); - } + map_result?; if let Some(container) = &message.docker_container { cache.add_overlay(&message.ns_name, Ipv4Addr::from(local_ip), container); } @@ -653,6 +647,19 @@ pub(crate) async fn heartbeat(server_epoch: &[u8]) -> Result<(), String> { first_error.get_or_insert(error); } } + if let Some(engine) = &*ENGINE.lock().await { + let peers: Vec<_> = engine.peers.lock().await.keys().copied().collect(); + for id in peers { + match native_xfrm::remove(id).await.map_err(error) { + Ok(()) => { + engine.peers.lock().await.remove(&id); + } + Err(error) => { + first_error.get_or_insert(error); + } + } + } + } if let Some(error) = first_error { return Err(error); } @@ -691,20 +698,18 @@ pub(crate) async fn ready(id: u32) -> Result { }; let mut found = false; let mut edges = engine.edges.lock().unwrap(); - for (name, edge) in edges.iter_mut() { - if name == &format!("ns_{id}_s") || name == &format!("ns_{id}_c") { - if edge.output_ready() { - edge.rule.enabled = 1; - } - engine - .maps - .lock() - .unwrap() - .outgoing - .insert(edge.key, edge.rule, 0) - .map_err(error)?; - found = true; + let mut maps = engine.maps.lock().unwrap(); + for name in [format!("ns_{id}_s"), format!("ns_{id}_c")] { + let Some(edge) = edges.get_mut(&name) else { + continue; + }; + if edge.output_ready() { + edge.rule.enabled = 1; } + maps.outgoing + .insert(edge.key, edge.rule, 0) + .map_err(error)?; + found = true; } Ok(found) } @@ -714,13 +719,12 @@ pub(crate) async fn retire(name: &str) -> Result { let Some(engine) = engine else { return Ok(false); }; - let (local, transport_id, removed, map_error) = { + let (removed, map_error) = { let mut edges = engine.edges.lock().unwrap(); let Some(edge) = edges.get(name) else { return Ok(false); }; let local = edge.local; - let transport_id = edge.transport_id; let id = name .strip_prefix("ns_") .and_then(|value| { @@ -757,19 +761,11 @@ pub(crate) async fn retire(name: &str) -> Result { } removed.push((name, edge)); } - (local, transport_id, removed, map_error) + (removed, map_error) }; - let mut xfrm_error = None; - if !local { - engine.peers.lock().await.remove(&transport_id); - if let Err(value) = native_xfrm::remove(transport_id).await { - xfrm_error = Some(error(value)); - } - } - let result = match (map_error, xfrm_error) { - (Some(map), Some(xfrm)) => Err(format!("{map}; {xfrm}")), - (Some(value), None) | (None, Some(value)) => Err(value), - (None, None) => Ok(true), + let result = match map_error { + Some(value) => Err(value), + None => Ok(true), }; if result.is_err() { let mut edges = engine.edges.lock().unwrap(); @@ -1216,7 +1212,6 @@ mod tests { rule, attachment: first, local: false, - transport_id: 0x20_0000, }, ); engine diff --git a/members/nullnet-client/src/commands/vxlan.rs b/members/nullnet-client/src/commands/vxlan.rs index 8e46be7..b3cec06 100644 --- a/members/nullnet-client/src/commands/vxlan.rs +++ b/members/nullnet-client/src/commands/vxlan.rs @@ -34,6 +34,7 @@ fn link_group(vxlan_id: u32, client_side: bool) -> u32 { #[derive(Clone, PartialEq, Eq)] pub(crate) struct VxlanSetupParams { pub(crate) vxlan_id: u32, + pub(crate) xfrm_transport_id: u32, pub(crate) ns_name: String, pub(crate) ns_net: Ipv4Network, pub(crate) br_name: String, @@ -254,7 +255,7 @@ async fn create_endpoint( forwarding, outer, params.br_net.ip(), - encrypted.then_some(params.vxlan_id), + encrypted.then_some(params.xfrm_transport_id), ) .await .handle_err(location!())?; @@ -369,11 +370,12 @@ async fn activate(endpoint: Arc, params: &VxlanSetupParams) -> Result< } else { super::native_xfrm::install_port( params.vxlan_id, + params.xfrm_transport_id, 0, - params.local_ip, - params.remote_ip, + (params.local_ip, params.remote_ip), key, params.dstport, + 0, ) .await .handle_err(location!())?; @@ -714,6 +716,7 @@ mod endpoint_packet_tests { let cache = BridgeIpCache::new(); let params = VxlanSetupParams { vxlan_id: 1959100, + xfrm_transport_id: 1959100, ns_name: "ns_1959100_s".into(), ns_net: Ipv4Network::new("10.250.11.1".parse().unwrap(), 29).unwrap(), br_name: "br_1959100_s".into(), @@ -818,6 +821,7 @@ mod endpoint_packet_tests { cache.test_namespace(name.clone(), namespace); endpoints.push(VxlanSetupParams { vxlan_id: id, + xfrm_transport_id: id, ns_name: name.clone(), ns_net: Ipv4Network::new(Ipv4Addr::new(10, 251, subnet, offset), 29).unwrap(), br_name: format!("br_{id}_{side}"), @@ -913,6 +917,7 @@ mod endpoint_packet_tests { } let params = VxlanSetupParams { vxlan_id: id, + xfrm_transport_id: id, ns_name: name.clone(), ns_net: Ipv4Network::new( Ipv4Addr::new(10, 250, case as u8, endpoint_offset), diff --git a/members/nullnet-client/src/commands/vxlan_environment.rs b/members/nullnet-client/src/commands/vxlan_environment.rs index 18832ae..fe2bc0d 100644 --- a/members/nullnet-client/src/commands/vxlan_environment.rs +++ b/members/nullnet-client/src/commands/vxlan_environment.rs @@ -62,6 +62,30 @@ async fn reconcile() -> Result { changed = true; } } + let routed = [ + "-p", + "udp", + "--dport", + "4791", + "-m", + "mark", + "--mark", + "0x80000000/0x80000000", + "-m", + "comment", + "--comment", + "nullnet-policy-xfrm-route", + "-j", + "ACCEPT", + ]; + let present = iptables(&[&["-C", "OUTPUT"][..], &routed].concat()) + .await? + .status + .success(); + if !present { + checked(&[&["-I", "OUTPUT", "1"][..], &routed].concat()).await?; + changed = true; + } Ok(changed) } diff --git a/members/nullnet-client/src/commands/xfrm_shards.rs b/members/nullnet-client/src/commands/xfrm_shards.rs new file mode 100644 index 0000000..f4a7c09 --- /dev/null +++ b/members/nullnet-client/src/commands/xfrm_shards.rs @@ -0,0 +1,217 @@ +//! Fixed XFRM-interface shards for multiplexed policy transports. +use super::netlink::get_link_by_name; +use futures::TryStreamExt; +use rtnetlink::{ + Handle, IpVersion, LinkUnspec, LinkXfrm, RouteMessageBuilder, + packet_route::{ + route::RouteScope, + rule::{RuleAction, RuleAttribute, RuleMessage}, + }, +}; +use std::{array, io, net::Ipv4Addr}; +use tokio::sync::OnceCell; + +pub(super) const COUNT: u32 = 64; +const IF_ID_PREFIX: u32 = 0x4e71_0000; +const TABLE_BASE: u32 = 0x4e00; +const PRIORITY_BASE: u32 = 12_000; +const ROUTE_MARK_PREFIX: u32 = 0x4e80_0000 | super::policy::types::XFRM_ROUTE_BIT; +const OWNED_MARK_MASK: u32 = 0xffc0_0000; +const SHARD_MARK_MASK: u32 = OWNED_MARK_MASK | (COUNT - 1); + +struct Shards { + handle: Handle, + parent: u32, + cells: [OnceCell; COUNT as usize], +} + +static SHARDS: OnceCell = OnceCell::const_new(); + +fn error(value: impl std::fmt::Display) -> io::Error { + io::Error::other(value.to_string()) +} + +fn shard(id: u32) -> u32 { + id & (COUNT - 1) +} + +fn if_id(shard: u32) -> u32 { + IF_ID_PREFIX | (shard + 1) +} + +fn table(shard: u32) -> u32 { + TABLE_BASE + shard +} + +fn mark(shard: u32) -> u32 { + ROUTE_MARK_PREFIX | shard +} + +fn rule( + handle: &Handle, + priority: u32, + mark: u32, + mask: u32, + action: RuleAction, + table: Option, +) -> rtnetlink::RuleAddRequest { + let mut request = handle + .rule() + .add() + .v4() + .priority(priority) + .fw_mark(mark) + .action(action); + request + .message_mut() + .attributes + .push(RuleAttribute::FwMask(mask)); + if let Some(table) = table { + request = request.table_id(table); + } else { + request.message_mut().header.table = 0; + } + request +} + +pub(super) async fn initialize(handle: &Handle, parent_name: &str) -> io::Result<()> { + let parent = get_link_by_name(handle, parent_name) + .await + .map_err(|e| io::Error::other(e.to_str().to_owned()))? + .header + .index; + rule( + handle, + PRIORITY_BASE + COUNT, + ROUTE_MARK_PREFIX, + OWNED_MARK_MASK, + RuleAction::Unreachable, + None, + ) + .execute() + .await + .map_err(error)?; + SHARDS + .set(Shards { + handle: handle.clone(), + parent, + cells: array::from_fn(|_| OnceCell::new()), + }) + .map_err(|_| io::Error::other("XFRM shards are already initialized")) +} + +pub(super) async fn ensure(id: u32) -> io::Result { + let shards = SHARDS + .get() + .ok_or_else(|| io::Error::other("XFRM shards are not initialized"))?; + let shard = shard(id); + shards.cells[shard as usize] + .get_or_try_init(|| async move { + let name = format!("nn-xf-{shard}"); + shards + .handle + .link() + .add( + LinkXfrm::new(&name, shards.parent, if_id(shard)) + .link_group(super::policy::types::GROUP) + .build(), + ) + .execute() + .await + .map_err(error)?; + let index = get_link_by_name(&shards.handle, &name) + .await + .map_err(|e| io::Error::other(e.to_str().to_owned()))? + .header + .index; + let setup = async { + shards + .handle + .link() + .set(LinkUnspec::new_with_index(index).up().build()) + .execute() + .await + .map_err(error)?; + let route = RouteMessageBuilder::::new() + .table_id(table(shard)) + .scope(RouteScope::Link) + .output_interface(index) + .build(); + shards + .handle + .route() + .add(route) + .execute() + .await + .map_err(error)?; + rule( + &shards.handle, + PRIORITY_BASE + shard, + mark(shard), + SHARD_MARK_MASK, + RuleAction::ToTable, + Some(table(shard)), + ) + .execute() + .await + .map_err(error)?; + Ok(if_id(shard)) + } + .await; + if setup.is_err() { + let _ = shards.handle.link().del(index).execute().await; + } + setup + }) + .await + .copied() +} + +fn owned_rule(rule: &RuleMessage) -> bool { + let mut priority = None; + let mut mark = None; + let mut mask = None; + for attribute in &rule.attributes { + match attribute { + RuleAttribute::Priority(value) => priority = Some(*value), + RuleAttribute::FwMark(value) => mark = Some(*value), + RuleAttribute::FwMask(value) => mask = Some(*value), + _ => {} + } + } + priority.is_some_and(|value| (PRIORITY_BASE..=PRIORITY_BASE + COUNT).contains(&value)) + && mark.is_some_and(|value| value & OWNED_MARK_MASK == ROUTE_MARK_PREFIX) + && mask.is_some_and(|value| value == OWNED_MARK_MASK || value == SHARD_MARK_MASK) +} + +pub(super) async fn recover(handle: &Handle) -> Result<(), String> { + let rules: Vec<_> = handle + .rule() + .get(IpVersion::V4) + .execute() + .try_collect() + .await + .map_err(|e| e.to_string())?; + for rule in rules.into_iter().filter(owned_rule) { + handle + .rule() + .del(rule) + .execute() + .await + .map_err(|e| e.to_string())?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ids_distribute_across_fixed_shards() { + assert_eq!(shard(0x20_0000), 0); + assert_eq!(shard(0x20_003f), 63); + assert_eq!(shard(0x20_0040), 0); + assert_ne!(if_id(0), if_id(63)); + } +} diff --git a/members/nullnet-client/src/control_channel.rs b/members/nullnet-client/src/control_channel.rs index 4c78f51..b6c4668 100644 --- a/members/nullnet-client/src/control_channel.rs +++ b/members/nullnet-client/src/control_channel.rs @@ -561,6 +561,7 @@ async fn handle_vxlan_setup( let init_t = std::time::Instant::now(); let vxlan_params = crate::commands::vxlan::VxlanSetupParams { vxlan_id, + xfrm_transport_id: message.xfrm_transport_id.unwrap_or(vxlan_id), ns_name: ns_name.clone(), ns_net, br_name: br_name.clone(), diff --git a/members/nullnet-client/src/main.rs b/members/nullnet-client/src/main.rs index 71df86f..45129c0 100644 --- a/members/nullnet-client/src/main.rs +++ b/members/nullnet-client/src/main.rs @@ -140,6 +140,7 @@ async fn main() -> Result<(), Error> { } if net_type.net() == Net::Vxlan { + commands::initialize_policy_shards(&rtnetlink_handle).await?; commands::vxlan_environment::start(grpc_server.clone()) .await .handle_err(location!())?; diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index 35c5533..23fbdbb 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -222,16 +222,12 @@ message VxlanSetup { // Set on the gateway's edge: forward tunnelled external-bound packets to the // internet (kernel ip_forward + MASQUERADE); the Cilium egress-gateway model. optional bool egress_intercept = 13; - // Per-tunnel AES-256 key (32 raw bytes), generated once by the server and - // sent identically to both endpoints. Used as the XFRM/ESP SA key that - // encrypts this tunnel's traffic at the kernel level. Meaningless - // (placeholder zero bytes) when `encrypted` is false. + // AES-256 key sent identically to both endpoints. Cross-host VXLANs share + // one XFRM/ESP carrier per host pair; same-host MACsec remains per edge. + // Meaningless (placeholder zero bytes) when `encrypted` is false. bytes encryption_key = 14; - // Per-tunnel VXLAN UDP destination port (replaces the IANA-standard 4789 - // default). Each tunnel gets a distinct port so an XFRM policy — which - // selects by src/dst IP and port, not by VNI — can tell concurrent - // tunnels between the same host pair apart. Allocated regardless of - // `encrypted`, for simplicity. + // VXLAN UDP destination port (replaces the IANA-standard 4789 default). + // XFRM policies select by outer host pair, destination port, and mark. uint32 dstport = 15; // Whether this tunnel is encrypted, mirroring the server's ENCRYPTION_ENABLED // env var at the time this edge was set up. When false, the client skips @@ -239,6 +235,8 @@ message VxlanSetup { // SA/policy or MACsec (see nullnet-client's commands/vxlan.rs). bool encrypted = 16; PolicyTransport policy_transport = 17; + // Shared cross-host XFRM carrier. Absent means legacy per-edge transport. + optional uint32 xfrm_transport_id = 18; } message VxlanTeardown { diff --git a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs index 2f63a24..23073dc 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -175,17 +175,13 @@ pub struct VxlanSetup { /// internet (kernel ip_forward + MASQUERADE); the Cilium egress-gateway model. #[prost(bool, optional, tag = "13")] pub egress_intercept: ::core::option::Option, - /// Per-tunnel AES-256 key (32 raw bytes), generated once by the server and - /// sent identically to both endpoints. Used as the XFRM/ESP SA key that - /// encrypts this tunnel's traffic at the kernel level. Meaningless - /// (placeholder zero bytes) when `encrypted` is false. + /// AES-256 key sent identically to both endpoints. Cross-host VXLANs share + /// one XFRM/ESP carrier per host pair; same-host MACsec remains per edge. + /// Meaningless (placeholder zero bytes) when `encrypted` is false. #[prost(bytes = "vec", tag = "14")] pub encryption_key: ::prost::alloc::vec::Vec, - /// Per-tunnel VXLAN UDP destination port (replaces the IANA-standard 4789 - /// default). Each tunnel gets a distinct port so an XFRM policy — which - /// selects by src/dst IP and port, not by VNI — can tell concurrent - /// tunnels between the same host pair apart. Allocated regardless of - /// `encrypted`, for simplicity. + /// VXLAN UDP destination port (replaces the IANA-standard 4789 default). + /// XFRM policies select by outer host pair, destination port, and mark. #[prost(uint32, tag = "15")] pub dstport: u32, /// Whether this tunnel is encrypted, mirroring the server's ENCRYPTION_ENABLED @@ -196,6 +192,9 @@ pub struct VxlanSetup { pub encrypted: bool, #[prost(message, optional, tag = "17")] pub policy_transport: ::core::option::Option, + /// Shared cross-host XFRM carrier. Absent means legacy per-edge transport. + #[prost(uint32, optional, tag = "18")] + pub xfrm_transport_id: ::core::option::Option, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct VxlanTeardown { diff --git a/members/nullnet-server/src/net.rs b/members/nullnet-server/src/net.rs index 97bf2d1..a8b1c85 100644 --- a/members/nullnet-server/src/net.rs +++ b/members/nullnet-server/src/net.rs @@ -34,6 +34,7 @@ pub(crate) trait NetExt { docker_containers: (Option, Option), dnat_port: Option, encryption_key: [u8; 32], + xfrm_transport_id: Option, dstport: Option, encrypted: bool, egress: EgressRole, @@ -65,6 +66,7 @@ impl NetExt for Net { docker_containers: (Option, Option), dnat_port: Option, encryption_key: [u8; 32], + xfrm_transport_id: Option, dstport: Option, encrypted: bool, egress: EgressRole, @@ -89,6 +91,7 @@ impl NetExt for Net { docker_containers, dnat_port, encryption_key, + xfrm_transport_id, dstport.unwrap_or(u32::from(DEFAULT_VXLAN_DSTPORT)), encrypted, egress, @@ -192,6 +195,7 @@ fn vxlan_setup( docker_containers: (Option, Option), dnat_port: Option, encryption_key: [u8; 32], + xfrm_transport_id: Option, dstport: u32, encrypted: bool, egress: EgressRole, @@ -277,6 +281,7 @@ fn vxlan_setup( egress_intercept, encrypted, policy_transport, + xfrm_transport_id, })), }, )) @@ -328,6 +333,7 @@ mod tests { (None, None), None, key, + None, 4789, false, EgressRole::None, @@ -343,6 +349,7 @@ mod tests { (None, None), None, key, + None, 4789, false, EgressRole::None, diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index b134635..e5ad7a8 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -2496,18 +2496,25 @@ async fn setup_edge( } let encrypted = *ENCRYPTION_ENABLED; - let policy_transport = policy.then(|| orchestrator.policy_transport(net_id)); + let policy_transport = + policy.then(|| orchestrator.policy_transport(client_ethernet, server_ethernet)); + let xfrm_transport = + (!policy && encrypted && *NET_TYPE == Net::Vxlan && client_ethernet != server_ethernet) + .then(|| orchestrator.xfrm_transport(client_ethernet, server_ethernet, false)); let encryption_key = if let Some(transport) = &policy_transport { transport .encryption_key .as_slice() .try_into() .expect("policy keys are generated as 32 bytes") + } else if let Some((_, key)) = xfrm_transport { + key } else if encrypted { generate_key() } else { [0u8; 32] }; + let xfrm_transport_id = xfrm_transport.map(|(id, _)| id); let dstport = None; let server_policy = policy_transport.clone().map(|mut transport| { transport.peer_container = client_docker.is_some(); @@ -2529,6 +2536,7 @@ async fn setup_edge( (cd, sd), None, encryption_key, + xfrm_transport_id, dstport, encrypted, server_egress, @@ -2545,6 +2553,7 @@ async fn setup_edge( (cd, sd), backend_entry_port, encryption_key, + xfrm_transport_id, dstport, encrypted, client_egress, diff --git a/members/nullnet-server/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index 7b70efe..875407d 100644 --- a/members/nullnet-server/src/orchestrator.rs +++ b/members/nullnet-server/src/orchestrator.rs @@ -8,20 +8,21 @@ use crate::services::changes::{apply_changes, detect_node_disconnect_changes}; use crate::services::input::StackMap; use crate::sessions::SessionStore; use nullnet_grpc_lib::nullnet_grpc::{ - ContainerResume, ContainerSuspend, EgressPolicyChanged, MsgId, NetMessage, NetReady, + ContainerResume, ContainerSuspend, EgressPolicyChanged, MsgId, Net, NetMessage, NetReady, PolicyHeartbeat, PolicyTransport, net_message, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::collections::HashMap; use std::net::{IpAddr, Ipv4Addr}; -use std::sync::Arc; -use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicU32, AtomicU64, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::{Duration, Instant}; use tokio::sync::{Mutex, RwLock, mpsc, oneshot}; use tonic::{Request, Status, Streaming}; use uuid::Uuid; type OutboundStream = mpsc::Sender>; +type XfrmTransportMap = HashMap<(IpAddr, IpAddr, bool), (u32, [u8; 32])>; /// Initiator replica identity keying an egress edge: (node IP, docker container). /// One edge per initiator replica multiplexes all of its external destinations. @@ -162,6 +163,8 @@ pub struct Orchestrator { /// their acks are still outstanding. Lets tests wait for the pool to reach /// steady state instead of racing the detached free task. inflight_teardowns: Arc, + xfrm_transports: Arc>, + xfrm_transport_id: Arc, policy_generation: Arc, policy_epoch: Arc<[u8; 16]>, pub(crate) events: EventStore, @@ -180,6 +183,8 @@ impl Orchestrator { backend_sessions: Arc::new(RwLock::new(HashMap::new())), geo: GeoCache::from_env(), inflight_teardowns: Arc::new(AtomicUsize::new(0)), + xfrm_transports: Arc::new(StdMutex::new(HashMap::new())), + xfrm_transport_id: Arc::new(AtomicU32::new(0x20_0000)), policy_generation: Arc::new(AtomicU64::new(1)), policy_epoch: Arc::new(*Uuid::new_v4().as_bytes()), sessions: SessionStore::with_events(events.clone()), @@ -343,7 +348,12 @@ impl Orchestrator { }; let encrypted = *ENCRYPTION_ENABLED; - let encryption_key = if encrypted { generate_key() } else { [0u8; 32] }; + let xfrm_transport = (encrypted && *NET_TYPE == Net::Vxlan && initiator_ip != proxy_ip) + .then(|| self.xfrm_transport(initiator_ip, proxy_ip, false)); + let encryption_key = xfrm_transport + .map(|(_, key)| key) + .unwrap_or_else(|| if encrypted { generate_key() } else { [0u8; 32] }); + let xfrm_transport_id = xfrm_transport.map(|(id, _)| id); let dstport = None; // Gateway is the server side (Intercept -> forward/MASQUERADE); initiator @@ -357,6 +367,7 @@ impl Orchestrator { dockers.clone(), None, encryption_key, + xfrm_transport_id, dstport, encrypted, EgressRole::Intercept, @@ -370,6 +381,7 @@ impl Orchestrator { dockers, None, encryption_key, + xfrm_transport_id, dstport, encrypted, EgressRole::Steer, @@ -982,6 +994,7 @@ impl Orchestrator { docker_containers: (Option, Option), dnat_port: Option, encryption_key: [u8; 32], + xfrm_transport_id: Option, dstport: Option, encrypted: bool, egress: EgressRole, @@ -997,6 +1010,7 @@ impl Orchestrator { docker_containers, dnat_port, encryption_key, + xfrm_transport_id, dstport, encrypted, egress, @@ -1111,10 +1125,35 @@ impl Orchestrator { self.net_id_pool.lock().await.allocate() } - pub(crate) fn policy_transport(&self, net_id: u32) -> PolicyTransport { + pub(crate) fn xfrm_transport( + &self, + first: IpAddr, + second: IpAddr, + policy: bool, + ) -> (u32, [u8; 32]) { + let hosts = if first < second { + (first, second, policy) + } else { + (second, first, policy) + }; + *self + .xfrm_transports + .lock() + .unwrap() + .entry(hosts) + .or_insert_with(|| { + ( + self.xfrm_transport_id.fetch_add(1, Ordering::Relaxed), + generate_key(), + ) + }) + } + + pub(crate) fn policy_transport(&self, first: IpAddr, second: IpAddr) -> PolicyTransport { + let (id, encryption_key) = self.xfrm_transport(first, second, true); PolicyTransport { - id: 0x20_0000 + net_id, - encryption_key: generate_key().to_vec(), + id, + encryption_key: encryption_key.to_vec(), peer_container: false, generation: self.policy_generation.fetch_add(1, Ordering::Relaxed), } @@ -1591,17 +1630,27 @@ mod teardown_ack_tests { mod policy_group_tests { use super::*; + fn ip(a: u8, b: u8, c: u8, d: u8) -> IpAddr { + IpAddr::V4(Ipv4Addr::new(a, b, c, d)) + } + #[test] - fn every_overflow_edge_gets_a_distinct_group_and_key() { + fn overflow_edges_share_host_pair_transport_but_not_generation() { let orchestrator = Orchestrator::new(); - let first = orchestrator.policy_transport(101); - let second = orchestrator.policy_transport(102); - - assert_eq!(first.id, 0x20_0000 + 101); - assert_eq!(second.id, 0x20_0000 + 102); - assert_ne!(first.id, second.id); - assert_ne!(first.encryption_key, second.encryption_key); + let host_a = ip(192, 0, 2, 1); + let host_b = ip(192, 0, 2, 2); + let host_c = ip(192, 0, 2, 3); + let first = orchestrator.policy_transport(host_a, host_b); + let second = orchestrator.policy_transport(host_b, host_a); + let other = orchestrator.policy_transport(host_a, host_c); + let dedicated = orchestrator.xfrm_transport(host_a, host_b, false); + + assert_eq!(first.id, second.id); + assert_eq!(first.encryption_key, second.encryption_key); assert_ne!(first.generation, second.generation); + assert_ne!(first.id, other.id); + assert_ne!(first.encryption_key, other.encryption_key); + assert_ne!(first.id, dedicated.0); } } diff --git a/members/nullnet-server/src/tests.rs b/members/nullnet-server/src/tests.rs index 53bd926..c561792 100644 --- a/members/nullnet-server/src/tests.rs +++ b/members/nullnet-server/src/tests.rs @@ -1782,13 +1782,13 @@ async fn multi_replica_first_step_container_disconnect() { // =========================================================================== // max_networks: A→B, max_networks=1, timeout=1. -// The first ingress edge is dedicated; later edges use unique policy groups. +// The first ingress edges are dedicated; later edges share a host-pair transport. // =========================================================================== const MAX_NETWORKS: &str = "max_networks"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn max_networks_assigns_one_policy_group_per_concurrent_overflow() { +async fn max_networks_shares_transport_across_concurrent_overflow() { let config: ServicesToml = toml::from_str( r#"[[services]] name = "A" @@ -1862,7 +1862,7 @@ proxy_dependencies = [["B"]] .map(|group| group.id) .collect::>() .len(), - 190 + 1 ); assert_eq!( groups @@ -1870,6 +1870,14 @@ proxy_dependencies = [["B"]] .map(|group| group.encryption_key.clone()) .collect::>() .len(), + 1 + ); + assert_eq!( + groups + .iter() + .map(|group| group.generation) + .collect::>() + .len(), 190 ); drop(messages); diff --git a/shared/policy.rs b/shared/policy.rs index 0056e55..89730bc 100644 --- a/shared/policy.rs +++ b/shared/policy.rs @@ -3,6 +3,7 @@ pub const APP_IP: u32 = 0xa9fe_f002; pub const GATEWAY_IP: u32 = 0xa9fe_f001; pub const POLICY_PORT: u16 = 4791; pub const GROUP: u32 = 0x4e70_0000; +pub const XFRM_ROUTE_BIT: u32 = 0x8000_0000; pub const MAX_GENERATION: u64 = (1 << 40) - 1; #[repr(C)] From 5260ea5c3cc5843b7e3513f1183ba8290d321bcb Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Tue, 20 Oct 2026 18:12:18 +0200 Subject: [PATCH 6/9] fix: require live probes for congestion recovery --- members/nullnet-server/src/congestion.rs | 93 ++++++++++++++++-------- 1 file changed, 63 insertions(+), 30 deletions(-) diff --git a/members/nullnet-server/src/congestion.rs b/members/nullnet-server/src/congestion.rs index bd9c70f..cd19573 100644 --- a/members/nullnet-server/src/congestion.rs +++ b/members/nullnet-server/src/congestion.rs @@ -4,7 +4,7 @@ use std::sync::Mutex; use std::time::{Duration, Instant}; const PRESSURE_WINDOW: Duration = Duration::from_secs(1); -const COMPLETION_HISTORY: Duration = Duration::from_secs(5); +const COMPLETION_HISTORY: Duration = Duration::from_secs(10); const RECOVERY_QUIET: Duration = Duration::from_secs(5); const CONGESTED_WINDOWS: u8 = 2; const RECOVERY_WINDOWS: u8 = 2; @@ -128,7 +128,6 @@ impl ProxyPressure { struct HostRate { rate: Option, - additive_step: f64, tokens: f64, last_refill: Instant, last_denied: Option, @@ -152,7 +151,6 @@ impl HostRate { fn new(now: Instant) -> Self { Self { rate: None, - additive_step: 1.0, tokens: 0.0, last_refill: now, last_denied: None, @@ -172,16 +170,32 @@ impl HostRate { } } - fn completion_rate(&mut self, now: Instant) -> f64 { + fn record_completion(&mut self, now: Instant) { self.trim_completions(now); - let elapsed = self + if self .completions - .front() - .map_or(Duration::from_secs(1), |first| { - now.duration_since(*first).max(Duration::from_secs(1)) - }) - .min(COMPLETION_HISTORY); - self.completions.len() as f64 / elapsed.as_secs_f64() + .back() + .is_some_and(|last| now.duration_since(*last) > RECOVERY_QUIET) + { + self.completions.clear(); + } + self.completions.push_back(now); + } + + fn sustained_completions(&mut self, now: Instant) -> bool { + self.trim_completions(now); + self.completions.front().is_some_and(|first| { + now.duration_since(*first) >= RECOVERY_QUIET + && self + .completions + .back() + .is_some_and(|last| now.duration_since(*last) <= RECOVERY_QUIET) + }) + } + + fn denied(&mut self, now: Instant) { + self.last_denied = Some(now); + self.completions.clear(); } fn apply( @@ -204,31 +218,28 @@ impl HostRate { match pressure.trend { Trend::Congested => { let activating = self.rate.is_none(); - if activating { - let measured = self.completion_rate(now); - self.additive_step = (measured * 0.05).max(1.0); - } self.rate = Some(0.0); self.tokens = 0.0; self.last_refill = now; - self.last_denied = Some(now); + self.denied(now); self.healthy_windows = 0; if activating { return Some(RateTransition::Activated { host }); } } Trend::Healthy => { - if let Some(rate) = &mut self.rate { + if self.rate.is_some() { self.healthy_windows = self.healthy_windows.saturating_add(1); if self.healthy_windows < RECOVERY_WINDOWS { return None; } - *rate += self.additive_step; - if self + *self.rate.as_mut().expect("rate is active") += 1.0; + let recovered = self .last_denied .is_some_and(|last| now.duration_since(last) >= RECOVERY_QUIET) && self.tokens >= BUCKET_BURST - { + && self.sustained_completions(now); + if recovered { self.rate = None; self.tokens = 0.0; return Some(RateTransition::Recovered { host }); @@ -330,7 +341,7 @@ impl CongestionController { for host in &hosts[..host_count] { let rate = state.hosts.get_mut(host).expect("host rate exists"); if rate.rate.is_some() && rate.tokens < 1.0 { - rate.last_denied = Some(now); + rate.denied(now); } } } @@ -359,8 +370,7 @@ impl CongestionController { .hosts .entry(host) .or_insert_with(|| HostRate::new(now)); - rate.completions.push_back(now); - rate.trim_completions(now); + rate.record_completion(now); if proxy == destination { break; } @@ -562,10 +572,6 @@ mod tests { fn congestion_pauses_dedicated_setups_then_additively_probes() { let start = Instant::now(); let mut rate = HostRate::new(start); - for i in 0..100 { - rate.completions - .push_back(start + Duration::from_millis(i * 10)); - } let proxy = ip(1); assert_eq!( rate.apply( @@ -627,7 +633,7 @@ mod tests { }, start + Duration::from_secs(6), ); - assert_eq!(rate.rate, Some(5.0)); + assert_eq!(rate.rate, Some(1.0)); rate.apply( ip(2), proxy, @@ -656,7 +662,7 @@ mod tests { }, start + Duration::from_secs(9), ); - assert_eq!(rate.rate, Some(5.0)); + assert_eq!(rate.rate, Some(1.0)); rate.tokens = 1.0; rate.last_refill = start + Duration::from_secs(12); assert_eq!( @@ -671,8 +677,10 @@ mod tests { ), None ); - assert_eq!(rate.rate, Some(10.0)); + assert_eq!(rate.rate, Some(2.0)); rate.tokens = BUCKET_BURST; + rate.record_completion(start + Duration::from_secs(8)); + rate.record_completion(start + Duration::from_secs(13)); assert_eq!( rate.apply( ip(2), @@ -687,4 +695,29 @@ mod tests { ); assert_eq!(rate.rate, None); } + + #[test] + fn idle_time_does_not_remove_the_probe_limit() { + let start = Instant::now(); + let mut rate = HostRate::new(start); + rate.rate = Some(1.0); + rate.tokens = BUCKET_BURST; + rate.last_denied = Some(start); + rate.healthy_windows = RECOVERY_WINDOWS - 1; + rate.record_completion(start + Duration::from_secs(1)); + + assert_eq!( + rate.apply( + ip(2), + ip(1), + PressureView { + sequence: 1, + trend: Trend::Healthy, + }, + start + Duration::from_secs(15), + ), + None + ); + assert_eq!(rate.rate, Some(2.0)); + } } From 8a7ed958cd878fb938b95ab8c81a72395d670713 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Tue, 20 Oct 2026 18:12:21 +0200 Subject: [PATCH 7/9] docs: describe adaptive transport multiplexing --- CHANGELOG.md | 4 ++-- SETUP.md | 13 +++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8afe534..5f6164b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,8 @@ All Nullnet releases with the relative changes are documented in this file. - Per-service egress/ingress traffic filters: arbitrary AND/OR/group combinations of Country, Organization, Src IP (ingress), and Dst IP (egress) conditions, evaluated via `rpn-predicate-interpreter` — replaces the country-only egress/ingress policy ([#171](https://github.com/NullNet-ai/nullnet/pull/171) — fixes [#143](https://github.com/NullNet-ai/nullnet/issues/143)) - Persist ingress and egress sessions to SQLite and show the full history on the Sessions page, filterable by status, service, direction, and policy verdict, with its own retention window ([#170](https://github.com/NullNet-ai/nullnet/pull/170) — fixes [#156](https://github.com/NullNet-ai/nullnet/issues/156)) ### Changed -- Adapt encrypted ingress multiplexing, with or without a configured hard cap, to sustained RPC admission congestion by pausing dedicated setups and additively probing recovery per host. -- Multiplex encrypted ingress overflow by client-IP policy group, with a unique key per group and direct same-host isolation. +- Adapt encrypted ingress multiplexing, with or without a configured hard cap, to sustained RPC admission congestion by pausing dedicated setups and conservatively probing verified recovery per host. +- Multiplex encrypted ingress overflow with client-IP policy isolation, persistent host-pair IPsec transports, and direct same-host forwarding. - Keep routine successful proxy requests out of Events, isolate diagnostic event RPC admission from routing, and group event filters by scope ([#204](https://github.com/NullNet-ai/nullnet/pull/204)). - Reduce fresh-edge cleanup work, batch conntrack scans, and share TC classifier infrastructure while preserving dedicated encrypted endpoints ([#201](https://github.com/NullNet-ai/nullnet/pull/201)). - Keep active backend chains visible to conntrack so liveness tracking does not release endpoints that still carry traffic ([#201](https://github.com/NullNet-ai/nullnet/pull/201)). diff --git a/SETUP.md b/SETUP.md index edfb523..ac6bb24 100644 --- a/SETUP.md +++ b/SETUP.md @@ -259,12 +259,13 @@ Run the setup scripts as root. The supplied systemd services also run as root; p - `max_networks` is an optional hard cap on dedicated ingress networks for a proxy-reachable service. Independently of that setting, encrypted VXLAN ingress switches new clients to multiplexing when the proxy's RPC backlog and slot-wait time grow for consecutive windows. A - host-scoped controller first pauses dedicated setups, then additively admits probes while the RPC - path recovers; renewed congestion immediately returns the rate to zero. Stable queues do not - activate it, and the limit is removed after sustained recovery. Multiplexed clients get one policy - group and encryption key per client IP over a shared VXLAN, while repeat connections reuse that - group. Same-host multiplexing uses the same isolation policy with direct eBPF redirection and no - VXLAN hairpin. + host-scoped controller first pauses dedicated setups, then additively admits one more probe per + healthy window; renewed congestion immediately returns the rate to zero. Stable queues do not + activate it, and only sustained successful probes under traffic remove the limit (idle time does + not count as recovery). Multiplexed clients get one policy + generation per client IP over a shared VXLAN. Cross-host groups share a persistent host-pair IPsec + transport; packet metadata and endpoint maps preserve per-client isolation. Same-host multiplexing + uses the same isolation policy with direct eBPF redirection and no VXLAN hairpin. - `proxy_dependencies` is a list of independent dep chains walked when the service is reached via a `Proxy` RPC from nullnet-proxy; each inner array is one linear branch and all branches are brought up in parallel From 4239dc2c8bc037ee6cf4b9876f0c568ee5c0b49e Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 21 Oct 2026 13:19:26 +0200 Subject: [PATCH 8/9] feat: show ingress congestion status on nodes --- CHANGELOG.md | 5 +- members/nullnet-server/src/congestion.rs | 256 ++++++++++++++++-- members/nullnet-server/src/events.rs | 13 + members/nullnet-server/src/http_server/mod.rs | 2 + .../nullnet-server/src/http_server/nodes.rs | 149 ++++++++++ .../src/http_server/observation.rs | 5 +- members/nullnet-server/src/main.rs | 1 + .../nullnet-server/src/nullnet_grpc_impl.rs | 14 + members/nullnet-server/ui/src/index.css | 47 +++- .../nullnet-server/ui/src/lib/eventKinds.ts | 1 + .../nullnet-server/ui/src/pages/Events.tsx | 2 + members/nullnet-server/ui/src/pages/Nodes.tsx | 241 ++++++++++++----- members/nullnet-server/ui/src/types.ts | 37 +++ 13 files changed, 687 insertions(+), 86 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6164b..a1a5532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,9 @@ All Nullnet releases with the relative changes are documented in this file. - Per-service egress/ingress traffic filters: arbitrary AND/OR/group combinations of Country, Organization, Src IP (ingress), and Dst IP (egress) conditions, evaluated via `rpn-predicate-interpreter` — replaces the country-only egress/ingress policy ([#171](https://github.com/NullNet-ai/nullnet/pull/171) — fixes [#143](https://github.com/NullNet-ai/nullnet/issues/143)) - Persist ingress and egress sessions to SQLite and show the full history on the Sessions page, filterable by status, service, direction, and policy verdict, with its own retention window ([#170](https://github.com/NullNet-ai/nullnet/pull/170) — fixes [#156](https://github.com/NullNet-ai/nullnet/issues/156)) ### Changed -- Adapt encrypted ingress multiplexing, with or without a configured hard cap, to sustained RPC admission congestion by pausing dedicated setups and conservatively probing verified recovery per host. -- Multiplex encrypted ingress overflow with client-IP policy isolation, persistent host-pair IPsec transports, and direct same-host forwarding. +- Show bounded live and recent ingress pressure, congestion-control state, and active ingress groups on the Nodes page ([#206](https://github.com/NullNet-ai/nullnet/pull/206)). +- Adapt encrypted ingress multiplexing, with or without a configured hard cap, to sustained RPC admission congestion by pausing dedicated setups and conservatively probing verified recovery per host ([#206](https://github.com/NullNet-ai/nullnet/pull/206)). +- Multiplex encrypted ingress overflow with client-IP policy isolation, persistent host-pair IPsec transports, and direct same-host forwarding ([#206](https://github.com/NullNet-ai/nullnet/pull/206)). - Keep routine successful proxy requests out of Events, isolate diagnostic event RPC admission from routing, and group event filters by scope ([#204](https://github.com/NullNet-ai/nullnet/pull/204)). - Reduce fresh-edge cleanup work, batch conntrack scans, and share TC classifier infrastructure while preserving dedicated encrypted endpoints ([#201](https://github.com/NullNet-ai/nullnet/pull/201)). - Keep active backend chains visible to conntrack so liveness tracking does not release endpoints that still carry traffic ([#201](https://github.com/NullNet-ai/nullnet/pull/201)). diff --git a/members/nullnet-server/src/congestion.rs b/members/nullnet-server/src/congestion.rs index cd19573..b3f410d 100644 --- a/members/nullnet-server/src/congestion.rs +++ b/members/nullnet-server/src/congestion.rs @@ -9,6 +9,8 @@ const RECOVERY_QUIET: Duration = Duration::from_secs(5); const CONGESTED_WINDOWS: u8 = 2; const RECOVERY_WINDOWS: u8 = 2; const BUCKET_BURST: f64 = 32.0; +const PRESSURE_HISTORY: usize = 60; +const TRANSITION_HISTORY: usize = 20; #[derive(Clone, Copy, Debug)] pub(crate) struct ProxyLoad { @@ -26,6 +28,67 @@ enum Trend { Neutral, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum PressureTrend { + Growing, + Healthy, + Neutral, +} + +#[derive(Clone, Debug)] +pub(crate) struct PressureWindowSnapshot { + pub(crate) age: Duration, + pub(crate) duration: Duration, + pub(crate) arrivals: u64, + pub(crate) completions: u64, + pub(crate) waiters: u32, + pub(crate) mean_wait_us: u64, + pub(crate) trend: PressureTrend, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ControlMode { + Normal, + Multiplexing, + Recovery, +} + +#[derive(Clone, Debug)] +pub(crate) struct ControlTransitionSnapshot { + pub(crate) mode: ControlMode, + pub(crate) age: Duration, + pub(crate) source_proxy: IpAddr, +} + +#[derive(Clone, Debug)] +pub(crate) struct HostStatusSnapshot { + pub(crate) mode: ControlMode, + pub(crate) state_age: Option, + pub(crate) rate_per_sec: Option, + pub(crate) tokens: Option, + pub(crate) last_token_shortage_age: Option, + pub(crate) source_proxy: Option, + pub(crate) transitions: Vec, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct NodeCongestionSnapshot { + pub(crate) pressure_age: Option, + pub(crate) windows: Vec, + pub(crate) control: Option, +} + +#[derive(Clone, Copy)] +struct PressureWindow { + ended_at: Instant, + duration: Duration, + arrivals: u64, + completions: u64, + waiters: u32, + mean_wait_us: u64, + trend: PressureTrend, +} + #[derive(Clone, Copy)] struct PressureView { sequence: u64, @@ -42,6 +105,8 @@ struct ProxyPressure { congested_windows: u8, sequence: u64, trend: Trend, + last_observed: Instant, + history: VecDeque, } impl ProxyPressure { @@ -56,13 +121,18 @@ impl ProxyPressure { congested_windows: 0, sequence: 0, trend: Trend::Neutral, + last_observed: now, + history: VecDeque::with_capacity(PRESSURE_HISTORY), } } fn reset(&mut self, now: Instant, sample: ProxyLoad) { let sequence = self.sequence.saturating_add(1); + let history = + std::mem::replace(&mut self.history, VecDeque::with_capacity(PRESSURE_HISTORY)); *self = Self::new(now, sample); self.sequence = sequence; + self.history = history; } fn observe(&mut self, now: Instant, sample: ProxyLoad) { @@ -74,6 +144,7 @@ impl ProxyPressure { return; } self.last = sample; + self.last_observed = now; self.wait_sum += u128::from(sample.wait_us); self.samples += 1; if now.duration_since(self.window_started) < PRESSURE_WINDOW { @@ -94,6 +165,14 @@ impl ProxyPressure { let healthy = arrivals <= completions && self.last.waiters <= self.start.waiters && wait_not_rising; + let display_trend = if growing { + PressureTrend::Growing + } else if healthy { + PressureTrend::Healthy + } else { + PressureTrend::Neutral + }; + self.trend = if growing { self.congested_windows = self.congested_windows.saturating_add(1); if self.congested_windows >= CONGESTED_WINDOWS { @@ -110,6 +189,18 @@ impl ProxyPressure { Trend::Neutral }; self.sequence += 1; + if self.history.len() == PRESSURE_HISTORY { + self.history.pop_front(); + } + self.history.push_back(PressureWindow { + ended_at: now, + duration: now.duration_since(self.window_started), + arrivals, + completions, + waiters: self.last.waiters, + mean_wait_us: wait_mean, + trend: display_trend, + }); self.previous_wait_mean = Some(wait_mean); self.window_started = now; self.start = sample; @@ -126,6 +217,13 @@ impl ProxyPressure { } } +#[derive(Clone, Copy)] +struct ControlTransition { + at: Instant, + mode: ControlMode, + source_proxy: IpAddr, +} + struct HostRate { rate: Option, tokens: f64, @@ -134,11 +232,15 @@ struct HostRate { completions: VecDeque, pressure_sequences: HashMap, healthy_windows: u8, + state_since: Option, + source_proxy: Option, + transitions: VecDeque, } #[derive(Debug, PartialEq)] pub(crate) enum RateTransition { Activated { host: IpAddr }, + RecoveryStarted { host: IpAddr }, Recovered { host: IpAddr }, } @@ -157,7 +259,31 @@ impl HostRate { completions: VecDeque::new(), pressure_sequences: HashMap::new(), healthy_windows: 0, + state_since: None, + source_proxy: None, + transitions: VecDeque::with_capacity(TRANSITION_HISTORY), + } + } + + fn mode(&self) -> ControlMode { + match self.rate { + None => ControlMode::Normal, + Some(0.0) => ControlMode::Multiplexing, + Some(_) => ControlMode::Recovery, + } + } + + fn transition(&mut self, mode: ControlMode, proxy: IpAddr, now: Instant) { + self.state_since = Some(now); + self.source_proxy = (mode != ControlMode::Normal).then_some(proxy); + if self.transitions.len() == TRANSITION_HISTORY { + self.transitions.pop_front(); } + self.transitions.push_back(ControlTransition { + at: now, + mode, + source_proxy: proxy, + }); } fn trim_completions(&mut self, now: Instant) { @@ -217,13 +343,14 @@ impl HostRate { self.pressure_sequences.insert(proxy, pressure.sequence); match pressure.trend { Trend::Congested => { - let activating = self.rate.is_none(); + let activating = self.rate != Some(0.0); self.rate = Some(0.0); self.tokens = 0.0; self.last_refill = now; self.denied(now); self.healthy_windows = 0; if activating { + self.transition(ControlMode::Multiplexing, proxy, now); return Some(RateTransition::Activated { host }); } } @@ -233,6 +360,7 @@ impl HostRate { if self.healthy_windows < RECOVERY_WINDOWS { return None; } + let recovery_starting = self.rate == Some(0.0); *self.rate.as_mut().expect("rate is active") += 1.0; let recovered = self .last_denied @@ -242,8 +370,13 @@ impl HostRate { if recovered { self.rate = None; self.tokens = 0.0; + self.transition(ControlMode::Normal, proxy, now); return Some(RateTransition::Recovered { host }); } + if recovery_starting { + self.transition(ControlMode::Recovery, proxy, now); + return Some(RateTransition::RecoveryStarted { host }); + } } } Trend::Neutral => { @@ -376,6 +509,70 @@ impl CongestionController { } } } + + pub(crate) fn snapshots( + &self, + nodes: impl IntoIterator, + ) -> HashMap { + let now = Instant::now(); + let state = self + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + nodes + .into_iter() + .map(|node| { + let pressure = state.proxies.get(&node); + let windows = pressure + .into_iter() + .flat_map(|pressure| pressure.history.iter()) + .map(|window| PressureWindowSnapshot { + age: now.duration_since(window.ended_at), + duration: window.duration, + arrivals: window.arrivals, + completions: window.completions, + waiters: window.waiters, + mean_wait_us: window.mean_wait_us, + trend: window.trend, + }) + .collect(); + let control = state.hosts.get(&node).map(|host| { + let rate = host.rate; + let tokens = rate.map(|rate| { + (host.tokens + now.duration_since(host.last_refill).as_secs_f64() * rate) + .min(BUCKET_BURST) + }); + HostStatusSnapshot { + mode: host.mode(), + state_age: host.state_since.map(|at| now.duration_since(at)), + rate_per_sec: rate, + tokens, + last_token_shortage_age: host.last_denied.map(|at| now.duration_since(at)), + source_proxy: host.source_proxy, + transitions: host + .transitions + .iter() + .rev() + .map(|transition| ControlTransitionSnapshot { + mode: transition.mode, + age: now.duration_since(transition.at), + source_proxy: transition.source_proxy, + }) + .collect(), + } + }); + ( + node, + NodeCongestionSnapshot { + pressure_age: pressure + .map(|pressure| now.duration_since(pressure.last_observed)), + windows, + control, + }, + ) + }) + .collect() + } } #[cfg(test)] @@ -425,6 +622,25 @@ mod tests { ); } + #[test] + fn pressure_history_is_bounded_and_survives_proxy_epoch_changes() { + let start = Instant::now(); + let mut pressure = ProxyPressure::new(start, sample(0, 0, 0, 0)); + for second in 1..=65 { + pressure.observe( + start + Duration::from_secs(second), + sample(second * 10, second * 10, 0, 100), + ); + } + assert_eq!(pressure.history.len(), PRESSURE_HISTORY); + assert_eq!(pressure.history.front().unwrap().arrivals, 10); + + let mut reset = sample(1, 1, 0, 100); + reset.epoch = 2; + pressure.observe(start + Duration::from_secs(66), reset); + assert_eq!(pressure.history.len(), PRESSURE_HISTORY); + } + #[test] fn growing_backlog_activates_rate_control() { let controller = CongestionController::default(); @@ -624,24 +840,30 @@ mod tests { start + Duration::from_secs(5), ); assert_eq!(rate.rate, Some(0.0)); - rate.apply( - ip(2), - proxy, - PressureView { - sequence: 6, - trend: Trend::Healthy, - }, - start + Duration::from_secs(6), + assert_eq!( + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 6, + trend: Trend::Healthy, + }, + start + Duration::from_secs(6), + ), + Some(RateTransition::RecoveryStarted { host: ip(2) }) ); assert_eq!(rate.rate, Some(1.0)); - rate.apply( - ip(2), - proxy, - PressureView { - sequence: 7, - trend: Trend::Congested, - }, - start + Duration::from_secs(7), + assert_eq!( + rate.apply( + ip(2), + proxy, + PressureView { + sequence: 7, + trend: Trend::Congested, + }, + start + Duration::from_secs(7), + ), + Some(RateTransition::Activated { host: ip(2) }) ); assert_eq!(rate.rate, Some(0.0)); rate.apply( diff --git a/members/nullnet-server/src/events.rs b/members/nullnet-server/src/events.rs index eab3978..0150bdc 100644 --- a/members/nullnet-server/src/events.rs +++ b/members/nullnet-server/src/events.rs @@ -238,6 +238,10 @@ pub(crate) enum Event { active: bool, timestamp: u64, }, + IngressCongestionRecoveryStarted { + node_ip: String, + timestamp: u64, + }, NetIdPoolExhausted { service: String, client_ip: String, @@ -569,6 +573,7 @@ impl Event { Self::StaleSessionEvicted { .. } => "stale_session_evicted", Self::MaxNetworksLimitEnforced { .. } => "max_networks_limit_enforced", Self::IngressCongestionControlChanged { .. } => "ingress_congestion_control_changed", + Self::IngressCongestionRecoveryStarted { .. } => "ingress_congestion_recovery_started", Self::NetIdPoolExhausted { .. } => "net_id_pool_exhausted", Self::ProxyChainSetupFailed { .. } => "proxy_chain_setup_failed", Self::BackendTriggerSetupBailed { .. } => "backend_trigger_setup_bailed", @@ -636,6 +641,7 @@ impl Event { } => Severity::Warning, Self::IngressCongestionControlChanged { active: true, .. } => Severity::Warning, Self::IngressCongestionControlChanged { active: false, .. } => Severity::Info, + Self::IngressCongestionRecoveryStarted { .. } => Severity::Info, Self::UiTlsCertificateUnavailable { using_self_signed, .. } => { @@ -973,6 +979,13 @@ impl Event { } } + pub(crate) fn ingress_congestion_recovery_started(node_ip: String) -> Self { + Self::IngressCongestionRecoveryStarted { + node_ip, + timestamp: now_secs(), + } + } + pub(crate) fn net_id_pool_exhausted(service: String, client_ip: String) -> Self { Self::NetIdPoolExhausted { service, diff --git a/members/nullnet-server/src/http_server/mod.rs b/members/nullnet-server/src/http_server/mod.rs index 420f3ca..8bc3113 100644 --- a/members/nullnet-server/src/http_server/mod.rs +++ b/members/nullnet-server/src/http_server/mod.rs @@ -1,3 +1,4 @@ +use crate::congestion::CongestionController; use crate::db::Db; use crate::events::EventStore; use crate::orchestrator::Orchestrator; @@ -42,6 +43,7 @@ pub(crate) struct AppState { /// directly — see `nullnet_grpc_impl::NullnetGrpcImpl`'s field docs. pub(crate) match_index: Arc>, pub(crate) orchestrator: Orchestrator, + pub(crate) congestion: Arc, pub(crate) events: EventStore, /// Persisted ingress/egress session history behind the Sessions page. pub(crate) sessions: SessionStore, diff --git a/members/nullnet-server/src/http_server/nodes.rs b/members/nullnet-server/src/http_server/nodes.rs index f2959e1..b461057 100644 --- a/members/nullnet-server/src/http_server/nodes.rs +++ b/members/nullnet-server/src/http_server/nodes.rs @@ -1,12 +1,14 @@ use super::AppState; use super::auth::{AuthContext, require_scope}; use crate::auth::Scope; +use crate::congestion::{ControlMode, NodeCongestionSnapshot, PressureTrend}; use crate::services::service_info::ServiceInfo; use axum::extract::{Extension, Path, State}; use axum::response::{IntoResponse, Response}; use serde::Serialize; use std::collections::HashMap; use std::net::IpAddr; +use std::time::Duration; #[derive(Serialize)] struct HostedServiceJson { @@ -18,6 +20,118 @@ struct HostedServiceJson { struct NodeJson { ip: String, hosted_services: Vec, + ingress_groups: IngressGroupsJson, + ingress_control: IngressControlJson, + pressure_age_ms: Option, + pressure_windows: Vec, +} + +#[derive(Default, Serialize)] +struct IngressGroupsJson { + dedicated: usize, + multiplexed: usize, +} + +#[derive(Serialize)] +struct IngressControlJson { + state: &'static str, + state_age_ms: Option, + rate_per_sec: Option, + tokens: Option, + token_capacity: u8, + last_token_shortage_age_ms: Option, + source_proxy: Option, + recent_states: Vec, +} + +#[derive(Serialize)] +struct IngressStateJson { + state: &'static str, + age_ms: u64, + source_proxy: String, +} + +#[derive(Serialize)] +struct PressureWindowJson { + age_ms: u64, + duration_ms: u64, + arrivals: u64, + completions: u64, + arrivals_per_sec: f64, + completions_per_sec: f64, + waiters: u32, + mean_wait_us: u64, + trend: &'static str, +} + +fn millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +fn control_mode(mode: ControlMode) -> &'static str { + match mode { + ControlMode::Normal => "normal", + ControlMode::Multiplexing => "multiplexing", + ControlMode::Recovery => "recovery", + } +} + +fn congestion_json( + snapshot: NodeCongestionSnapshot, +) -> (IngressControlJson, Option, Vec) { + let control = snapshot.control.map_or_else( + || IngressControlJson { + state: "normal", + state_age_ms: None, + rate_per_sec: None, + tokens: None, + token_capacity: 32, + last_token_shortage_age_ms: None, + source_proxy: None, + recent_states: Vec::new(), + }, + |control| IngressControlJson { + state: control_mode(control.mode), + state_age_ms: control.state_age.map(millis), + rate_per_sec: control.rate_per_sec, + tokens: control.tokens, + token_capacity: 32, + last_token_shortage_age_ms: control.last_token_shortage_age.map(millis), + source_proxy: control.source_proxy.map(|ip| ip.to_string()), + recent_states: control + .transitions + .into_iter() + .map(|transition| IngressStateJson { + state: control_mode(transition.mode), + age_ms: millis(transition.age), + source_proxy: transition.source_proxy.to_string(), + }) + .collect(), + }, + ); + let windows = snapshot + .windows + .into_iter() + .map(|window| { + let seconds = window.duration.as_secs_f64(); + PressureWindowJson { + age_ms: millis(window.age), + duration_ms: millis(window.duration), + arrivals: window.arrivals, + completions: window.completions, + arrivals_per_sec: window.arrivals as f64 / seconds, + completions_per_sec: window.completions as f64 / seconds, + waiters: window.waiters, + mean_wait_us: window.mean_wait_us, + trend: match window.trend { + PressureTrend::Growing => "growing", + PressureTrend::Healthy => "healthy", + PressureTrend::Neutral => "neutral", + }, + } + }) + .collect(); + (control, snapshot.pressure_age.map(millis), windows) } pub(super) async fn nodes_handler( @@ -29,10 +143,15 @@ pub(super) async fn nodes_handler( return resp; } let connected_ips = state.orchestrator.connected_node_ips().await; + let mut congestion = state.congestion.snapshots(connected_ips.iter().copied()); let services = state.services.read().await; let mut ip_services: HashMap> = connected_ips.iter().map(|ip| (*ip, vec![])).collect(); + let mut ingress_groups: HashMap> = connected_ips + .iter() + .map(|ip| (*ip, HashMap::new())) + .collect(); if let Some(stack_map) = services.get(&stack) { for (name, info) in stack_map { @@ -44,6 +163,25 @@ pub(super) async fn nodes_handler( stack: stack.clone(), }); } + for (client, info) in replica.clients() { + let Some(proxy) = client.is_proxy() else { + continue; + }; + if info.is_pending() { + continue; + } + for node in [proxy, replica.ip()] { + if let Some(groups) = ingress_groups.get_mut(&node) { + groups + .entry(info.net_id()) + .and_modify(|multiplexed| *multiplexed |= info.policy()) + .or_insert_with(|| info.policy()); + } + if proxy == replica.ip() { + break; + } + } + } } } } @@ -53,9 +191,20 @@ pub(super) async fn nodes_handler( .into_iter() .map(|(ip, mut svcs)| { svcs.sort_by(|a, b| a.stack.cmp(&b.stack).then(a.name.cmp(&b.name))); + let groups = ingress_groups.remove(&ip).unwrap_or_default(); + let multiplexed = groups.values().filter(|policy| **policy).count(); + let snapshot = congestion.remove(&ip).unwrap_or_default(); + let (ingress_control, pressure_age_ms, pressure_windows) = congestion_json(snapshot); NodeJson { ip: ip.to_string(), hosted_services: svcs, + ingress_groups: IngressGroupsJson { + dedicated: groups.len() - multiplexed, + multiplexed, + }, + ingress_control, + pressure_age_ms, + pressure_windows, } }) .collect(); diff --git a/members/nullnet-server/src/http_server/observation.rs b/members/nullnet-server/src/http_server/observation.rs index f9673a2..b4092d0 100644 --- a/members/nullnet-server/src/http_server/observation.rs +++ b/members/nullnet-server/src/http_server/observation.rs @@ -337,6 +337,7 @@ port = 8003 services: Arc::new(RwLock::new(services)), routes: Arc::new(RwLock::new(routes)), match_index: Arc::new(RwLock::new(index)), + congestion: Arc::default(), events: orchestrator.events.clone(), sessions: orchestrator.sessions.clone(), orchestrator, @@ -535,7 +536,7 @@ port = 8003 ); assert_ne!(a.is_ok(), b.is_ok()); let Json(started) = a.or(b).unwrap(); - stop_handler( + let _ = stop_handler( admin(), Path(("mesh".into(), started.id)), State(state.clone()), @@ -662,7 +663,7 @@ port = 8003 .status(), StatusCode::FORBIDDEN ); - start_handler(admin(), Path("mesh".into()), State(state.clone())) + let _ = start_handler(admin(), Path("mesh".into()), State(state.clone())) .await .unwrap(); let body = serde_json::from_value( diff --git a/members/nullnet-server/src/main.rs b/members/nullnet-server/src/main.rs index e3b477a..60ba48e 100644 --- a/members/nullnet-server/src/main.rs +++ b/members/nullnet-server/src/main.rs @@ -112,6 +112,7 @@ async fn main() -> Result<(), Error> { services: nullnet.services().clone(), routes: nullnet.routes().clone(), match_index: nullnet.match_index().clone(), + congestion: nullnet.congestion().clone(), events: nullnet.orchestrator().events.clone(), sessions: nullnet.orchestrator().sessions.clone(), orchestrator: nullnet.orchestrator().clone(), diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index e5ad7a8..95a2f6d 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -801,6 +801,16 @@ impl NullnetGrpcImpl { ); (*host, true) } + RateTransition::RecoveryStarted { host } => { + println!( + "Ingress congestion recovery started for {host}: dedicated setup probing enabled" + ); + self.orchestrator + .events + .emit(Event::ingress_congestion_recovery_started(host.to_string())) + .await; + continue; + } RateTransition::Recovered { host } => { println!( "Ingress congestion control recovered for {host}: dedicated setup rate unrestricted" @@ -1872,6 +1882,10 @@ impl NullnetGrpcImpl { &self.match_index } + pub(crate) fn congestion(&self) -> &Arc { + &self.congestion + } + pub(crate) fn orchestrator(&self) -> &Orchestrator { &self.orchestrator } diff --git a/members/nullnet-server/ui/src/index.css b/members/nullnet-server/ui/src/index.css index 7f20003..e7bbf22 100644 --- a/members/nullnet-server/ui/src/index.css +++ b/members/nullnet-server/ui/src/index.css @@ -245,11 +245,16 @@ body { .nc-head { padding: 16px 18px; display: flex; align-items: flex-start; justify-content: space-between; } .nc-name { font-weight: 700; font-size: 14px; display: flex; align-items: center; gap: 8px; } .nc-ip { font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--cyan); margin-top: 3px; } +.nc-observed { font-size: 10px; color: var(--t2); margin: 3px 0 0 16px; } .nc-meta { text-align: right; font-size: 10px; color: var(--t2); line-height: 1.5; } -.nc-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0; padding: 0 18px 14px; } +.nc-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0; padding: 0 18px 14px; } .nc-stat { padding: 6px 0; } .nc-stat-k { font-size: 10px; color: var(--t2); margin-bottom: 2px; } .nc-stat-v { font-size: 16px; font-weight: 700; font-family: 'Syne', sans-serif; letter-spacing: -.02em; } +.nc-stat-v.compact { font-size: 14px; } +.nc-groups { padding: 8px 18px; border-top: 1px solid var(--t3); display: flex; gap: 14px; color: var(--t2); font-size: 10px; } +.nc-groups strong { color: var(--t0); font-family: 'JetBrains Mono', monospace; font-weight: 500; } +.nc-groups span:last-child { margin-left: auto; } .nc-svcs { padding: 10px 18px; border-top: 1px solid var(--t3); display: flex; flex-wrap: wrap; gap: 5px; } .svc-tag { font-size: 10px; padding: 2px 9px; background: rgba(255,255,255,.05); border: 1px solid var(--t3); color: var(--t1); border-radius: 20px; } .dp { width: 300px; background: rgba(3,5,8,.9); backdrop-filter: blur(28px); -webkit-backdrop-filter: blur(28px); border-left: 1px solid var(--gb); box-shadow: inset 0 1px 0 rgba(255,255,255,.07); display: flex; flex-direction: column; flex-shrink: 0; overflow: hidden; position: relative; } @@ -272,6 +277,46 @@ body { .mini-tbl td { font-size: 11px; padding: 5px 0; border-bottom: rgba(255,255,255,.03) solid 1px; } .mini-tbl tr:last-child td { border-bottom: none; } .empty-dp { display: flex; align-items: center; justify-content: center; flex: 1; flex-direction: column; gap: 10px; color: var(--t2); } +.pressure-chart-wrap { background: rgba(255,255,255,.025); border: 1px solid var(--t3); border-radius: 9px; padding: 9px 9px 4px; } +.pressure-legend { display: flex; align-items: center; gap: 12px; color: var(--t2); font-size: 9px; } +.pressure-legend span { display: flex; align-items: center; gap: 5px; } +.pressure-legend .pressure-range { margin-left: auto; } +.legend-line { width: 11px; height: 2px; border-radius: 2px; display: inline-block; } +.legend-line.arrivals { background: var(--cyan); } +.legend-line.completions { background: var(--green); } +.pressure-chart { display: block; width: 100%; height: 82px; margin-top: 5px; overflow: visible; } +.chart-line { fill: none; stroke-width: 2; vector-effect: non-scaling-stroke; stroke-linejoin: round; stroke-linecap: round; } +.chart-line.arrivals { stroke: var(--cyan); } +.chart-line.completions { stroke: var(--green); } +.chart-line.waiters { stroke: var(--amber); } +.chart-line.wait { stroke: var(--purple); } +.chart-axis { stroke: rgba(255,255,255,.12); vector-effect: non-scaling-stroke; } +.chart-grid { stroke: rgba(255,255,255,.05); stroke-dasharray: 2 3; vector-effect: non-scaling-stroke; } +.queue-charts { margin-top: 8px; border: 1px solid var(--t3); border-radius: 9px; overflow: hidden; } +.queue-chart-row { height: 46px; display: grid; grid-template-columns: 64px minmax(0, 1fr) 58px; gap: 8px; align-items: center; padding: 5px 9px; } +.queue-chart-row + .queue-chart-row { border-top: 1px solid var(--t3); } +.queue-chart-row > span { color: var(--t2); font-size: 9px; } +.queue-chart-row svg { width: 100%; height: 30px; overflow: visible; } +.queue-chart-row strong { color: var(--t1); font-family: 'JetBrains Mono', monospace; font-size: 9px; font-weight: 500; text-align: right; } +.pressure-empty { min-height: 92px; border: 1px dashed var(--t3); border-radius: 9px; display: flex; align-items: center; justify-content: center; color: var(--t2); font-size: 10px; } +.pressure-empty.small { min-height: 54px; } +.pressure-observed { margin-top: 6px; color: var(--t2); font-size: 9px; text-align: right; } +.group-summary { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; } +.group-summary > div { background: rgba(255,255,255,.025); border: 1px solid var(--t3); border-radius: 9px; padding: 9px 11px; display: flex; flex-direction: column; } +.group-summary strong { color: var(--t0); font-family: 'Syne', sans-serif; font-size: 18px; line-height: 1.3; } +.group-summary span { color: var(--t2); font-size: 9px; } +.state-history { display: flex; flex-direction: column; } +.state-history-row { display: grid; grid-template-columns: 7px auto minmax(0, 1fr) auto; gap: 7px; align-items: center; padding: 6px 0; border-bottom: 1px solid rgba(255,255,255,.03); font-size: 10px; } +.state-history-row:last-child { border-bottom: none; } +.state-history-row time, .state-source { color: var(--t2); } +.state-source { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.state-mark { width: 6px; height: 6px; border-radius: 50%; background: var(--green); } +.state-mark.multiplexing { background: var(--amber); } +.state-mark.recovery { background: var(--blue); } + +@media (max-width: 1050px) { + .nodes-grid { grid-template-columns: 1fr; } +} /* ── Pool ── */ diff --git a/members/nullnet-server/ui/src/lib/eventKinds.ts b/members/nullnet-server/ui/src/lib/eventKinds.ts index b2967f4..e5addf7 100644 --- a/members/nullnet-server/ui/src/lib/eventKinds.ts +++ b/members/nullnet-server/ui/src/lib/eventKinds.ts @@ -38,6 +38,7 @@ export const EVENT_KIND_GROUPS = defineEventKindGroups([ 'egress_reservation_reclaimed', 'file_watch_failed', 'ingress_congestion_control_changed', + 'ingress_congestion_recovery_started', 'max_networks_limit_enforced', 'net_id_pool_exhausted', 'net_teardown_unconfirmed', diff --git a/members/nullnet-server/ui/src/pages/Events.tsx b/members/nullnet-server/ui/src/pages/Events.tsx index 183082f..b301242 100644 --- a/members/nullnet-server/ui/src/pages/Events.tsx +++ b/members/nullnet-server/ui/src/pages/Events.tsx @@ -76,6 +76,8 @@ function eventDetail(e: EventJson): string { return e.active ? `${e.node_ip} · congestion multiplexing activated` : `${e.node_ip} · congestion multiplexing deactivated`; + case 'ingress_congestion_recovery_started': + return `${e.node_ip} · dedicated setup recovery test started`; case 'net_id_pool_exhausted': case 'proxy_chain_setup_failed': return `${e.service} · ${e.client_ip}`; diff --git a/members/nullnet-server/ui/src/pages/Nodes.tsx b/members/nullnet-server/ui/src/pages/Nodes.tsx index e3b226e..8fa134b 100644 --- a/members/nullnet-server/ui/src/pages/Nodes.tsx +++ b/members/nullnet-server/ui/src/pages/Nodes.tsx @@ -3,19 +3,112 @@ import { useState } from 'react'; import Layout from '../components/Layout'; import { useApi } from '../hooks/useApi'; import { useStack } from '../StackContext'; -import type { NodeJson } from '../types'; +import type { IngressControlState, NodeJson, PressureWindowJson } from '../types'; import { useDragResize } from '../hooks/useDragResize'; +function stateLabel(state: IngressControlState) { + if (state === 'multiplexing') return 'Multiplexing'; + if (state === 'recovery') return 'Recovery test'; + return 'Normal'; +} + +function stateBadge(state: IngressControlState) { + if (state === 'multiplexing') return 'b-amber'; + if (state === 'recovery') return 'b-blue'; + return 'b-green'; +} + +function formatAge(ms: number | null | undefined) { + if (ms == null) return 'No samples yet'; + if (ms < 1000) return 'just now'; + if (ms < 60_000) return `${Math.floor(ms / 1000)}s ago`; + return `${Math.floor(ms / 60_000)}m ago`; +} + +function formatStateAge(ms: number | null | undefined) { + return ms == null ? 'No control change' : formatAge(ms); +} + +function formatWait(us: number | null | undefined) { + if (us == null) return '–'; + if (us < 1000) return `${Math.round(us)} µs`; + return `${(us / 1000).toFixed(us < 10_000 ? 1 : 0)} ms`; +} + +function formatRate(value: number | null | undefined) { + if (value == null) return '–'; + return value >= 100 ? Math.round(value).toLocaleString() : value.toFixed(1); +} + +function linePoints(values: number[], width: number, height: number, max: number) { + if (values.length === 1) { + const y = height - (values[0] / max) * height; + return `0,${y} ${width},${y}`; + } + return values.map((value, index) => { + const x = (index / (values.length - 1)) * width; + const y = height - (value / max) * height; + return `${x},${y}`; + }).join(' '); +} + +function RoutingChart({ windows }: { windows: PressureWindowJson[] }) { + if (windows.length === 0) return
Waiting for routing samples…
; + const arrivals = windows.map(window => window.arrivals_per_sec); + const completions = windows.map(window => window.completions_per_sec); + const max = Math.max(1, ...arrivals, ...completions); + return ( +
+
+ Arrivals/s + Completions/s + last {windows.length}s +
+ + + + + + +
+ ); +} + +function QueueChart({ windows }: { windows: PressureWindowJson[] }) { + if (windows.length === 0) return null; + const waiters = windows.map(window => window.waiters); + const waits = windows.map(window => window.mean_wait_us / 1000); + const waiterMax = Math.max(1, ...waiters); + const waitMax = Math.max(1, ...waits); + return ( +
+
+ Waiters + + + + {waiters.at(-1) ?? 0} +
+
+ Mean wait + + + + {formatWait(windows.at(-1)?.mean_wait_us)} +
+
+ ); +} + export default function Nodes() { const { stack } = useStack(); const { data: nodes, loading, error, updatedAt } = useApi(`/api/nodes/${stack}`, 5000); const [selected, setSelected] = useState(null); - const { width: dpWidth, onResizeStart } = useDragResize(300, 200, 560); - - const selectedNode = nodes?.find(n => n.ip === selected) ?? null; + const { width: dpWidth, onResizeStart } = useDragResize(360, 300, 640); + const selectedNode = nodes?.find(node => node.ip === selected) ?? null; function select(ip: string) { - setSelected(prev => (prev === ip ? null : ip)); + setSelected(previous => (previous === ip ? null : ip)); } return ( @@ -31,51 +124,48 @@ export default function Nodes() {
Connected Nodes
-
Click a node to inspect hosted services
+
Current ingress pressure and the last 60 seconds, refreshed every 5 seconds
{loading &&
Loading…
}
- {nodes?.map(node => ( -
select(node.ip)} - > -
-
-
- - {node.ip} - Online + {nodes?.map(node => { + const latest = node.pressure_windows.at(-1); + return ( +
select(node.ip)}> +
+
+
{node.ip}
+
Observed {formatAge(node.pressure_age_ms)}
-
{node.ip}
-
-
-
{node.hosted_services.length} service{node.hosted_services.length !== 1 ? 's' : ''}
+ + {stateLabel(node.ingress_control.state)} +
-
-
-
-
Services
-
{node.hosted_services.length}
+
+
Arrivals/s
{formatRate(latest?.arrivals_per_sec)}
+
Completions/s
{formatRate(latest?.completions_per_sec)}
+
Waiters
{latest?.waiters ?? '–'}
+
Mean wait
{formatWait(latest?.mean_wait_us)}
-
- {node.hosted_services.length > 0 && ( -
- {node.hosted_services.map(s => ( - {s.name} - ))} +
+ {node.ingress_groups.dedicated} dedicated + {node.ingress_groups.multiplexed} multiplexed + {node.hosted_services.length} service{node.hosted_services.length !== 1 ? 's' : ''}
- )} -
- ))} - {!loading && nodes?.length === 0 && ( -
No nodes connected
- )} + {node.hosted_services.length > 0 && ( +
+ {node.hosted_services.map(service => {service.name})} +
+ )} +
+ ); + })} + + {!loading && nodes?.length === 0 &&
No nodes connected
}
@@ -83,51 +173,74 @@ export default function Nodes() {
{selectedNode ? selectedNode.ip : '–'} - {selectedNode && ( - - )} + {selectedNode && }
{selectedNode ? (
-
Connection
-
- IP Address - {selectedNode.ip} -
-
- Status - Online +
Ingress control
+
State{stateLabel(selectedNode.ingress_control.state)}
+
Entered state{formatStateAge(selectedNode.ingress_control.state_age_ms)}
+ {selectedNode.ingress_control.source_proxy &&
Triggering proxy{selectedNode.ingress_control.source_proxy}
} + {selectedNode.ingress_control.state !== 'normal' && ( + <> +
Dedicated setup rate{selectedNode.ingress_control.state === 'multiplexing' ? 'Paused' : `${formatRate(selectedNode.ingress_control.rate_per_sec)}/s`}
+
Available tokens{Math.floor(selectedNode.ingress_control.tokens ?? 0)} / {selectedNode.ingress_control.token_capacity}
+ + )} +
+ +
+
Routing pressure
+ + +
Latest proxy sample {formatAge(selectedNode.pressure_age_ms)}
+
+ +
+
Active ingress groups
+
+
{selectedNode.ingress_groups.dedicated}Dedicated
+
{selectedNode.ingress_groups.multiplexed}Multiplexed
+
+
Recent control states
+ {selectedNode.ingress_control.recent_states.length > 0 ? ( +
+ {selectedNode.ingress_control.recent_states.map((entry, index) => ( +
+ + {stateLabel(entry.state)} + via {entry.source_proxy} + +
+ ))} +
+ ) :
No control changes since server start
} +
+
Hosted Services
{selectedNode.hosted_services.length > 0 ? ( - - - + - {selectedNode.hosted_services.map(s => ( - - - + {selectedNode.hosted_services.map(service => ( + + + ))}
ServiceStack
ServiceStack
{s.name}{s.stack}
{service.name}{service.stack}
- ) : ( -
No services hosted
- )} + ) :
No services hosted
}
) : ( -
- ◉ - Select a node -
+
◉Select a node
)}
diff --git a/members/nullnet-server/ui/src/types.ts b/members/nullnet-server/ui/src/types.ts index 686e092..ef83bd3 100644 --- a/members/nullnet-server/ui/src/types.ts +++ b/members/nullnet-server/ui/src/types.ts @@ -28,6 +28,42 @@ export interface HostedServiceJson { export interface NodeJson { ip: string; hosted_services: HostedServiceJson[]; + ingress_groups: { + dedicated: number; + multiplexed: number; + }; + ingress_control: { + state: IngressControlState; + state_age_ms: number | null; + rate_per_sec: number | null; + tokens: number | null; + token_capacity: number; + last_token_shortage_age_ms: number | null; + source_proxy: string | null; + recent_states: IngressStateJson[]; + }; + pressure_age_ms: number | null; + pressure_windows: PressureWindowJson[]; +} + +export type IngressControlState = 'normal' | 'multiplexing' | 'recovery'; + +export interface IngressStateJson { + state: IngressControlState; + age_ms: number; + source_proxy: string; +} + +export interface PressureWindowJson { + age_ms: number; + duration_ms: number; + arrivals: number; + completions: number; + arrivals_per_sec: number; + completions_per_sec: number; + waiters: number; + mean_wait_us: number; + trend: 'healthy' | 'growing' | 'neutral'; } export interface SessionJson { @@ -171,6 +207,7 @@ export type EventJson = | WithSeverity & { type: 'stale_session_evicted'; service: string; client_ip: string; proxy_ip: string } | WithSeverity & { type: 'max_networks_limit_enforced'; service: string; proxy_ip: string; net_id: number; limit: number } | WithSeverity & { type: 'ingress_congestion_control_changed'; node_ip: string; active: boolean } + | WithSeverity & { type: 'ingress_congestion_recovery_started'; node_ip: string } | WithSeverity & { type: 'net_id_pool_exhausted'; service: string; client_ip: string } | WithSeverity & { type: 'proxy_chain_setup_failed'; service: string; client_ip: string } | WithSeverity & { type: 'backend_trigger_setup_bailed'; service: string; port: number } From 0d7d346348ecd0ec008a45ca2e83b819526a54d6 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 21 Oct 2026 13:19:26 +0200 Subject: [PATCH 9/9] test: restore standalone policy verification --- members/nullnet-client/src/commands/policy.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/members/nullnet-client/src/commands/policy.rs b/members/nullnet-client/src/commands/policy.rs index cde49dd..46aa608 100644 --- a/members/nullnet-client/src/commands/policy.rs +++ b/members/nullnet-client/src/commands/policy.rs @@ -990,6 +990,13 @@ mod tests { .unwrap(); heartbeat(&[1; 16]).await.unwrap(); let handle = RtNetLinkHandle::new().unwrap(); + super::super::xfrm_shards::recover(&handle.handle) + .await + .unwrap(); + recover(&handle.handle).await.unwrap(); + super::super::initialize_policy_shards(&handle) + .await + .unwrap(); let cache = BridgeIpCache::new(); let keys = if side == "s" { [0x42, 0x43, 0x44] @@ -1058,6 +1065,9 @@ mod tests { assert!(retire(&format!("ns_{net_id}_{side}")).await.unwrap()); } destroy_all(&handle).await.unwrap(); + super::super::xfrm_shards::recover(&handle.handle) + .await + .unwrap(); for message in messages { super::super::endpoint_namespace::remove(&message.ns_name) .await