From 5e2a7015f21eeaec09cd84ef51b84d989dca4943 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 28 Oct 2026 10:39:54 +0100 Subject: [PATCH 1/6] feat: reuse VXLAN carriers across ingress groups --- ebpf/src/policy.rs | 7 +- members/nullnet-client/src/commands/mod.rs | 1 + .../src/commands/native_vxlan.rs | 85 +++++ members/nullnet-client/src/commands/policy.rs | 304 ++++++++++++------ .../src/commands/vxlan_environment.rs | 2 +- members/nullnet-client/src/control_channel.rs | 20 +- .../nullnet-grpc-lib/proto/nullnet_grpc.proto | 22 +- .../src/proto/nullnet_grpc.rs | 23 +- members/nullnet-server/src/net.rs | 18 +- .../nullnet-server/src/nullnet_grpc_impl.rs | 53 +-- members/nullnet-server/src/orchestrator.rs | 28 +- .../nullnet-server/src/services/changes.rs | 7 +- .../nullnet-server/src/services/clients.rs | 11 + .../src/services/service_info.rs | 20 +- members/nullnet-server/src/tests.rs | 48 +-- shared/policy.rs | 3 +- 16 files changed, 466 insertions(+), 186 deletions(-) create mode 100644 members/nullnet-client/src/commands/native_vxlan.rs diff --git a/ebpf/src/policy.rs b/ebpf/src/policy.rs index 5497712..213d9fb 100644 --- a/ebpf/src/policy.rs +++ b/ebpf/src/policy.rs @@ -1,4 +1,4 @@ -//! Permission-gated IPv4 forwarding over shared policy transports. +//! Permission-gated IPv4 forwarding over reusable group carriers. use aya_ebpf::{ EbpfContext, bindings::{TC_ACT_SHOT, bpf_tunnel_key}, @@ -125,7 +125,7 @@ fn outgoing(ctx: &TcContext) -> Result { } let mut tunnel: bpf_tunnel_key = unsafe { zeroed() }; - tunnel.tunnel_id = (rule.generation & 0xff_ffff) as u32; + tunnel.tunnel_id = rule.vni; tunnel.tunnel_ttl = 64; tunnel.__bindgen_anon_1.remote_ipv4 = rule.underlay_remote; tunnel.__bindgen_anon_3.local_ipv4 = rule.underlay_local; @@ -166,7 +166,8 @@ fn incoming(ctx: &TcContext) -> Result { 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 + || tunnel.tunnel_id != rule.vni + || u64::from(option & 0xffff) != rule.generation >> 24 { return Err(-1); } diff --git a/members/nullnet-client/src/commands/mod.rs b/members/nullnet-client/src/commands/mod.rs index 6d6211f..0b1b81d 100644 --- a/members/nullnet-client/src/commands/mod.rs +++ b/members/nullnet-client/src/commands/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod endpoint_namespace; mod endpoint_tc; mod native_macsec; mod native_netlink; +mod native_vxlan; mod native_xfrm; mod netlink; pub(crate) mod nfqueue; diff --git a/members/nullnet-client/src/commands/native_vxlan.rs b/members/nullnet-client/src/commands/native_vxlan.rs new file mode 100644 index 0000000..2dd69f4 --- /dev/null +++ b/members/nullnet-client/src/commands/native_vxlan.rs @@ -0,0 +1,85 @@ +//! VXLAN VNI-filter membership through route Netlink. +use super::native_netlink::{NESTED, NativeSocket, REQUEST, attr, blocking}; +use std::io; + +const RTM_NEWTUNNEL: u16 = 120; +const RTM_DELTUNNEL: u16 = 121; +const RTM_DELLINK: u16 = 17; +const VXLAN_VNIFILTER_ENTRY: u16 = 1; +const VXLAN_VNIFILTER_ENTRY_START: u16 = 1; + +fn message(ifindex: u32, vni: u32) -> Vec { + let mut body = vec![libc::AF_BRIDGE as u8, 0, 0, 0]; + body.extend(ifindex.to_ne_bytes()); + let entry = attr(VXLAN_VNIFILTER_ENTRY_START, &vni.to_ne_bytes()); + body.extend(attr(VXLAN_VNIFILTER_ENTRY | NESTED, &entry)); + body +} + +async fn modify(ifindex: u32, vni: u32, add: bool) -> io::Result<()> { + blocking(move || { + let kind = if add { RTM_NEWTUNNEL } else { RTM_DELTUNNEL }; + let result = NativeSocket::new(libc::NETLINK_ROUTE as _)?.request( + kind, + REQUEST, + &message(ifindex, vni), + ); + match result { + Ok(_) => Ok(()), + Err(error) + if !add && matches!(error.raw_os_error(), Some(libc::ENOENT | libc::ESRCH)) => + { + Ok(()) + } + Err(error) => Err(error), + } + }) + .await +} + +pub(super) async fn add_vni(ifindex: u32, vni: u32) -> io::Result<()> { + modify(ifindex, vni, true).await +} + +pub(super) async fn remove_vni(ifindex: u32, vni: u32) -> io::Result<()> { + modify(ifindex, vni, false).await +} + +pub(super) async fn remove_link(ifindex: u32) -> io::Result<()> { + blocking(move || { + let mut body = vec![libc::AF_UNSPEC as u8, 0, 0, 0]; + body.extend(ifindex.to_ne_bytes()); + body.extend([0; 8]); + let result = + NativeSocket::new(libc::NETLINK_ROUTE as _)?.request(RTM_DELLINK, REQUEST, &body); + match result { + Ok(_) => Ok(()), + Err(error) if matches!(error.raw_os_error(), Some(libc::ENODEV | libc::ENOENT)) => { + Ok(()) + } + Err(error) => Err(error), + } + }) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::native_netlink::attrs; + + #[test] + fn vni_filter_message_matches_tunnel_uapi() { + let body = message(17, 0x12_3456); + assert_eq!(body[..4], [libc::AF_BRIDGE as u8, 0, 0, 0]); + assert_eq!(u32::from_ne_bytes(body[4..8].try_into().unwrap()), 17); + let outer = attrs(&body[8..]).unwrap(); + assert_eq!(outer[0].0, VXLAN_VNIFILTER_ENTRY); + let inner = attrs(outer[0].1).unwrap(); + assert_eq!(inner[0].0, VXLAN_VNIFILTER_ENTRY_START); + assert_eq!( + u32::from_ne_bytes(inner[0].1.try_into().unwrap()), + 0x12_3456 + ); + } +} diff --git a/members/nullnet-client/src/commands/policy.rs b/members/nullnet-client/src/commands/policy.rs index cfd0937..cf6f2e6 100644 --- a/members/nullnet-client/src/commands/policy.rs +++ b/members/nullnet-client/src/commands/policy.rs @@ -1,6 +1,6 @@ -//! Persistent attachments and permission groups for overflow IPv4 edges. +//! Persistent attachments and permission groups for encrypted IPv4 ingress. use super::netlink::{add_link_echo, get_link_by_name}; -use super::{RtNetLinkHandle, endpoint_namespace::EndpointNamespace, native_xfrm}; +use super::{RtNetLinkHandle, endpoint_namespace::EndpointNamespace, native_vxlan, native_xfrm}; use crate::nfqueue::BridgeIpCache; use aya::{ Ebpf, @@ -11,7 +11,7 @@ use aya::{ }, }; use futures::TryStreamExt; -use nullnet_grpc_lib::nullnet_grpc::{PolicyTransport, VxlanSetup}; +use nullnet_grpc_lib::nullnet_grpc::{GroupTransport, VxlanSetup}; use rtnetlink::{ Handle, IpVersion, LinkUnspec, LinkVeth, LinkVxlan, packet_route::{ @@ -20,7 +20,7 @@ use rtnetlink::{ }, }; use std::{ - collections::HashMap, + collections::{HashMap, HashSet}, net::Ipv4Addr, os::{fd::AsRawFd, unix::fs::MetadataExt}, sync::{ @@ -33,13 +33,13 @@ 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}; +use types::{APP_IP, GATEWAY_IP, GROUP, GROUP_PORT, MAX_GENERATION, OutKey, 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"; +const VXLAN_PREFIX: &str = "nng_"; const LEGACY_XFRM_ROUTE_MARK: u32 = 0xce80_0000; const LEGACY_XFRM_RULE_PRIORITY: std::ops::RangeInclusive = 12_000..=12_064; static ENGINE: AsyncMutex>> = AsyncMutex::const_new(None); @@ -62,9 +62,16 @@ struct Maps { struct Engine { bpf: Mutex, maps: Mutex, - transport: OnceCell, + carriers: Mutex>>, peers: AsyncMutex>>>, - edges: Mutex>, + edges: Mutex>, +} +struct Carrier { + state: AsyncMutex>, +} +struct CarrierState { + index: u32, + vnis: HashSet, } struct Attachment { namespace: Arc, @@ -72,14 +79,16 @@ struct Attachment { mac: [u8; 6], } #[derive(Clone)] -struct PolicyEdge { +struct GroupEdge { key: OutKey, rule: Rule, attachment: Arc, local: bool, transport_id: u32, + carrier_id: u64, + vni: u32, } -impl PolicyEdge { +impl GroupEdge { fn output_ready(&self) -> bool { !self.local || self.rule.output != 0 } @@ -87,6 +96,13 @@ impl PolicyEdge { fn error(e: impl std::fmt::Display) -> String { e.to_string() } +fn cleanup_error(primary: String, cleanup: Vec) -> String { + if cleanup.is_empty() { + primary + } else { + format!("{primary}; cleanup: {}", cleanup.join("; ")) + } +} fn clock() -> u64 { let mut now = libc::timespec { tv_sec: 0, @@ -138,7 +154,7 @@ async fn engine() -> Result, String> { Ok::<_, String>(Arc::new(Engine { bpf: Mutex::new(bpf), maps: Mutex::new(maps), - transport: OnceCell::new(), + carriers: Mutex::new(HashMap::new()), peers: AsyncMutex::new(HashMap::new()), edges: Mutex::new(HashMap::new()), })) @@ -168,53 +184,111 @@ impl Engine { .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 + async fn carrier(&self, handle: &Handle, id: u64, vni: u32) -> Result { + let carrier = self + .carriers + .lock() + .unwrap() + .entry(id) + .or_insert_with(|| { + Arc::new(Carrier { + state: AsyncMutex::new(None), + }) }) + .clone(); + let mut state = carrier.state.lock().await; + if let Some(state) = state.as_mut() { + native_vxlan::add_vni(state.index, vni) + .await + .map_err(error)?; + state.vnis.insert(vni); + return Ok(state.index); + } + let name = format!("{VXLAN_PREFIX}{id:x}"); + let link = LinkVxlan::new(&name, 0) + .collect_metadata(true) + .vnifilter(true) + .gbp() + .learning(false) + .port(GROUP_PORT) + .mtu(1080) + .link_group(GROUP) + .build(); + handle.link().add(link).execute().await.map_err(error)?; + let index = get_link_by_name(handle, &name) .await - .copied() + .map_err(|e| e.to_str().to_owned())? + .header + .index; + let setup = async { + self.attach(&name, "nullnet_policy_in")?; + native_vxlan::add_vni(index, vni).await.map_err(error)?; + handle + .link() + .set(LinkUnspec::new_with_index(index).up().build()) + .execute() + .await + .map_err(error)?; + Ok::(index) + } + .await; + let index = match setup { + Ok(index) => index, + Err(setup_error) => { + let cleanup = handle + .link() + .del(index) + .execute() + .await + .err() + .map(error) + .into_iter() + .collect(); + return Err(cleanup_error(setup_error, cleanup)); + } + }; + *state = Some(CarrierState { + index, + vnis: HashSet::from([vni]), + }); + Ok(index) + } + + async fn release_carrier(&self, id: u64, vni: u32) -> Result<(), String> { + let Some(carrier) = self.carriers.lock().unwrap().get(&id).cloned() else { + return Ok(()); + }; + let mut state = carrier.state.lock().await; + let Some(active) = state.as_mut() else { + return Ok(()); + }; + native_vxlan::remove_vni(active.index, vni) + .await + .map_err(error)?; + active.vnis.remove(&vni); + if active.vnis.is_empty() { + let index = active.index; + native_vxlan::remove_link(index).await.map_err(error)?; + *state = None; + let mut carriers = self.carriers.lock().unwrap(); + if carriers + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, &carrier)) + && Arc::strong_count(&carrier) == 2 + { + carriers.remove(&id); + } + } + Ok(()) } async fn peer( &self, - transport: &PolicyTransport, + transport: &GroupTransport, local: Ipv4Addr, remote: Ipv4Addr, ) -> Result<(), String> { if !(0x20_0000..0x40_0000).contains(&transport.id) { - return Err("Invalid policy transport identity".into()); + return Err("Invalid group transport identity".into()); } let key: [u8; 32] = transport .encryption_key @@ -235,13 +309,21 @@ impl Engine { transport.generation as u32, (local, remote), key, - POLICY_PORT, + GROUP_PORT, ) .await; - if result.is_err() { - native_xfrm::remove(transport.id).await.map_err(error)?; + match result { + Ok(()) => Ok(()), + Err(install_error) => { + let cleanup = native_xfrm::remove(transport.id) + .await + .err() + .map(error) + .into_iter() + .collect(); + Err(cleanup_error(error(install_error), cleanup)) + } } - result.map_err(error) }) .await?; Ok(()) @@ -489,11 +571,15 @@ pub(crate) async fn setup( return Err("Host plaintext protection is not ready".into()); } let transport = message - .policy_transport + .group_transport .as_ref() - .ok_or("Missing policy transport")?; - if transport.generation == 0 || transport.generation > MAX_GENERATION { - return Err("Policy generation exhausted".into()); + .ok_or("Missing group transport")?; + if transport.generation == 0 + || transport.generation > MAX_GENERATION + || transport.carrier_id == 0 + || transport.carrier_id > MAX_GENERATION + { + return Err("Group generation exhausted".into()); } let local: Ipv4Addr = message.local_ip.parse().map_err(error)?; let remote: Ipv4Addr = message.remote_ip.parse().map_err(error)?; @@ -502,11 +588,11 @@ pub(crate) async fn setup( || message.egress_intercept.unwrap_or(false) || message.dnat_port.is_some() { - return Err("Unsupported policy placement".into()); + return Err("Unsupported group placement".into()); } let engine = engine().await?; if engine.edges.lock().unwrap().contains_key(&message.ns_name) { - return Err("Policy endpoint is already active".into()); + return Err("Group endpoint is already active".into()); } let output = attachment( &rtnetlink.handle, @@ -520,8 +606,18 @@ pub(crate) async fn setup( let vxlan = if local_transport { 0 } else { - let vxlan = engine.transport(&rtnetlink.handle).await?; - engine.peer(transport, local, remote).await?; + let vxlan = engine + .carrier(&rtnetlink.handle, transport.carrier_id, message.vxlan_id) + .await?; + if let Err(peer_error) = engine.peer(transport, local, remote).await { + let cleanup = engine + .release_carrier(transport.carrier_id, message.vxlan_id) + .await + .err() + .into_iter() + .collect(); + return Err(cleanup_error(peer_error, cleanup)); + } vxlan }; let block = u32::from(Ipv4Addr::new(10, 0, 0, 0)) + message.vxlan_id * 8; @@ -535,6 +631,7 @@ pub(crate) async fn setup( }; let rule = Rule { generation: transport.generation, + vni: message.vxlan_id, local: local_ip, peer: peer_ip, underlay_local: local.into(), @@ -575,24 +672,28 @@ pub(crate) async fn setup( maps.outgoing.insert(key, local_rule, 0).map_err(error)?; edges.insert( message.ns_name.clone(), - PolicyEdge { + GroupEdge { key, rule: local_rule, attachment: output.clone(), local: true, transport_id: transport.id, + carrier_id: transport.carrier_id, + vni: message.vxlan_id, }, ); } else { maps.outgoing.insert(key, rule, 0).map_err(error)?; edges.insert( message.ns_name.clone(), - PolicyEdge { + GroupEdge { key, rule, attachment: output.clone(), local: true, transport_id: transport.id, + carrier_id: transport.carrier_id, + vni: message.vxlan_id, }, ); } @@ -604,12 +705,14 @@ pub(crate) async fn setup( } edges.insert( message.ns_name.clone(), - PolicyEdge { + GroupEdge { key, rule, attachment: output.clone(), local: false, transport_id: transport.id, + carrier_id: transport.carrier_id, + vni: message.vxlan_id, }, ); } @@ -619,7 +722,17 @@ pub(crate) async fn setup( 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)?; + let mut cleanup = Vec::new(); + if let Err(value) = native_xfrm::remove(transport.id).await { + cleanup.push(error(value)); + } + if let Err(value) = engine + .release_carrier(transport.carrier_id, message.vxlan_id) + .await + { + cleanup.push(value); + } + return Err(cleanup_error(map_error, cleanup)); } return Err(map_error); } @@ -730,13 +843,15 @@ pub(crate) async fn retire(name: &str) -> Result { let Some(engine) = engine else { return Ok(false); }; - let (local, transport_id, removed, map_error) = { + let (local, transport_id, carrier_id, vni, 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 carrier_id = edge.carrier_id; + let vni = edge.vni; let id = name .strip_prefix("ns_") .and_then(|value| { @@ -773,19 +888,25 @@ pub(crate) async fn retire(name: &str) -> Result { } removed.push((name, edge)); } - (local, transport_id, removed, map_error) + (local, transport_id, carrier_id, vni, removed, map_error) }; - let mut xfrm_error = None; + let mut cleanup_errors = Vec::new(); + if let Some(value) = map_error { + cleanup_errors.push(value); + } if !local { engine.peers.lock().await.remove(&transport_id); if let Err(value) = native_xfrm::remove(transport_id).await { - xfrm_error = Some(error(value)); + cleanup_errors.push(error(value)); + } + if let Err(value) = engine.release_carrier(carrier_id, vni).await { + cleanup_errors.push(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 = if cleanup_errors.is_empty() { + Ok(true) + } else { + Err(cleanup_errors.join("; ")) }; if result.is_err() { let mut edges = engine.edges.lock().unwrap(); @@ -903,13 +1024,14 @@ mod tests { remote_ip: "192.0.2.1".into(), docker_container: Some(container.into()), encryption_key: vec![7; 32], - dstport: u32::from(POLICY_PORT), + dstport: u32::from(GROUP_PORT), encrypted: true, - policy_transport: Some(PolicyTransport { + group_transport: Some(GroupTransport { id: transport_id, encryption_key: vec![7; 32], peer_container: true, generation, + carrier_id: generation, }), ..Default::default() } @@ -1023,7 +1145,9 @@ mod tests { 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]; + let transport = message.group_transport.as_mut().unwrap(); + transport.encryption_key = vec![key; 32]; + transport.carrier_id = 42; message } @@ -1075,16 +1199,8 @@ mod tests { messages.push(message); } assert_ne!( - messages[0] - .policy_transport - .as_ref() - .unwrap() - .encryption_key, - messages[1] - .policy_transport - .as_ref() - .unwrap() - .encryption_key + messages[0].group_transport.as_ref().unwrap().encryption_key, + messages[1].group_transport.as_ref().unwrap().encryption_key ); std::fs::write("/tmp/nn-policy-cross-ready", "ok").unwrap(); wait_for("/tmp/nn-policy-cross-go").await; @@ -1191,8 +1307,8 @@ mod tests { 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; + client.group_transport.as_mut().unwrap().peer_container = false; + server.group_transport.as_mut().unwrap().peer_container = false; setup(&client, &handle, &cache).await.unwrap(); setup(&server, &handle, &cache).await.unwrap(); assert!(ready(703).await.unwrap()); @@ -1250,8 +1366,10 @@ mod tests { .await .unwrap(); let engine = engine().await.unwrap(); - let transport = engine.transport(&handle.handle).await.unwrap(); + let transport = engine.carrier(&handle.handle, 1, 192).await.unwrap(); + let other_transport = engine.carrier(&handle.handle, 2, 193).await.unwrap(); assert_ne!(transport, 0); + assert_ne!(transport, other_transport); let cache = BridgeIpCache::new(); let first = attachment(&handle.handle, &cache, None, &engine) .await @@ -1274,12 +1392,14 @@ mod tests { }; engine.edges.lock().unwrap().insert( "ns_192_c".into(), - PolicyEdge { + GroupEdge { key, rule, attachment: first, local: false, transport_id: 0x20_0000, + carrier_id: 1, + vni: 192, }, ); engine @@ -1291,9 +1411,11 @@ mod tests { .unwrap(); assert!(retire("ns_192_c").await.unwrap()); assert!(!retire("ns_192_c").await.unwrap()); + assert!(get_link_by_name(&handle.handle, "nng_1").await.is_err()); + assert!(get_link_by_name(&handle.handle, "nng_2").await.is_ok()); 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, "nng_1").await.is_err()); assert!(get_link_by_name(&handle.handle, APP_NAME).await.is_err()); } } diff --git a/members/nullnet-client/src/commands/vxlan_environment.rs b/members/nullnet-client/src/commands/vxlan_environment.rs index fef21b9..bb8d150 100644 --- a/members/nullnet-client/src/commands/vxlan_environment.rs +++ b/members/nullnet-client/src/commands/vxlan_environment.rs @@ -1,4 +1,4 @@ -//! Reconcile the host-wide guard for the shared metadata VXLAN. +//! Reconcile the host-wide guard for group carrier VXLANs. use nullnet_grpc_lib::{ NullnetGrpcInterface, nullnet_grpc::{AgentEvent, AgentVxlanEnvironmentFailed, agent_event::Event}, diff --git a/members/nullnet-client/src/control_channel.rs b/members/nullnet-client/src/control_channel.rs index af7b246..bdfa45e 100644 --- a/members/nullnet-client/src/control_channel.rs +++ b/members/nullnet-client/src/control_channel.rs @@ -506,7 +506,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 group_message = message.group_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 @@ -591,8 +591,8 @@ async fn handle_vxlan_setup( message.docker_container.clone() }, }; - let setup_result = if let Some(policy_message) = &policy_message { - crate::commands::policy::setup(policy_message, &rtnetlink_handle, &bridge_cache) + let setup_result = if let Some(group_message) = &group_message { + crate::commands::policy::setup(group_message, &rtnetlink_handle, &bridge_cache) .await .handle_err(location!()) } else { @@ -737,7 +737,7 @@ async fn handle_vxlan_setup( ); return Err("gateway forwarding installation failed").handle_err(location!()); } - } else if policy_message.is_none() + } else if group_message.is_none() && let Some(host_mapping) = &message.host_mapping { // Backend DNAT keeps the trigger address so each new flow remains @@ -863,11 +863,11 @@ async fn handle_vxlan_teardown( bridge_cache: BridgeIpCache, ) { let ack_id = message.msg_id.clone(); - let policy_cleanup_ok = if message.policy { + let group_cleanup_ok = if message.group { match crate::commands::policy::retire(&message.ns_name).await { Ok(_) => true, Err(error) => { - eprintln!("Policy revocation failed: {error}"); + eprintln!("Group revocation failed: {error}"); false } } @@ -875,8 +875,8 @@ async fn handle_vxlan_teardown( true }; // reverse egress steering/interception if this was an egress edge - let mut local_cleanup_ok = policy_cleanup_ok; - if !message.policy + let mut local_cleanup_ok = group_cleanup_ok; + if !message.group && let Some(rec) = egress_state.take(message.vxlan_id) { match rec { @@ -907,7 +907,7 @@ 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 !message.policy + if !message.group && let Some((container, port, overlay_ip, container_ip)) = triggers_state.remove_by_vxlan(message.vxlan_id) && port != crate::triggers::EGRESS_TRIGGER_PORT @@ -952,7 +952,7 @@ async fn handle_vxlan_teardown( vxlan_id, ns_name: ns_name.clone(), }; - let teardown_result = if message.policy { + let teardown_result = if message.group { Ok(()) } else { crate::commands::vxlan::teardown(&rtnetlink_handle, &vxlan_params, &bridge_cache).await diff --git a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto index 5c346e0..7502bfb 100644 --- a/members/nullnet-grpc-lib/proto/nullnet_grpc.proto +++ b/members/nullnet-grpc-lib/proto/nullnet_grpc.proto @@ -138,13 +138,16 @@ message PolicyHeartbeat { bytes server_epoch = 1; } -message PolicyTransport { +message GroupTransport { // Per-group XFRM identity and AES-256 key. Different active groups use - // different identities and keys even when they share one VXLAN device. + // different identities and keys even when they share a carrier VXLAN. uint32 id = 1; bytes encryption_key = 2; bool peer_container = 3; uint64 generation = 4; + // Standard ingress groups seed carriers; overflow groups are assigned to + // the least-loaded live carrier. Both endpoints receive the same identity. + uint64 carrier_id = 5; } // Both endpoints are prepared; the initiator may release held packets. @@ -224,9 +227,9 @@ 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; - // AES-256 key sent identically to both endpoints. Dedicated cross-host - // VXLANs share one XFRM/ESP carrier per host pair; policy groups carry - // their independently keyed transport in `policy_transport`. + // AES-256 key sent identically to both endpoints. Group ingress carries its + // independently keyed transport in `group_transport`; other VXLAN edges + // use this field directly. // Meaningless (placeholder zero bytes) when `encrypted` is false. bytes encryption_key = 14; // VXLAN UDP destination port (replaces the IANA-standard 4789 default). @@ -237,9 +240,9 @@ 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; - // Shared dedicated cross-host XFRM carrier. Policy groups use - // `policy_transport`; absent means legacy per-edge transport. + GroupTransport group_transport = 17; + // Shared dedicated cross-host XFRM carrier. Group ingress uses + // `group_transport`; absent means legacy per-edge transport. optional uint32 xfrm_transport_id = 18; } @@ -258,7 +261,8 @@ 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; + // True when this endpoint uses the reusable group dataplane. + bool group = 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 95b9cc6..e920f25 100644 --- a/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs +++ b/members/nullnet-grpc-lib/src/proto/nullnet_grpc.rs @@ -58,9 +58,9 @@ pub struct PolicyHeartbeat { pub server_epoch: ::prost::alloc::vec::Vec, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] -pub struct PolicyTransport { +pub struct GroupTransport { /// Per-group XFRM identity and AES-256 key. Different active groups use - /// different identities and keys even when they share one VXLAN device. + /// different identities and keys even when they share a carrier VXLAN. #[prost(uint32, tag = "1")] pub id: u32, #[prost(bytes = "vec", tag = "2")] @@ -69,6 +69,10 @@ pub struct PolicyTransport { pub peer_container: bool, #[prost(uint64, tag = "4")] pub generation: u64, + /// Standard ingress groups seed carriers; overflow groups are assigned to + /// the least-loaded live carrier. Both endpoints receive the same identity. + #[prost(uint64, tag = "5")] + pub carrier_id: u64, } /// Both endpoints are prepared; the initiator may release held packets. #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] @@ -177,9 +181,9 @@ pub struct VxlanSetup { /// internet (kernel ip_forward + MASQUERADE); the Cilium egress-gateway model. #[prost(bool, optional, tag = "13")] pub egress_intercept: ::core::option::Option, - /// AES-256 key sent identically to both endpoints. Dedicated cross-host - /// VXLANs share one XFRM/ESP carrier per host pair; policy groups carry - /// their independently keyed transport in `policy_transport`. + /// AES-256 key sent identically to both endpoints. Group ingress carries its + /// independently keyed transport in `group_transport`; other VXLAN edges + /// use this field directly. /// Meaningless (placeholder zero bytes) when `encrypted` is false. #[prost(bytes = "vec", tag = "14")] pub encryption_key: ::prost::alloc::vec::Vec, @@ -194,9 +198,9 @@ pub struct VxlanSetup { #[prost(bool, tag = "16")] pub encrypted: bool, #[prost(message, optional, tag = "17")] - pub policy_transport: ::core::option::Option, - /// Shared dedicated cross-host XFRM carrier. Policy groups use - /// `policy_transport`; absent means legacy per-edge transport. + pub group_transport: ::core::option::Option, + /// Shared dedicated cross-host XFRM carrier. Group ingress uses + /// `group_transport`; absent means legacy per-edge transport. #[prost(uint32, optional, tag = "18")] pub xfrm_transport_id: ::core::option::Option, } @@ -224,8 +228,9 @@ pub struct VxlanTeardown { /// timer instead. #[prost(message, optional, tag = "8")] pub msg_id: ::core::option::Option, + /// True when this endpoint uses the reusable group dataplane. #[prost(bool, tag = "9")] - pub policy: bool, + pub group: bool, } #[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] pub struct MsgId { diff --git a/members/nullnet-server/src/net.rs b/members/nullnet-server/src/net.rs index a8b1c85..755e673 100644 --- a/members/nullnet-server/src/net.rs +++ b/members/nullnet-server/src/net.rs @@ -1,7 +1,7 @@ use crate::net_id_pool::DEFAULT_VXLAN_DSTPORT; use ipnetwork::Ipv4Network; use nullnet_grpc_lib::nullnet_grpc::{ - HostMapping, MsgId, Net, NetMessage, PolicyTransport, VlanSetup, VlanTeardown, VxlanSetup, + GroupTransport, HostMapping, MsgId, Net, NetMessage, VlanSetup, VlanTeardown, VxlanSetup, VxlanTeardown, net_message, }; use nullnet_liberror::{ErrorHandler, Location, location}; @@ -38,7 +38,7 @@ pub(crate) trait NetExt { dstport: Option, encrypted: bool, egress: EgressRole, - policy_transport: Option, + group_transport: Option, ) -> Option<(Ipv4Addr, NetMessage)>; #[allow(clippy::too_many_arguments)] @@ -51,7 +51,7 @@ pub(crate) trait NetExt { remote_ip: IpAddr, dstport: Option, msg_id: String, - policy: bool, + group: bool, ) -> NetMessage; } @@ -70,7 +70,7 @@ impl NetExt for Net { dstport: Option, encrypted: bool, egress: EgressRole, - policy_transport: Option, + group_transport: Option, ) -> Option<(Ipv4Addr, NetMessage)> { match self { Net::Vlan => vlan_setup( @@ -95,7 +95,7 @@ impl NetExt for Net { dstport.unwrap_or(u32::from(DEFAULT_VXLAN_DSTPORT)), encrypted, egress, - policy_transport, + group_transport, ), } } @@ -109,7 +109,7 @@ impl NetExt for Net { remote_ip: IpAddr, dstport: Option, msg_id: String, - policy: bool, + group: bool, ) -> NetMessage { let msg_id = Some(MsgId { id: msg_id }); match self { @@ -129,7 +129,7 @@ impl NetExt for Net { remote_ip: remote_ip.to_string(), dstport: u32::from(dstport.unwrap_or(DEFAULT_VXLAN_DSTPORT)), msg_id, - policy, + group, })), }, } @@ -199,7 +199,7 @@ fn vxlan_setup( dstport: u32, encrypted: bool, egress: EgressRole, - policy_transport: Option, + group_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. @@ -280,7 +280,7 @@ fn vxlan_setup( egress_steer, egress_intercept, encrypted, - policy_transport, + group_transport, xfrm_transport_id, })), }, diff --git a/members/nullnet-server/src/nullnet_grpc_impl.rs b/members/nullnet-server/src/nullnet_grpc_impl.rs index 40d656a..b88c755 100644 --- a/members/nullnet-server/src/nullnet_grpc_impl.rs +++ b/members/nullnet-server/src/nullnet_grpc_impl.rs @@ -2419,7 +2419,7 @@ async fn setup_edge( // by (client, proxy) and dep clients by source replica, and a source // replica can only route to one replica of a given dep. let deadline = std::time::Instant::now() + EDGE_CLAIM_TIMEOUT; - let (server_ethernet, server_docker, server_resume, reservation) = loop { + let (server_ethernet, server_docker, server_resume, reservation, reserved_carrier) = loop { let waiting_on = { let mut services_guard = services.write().await; let Some(stack_map) = services_guard.get_mut(stack) else { @@ -2446,12 +2446,18 @@ async fn setup_edge( }; // Reserving takes the refcount too, so the slot is never // visible at zero. - if !reg.add_client_to_replica( - ip, - docker.as_deref(), - client.clone(), - ClientInfo::reservation(client_ethernet, policy), - ) { + let reserved_carrier = if policy { + client + .is_proxy() + .and_then(|proxy_ip| reg.group_carrier(proxy_ip)) + } else { + None + }; + let mut info = ClientInfo::reservation(client_ethernet, policy); + if let Some(carrier) = reserved_carrier { + info.set_group_carrier(carrier); + } + if !reg.add_client_to_replica(ip, docker.as_deref(), client.clone(), info) { return EdgeOutcome::Failed; } // Does the target replica need unpausing first? @@ -2461,7 +2467,7 @@ async fn setup_edge( .find(|r| r.matches_identity(ip, docker.as_deref())) .and_then(|r| r.resume(orchestrator)); let reservation = reg.pending_notify(&client).expect("just reserved edge"); - break (ip, docker, resume, reservation); + break (ip, docker, resume, reservation, reserved_carrier); } } }; @@ -2526,11 +2532,16 @@ async fn setup_edge( } let encrypted = *ENCRYPTION_ENABLED; - let policy_transport = policy.then(|| orchestrator.policy_transport(net_id)); + let group = encrypted + && *NET_TYPE == Net::Vxlan + && client.is_proxy().is_some() + && backend_entry_port.is_none() + && !egress; + let group_transport = group.then(|| orchestrator.group_transport(net_id, reserved_carrier)); let xfrm_transport = - (!policy && encrypted && *NET_TYPE == Net::Vxlan && client_ethernet != server_ethernet) + (!group && encrypted && *NET_TYPE == Net::Vxlan && client_ethernet != server_ethernet) .then(|| orchestrator.xfrm_transport(client_ethernet, server_ethernet)); - let encryption_key = if let Some(transport) = &policy_transport { + let encryption_key = if let Some(transport) = &group_transport { transport .encryption_key .as_slice() @@ -2545,11 +2556,14 @@ async fn setup_edge( }; let xfrm_transport_id = xfrm_transport.map(|(id, _)| id); let dstport = None; - let server_policy = policy_transport.clone().map(|mut transport| { + let group_carrier = group_transport + .as_ref() + .map(|transport| transport.carrier_id); + let server_group = group_transport.clone().map(|mut transport| { transport.peer_container = client_docker.is_some(); transport }); - let client_policy = policy_transport.map(|mut transport| { + let client_group = group_transport.map(|mut transport| { transport.peer_container = server_docker.is_some(); transport }); @@ -2574,7 +2588,7 @@ async fn setup_edge( dstport, encrypted, server_egress, - server_policy, + server_group, ) .await }; @@ -2600,7 +2614,7 @@ async fn setup_edge( dstport, encrypted, client_egress, - client_policy, + client_group, ) .await }; @@ -2612,7 +2626,7 @@ async fn setup_edge( && (backend_entry_port.is_some() || client_egress == EgressRole::Steer); let ready = if server_ok.is_none() || client_ok.is_none() { false - } else if policy { + } else if group { orchestrator.send_net_ready(server_ethernet, net_id).await && orchestrator.send_net_ready(client_ethernet, net_id).await } else { @@ -2633,7 +2647,7 @@ async fn setup_edge( server_ethernet, server_docker.clone(), net_id, - policy, + group, ) .await; // remove placeholder @@ -2687,6 +2701,9 @@ async fn setup_edge( if policy { ci.set_policy(); } + if let Some(carrier) = group_carrier { + ci.set_group_carrier(carrier); + } reg.add_client_to_replica( server_ethernet, server_docker.as_deref(), @@ -2723,7 +2740,7 @@ async fn setup_edge( server_ethernet, server_docker, net_id, - policy, + group, ) .await; return EdgeOutcome::Failed; diff --git a/members/nullnet-server/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index fed461f..bd2276a 100644 --- a/members/nullnet-server/src/orchestrator.rs +++ b/members/nullnet-server/src/orchestrator.rs @@ -8,8 +8,8 @@ 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, Net, NetMessage, NetReady, - PolicyHeartbeat, PolicyTransport, net_message, + ContainerResume, ContainerSuspend, EgressPolicyChanged, GroupTransport, MsgId, Net, NetMessage, + NetReady, PolicyHeartbeat, net_message, }; use nullnet_liberror::{Error, ErrorHandler, Location, location}; use std::collections::HashMap; @@ -1002,7 +1002,7 @@ impl Orchestrator { dstport: Option, encrypted: bool, egress: EgressRole, - policy_transport: Option, + group_transport: Option, ) -> Option { self.send_control(dest, |msg_id| { NET_TYPE.setup( @@ -1018,7 +1018,7 @@ impl Orchestrator { dstport, encrypted, egress, - policy_transport, + group_transport, ) }) .await @@ -1148,12 +1148,14 @@ impl Orchestrator { }) } - pub(crate) fn policy_transport(&self, net_id: u32) -> PolicyTransport { - PolicyTransport { + pub(crate) fn group_transport(&self, net_id: u32, carrier: Option) -> GroupTransport { + let generation = self.policy_generation.fetch_add(1, Ordering::Relaxed); + GroupTransport { id: POLICY_XFRM_ID_START + net_id, encryption_key: generate_key().to_vec(), peer_container: false, - generation: self.policy_generation.fetch_add(1, Ordering::Relaxed), + generation, + carrier_id: carrier.unwrap_or(generation), } } @@ -1258,7 +1260,7 @@ impl Orchestrator { server: IpAddr, server_docker: Option, net_id: u32, - policy: bool, + group: bool, ) { let mut acks = Vec::new(); for (dest, remote, side, docker) in [ @@ -1281,7 +1283,7 @@ impl Orchestrator { remote, None, msg_id.clone(), - policy, + group, ); acks.push((dest, msg_id, rx, outbound, message)); @@ -1637,9 +1639,9 @@ mod policy_group_tests { let orchestrator = Orchestrator::new(); let host_a = ip(192, 0, 2, 1); let host_b = ip(192, 0, 2, 2); - let first = orchestrator.policy_transport(101); - let second = orchestrator.policy_transport(102); - let recycled = orchestrator.policy_transport(101); + let first = orchestrator.group_transport(101, None); + let second = orchestrator.group_transport(102, Some(first.carrier_id)); + let recycled = orchestrator.group_transport(101, None); let dedicated = orchestrator.xfrm_transport(host_a, host_b); assert_eq!(first.id, POLICY_XFRM_ID_START + 101); @@ -1647,6 +1649,8 @@ mod policy_group_tests { assert_ne!(first.id, second.id); assert_ne!(first.encryption_key, second.encryption_key); assert_ne!(first.generation, second.generation); + assert_eq!(first.carrier_id, first.generation); + assert_eq!(second.carrier_id, first.carrier_id); assert_eq!(first.id, recycled.id); assert_ne!(first.encryption_key, recycled.encryption_key); assert_ne!(first.generation, recycled.generation); diff --git a/members/nullnet-server/src/services/changes.rs b/members/nullnet-server/src/services/changes.rs index 7b25153..131f761 100644 --- a/members/nullnet-server/src/services/changes.rs +++ b/members/nullnet-server/src/services/changes.rs @@ -279,7 +279,7 @@ async fn teardown_chain( ci.docker_container().cloned(), replica_ip, replica_docker, - ci.policy(), + ci.group_carrier().is_some(), ) }) .collect(); @@ -319,8 +319,7 @@ async fn teardown_chain( } let mut torn_down_net_ids = HashSet::new(); - for (_, client_ip, net_id, client_docker, service_ip, service_docker, policy) in - &proxy_teardowns + for (_, client_ip, net_id, client_docker, service_ip, service_docker, group) in &proxy_teardowns { if !torn_down_net_ids.insert(*net_id) { continue; // already handled this net_id @@ -338,7 +337,7 @@ async fn teardown_chain( *service_ip, service_docker.clone(), *net_id, - *policy, + *group, ) .await; if crate::routine_diagnostics() { diff --git a/members/nullnet-server/src/services/clients.rs b/members/nullnet-server/src/services/clients.rs index 3e91150..8bd56ab 100644 --- a/members/nullnet-server/src/services/clients.rs +++ b/members/nullnet-server/src/services/clients.rs @@ -142,6 +142,7 @@ pub(crate) struct ClientInfo { created_at: SystemTime, docker_container: Option, policy: bool, + group_carrier: Option, /// 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 @@ -171,6 +172,7 @@ impl ClientInfo { created_at: SystemTime::now(), docker_container, policy: false, + group_carrier: None, pending: None, } } @@ -191,6 +193,7 @@ impl ClientInfo { created_at: SystemTime::now(), docker_container: None, policy, + group_carrier: None, pending: Some(Arc::new(Notify::new())), } } @@ -207,6 +210,14 @@ impl ClientInfo { self.policy } + pub(crate) fn set_group_carrier(&mut self, carrier: u64) { + self.group_carrier = Some(carrier); + } + + pub(crate) fn group_carrier(&self) -> Option { + self.group_carrier + } + pub(crate) fn client_ip(&self) -> IpAddr { self.client_ip } diff --git a/members/nullnet-server/src/services/service_info.rs b/members/nullnet-server/src/services/service_info.rs index 98c23a7..2d9832e 100644 --- a/members/nullnet-server/src/services/service_info.rs +++ b/members/nullnet-server/src/services/service_info.rs @@ -584,7 +584,7 @@ impl RegisteredServiceInfo { replica.ip, replica.docker_container.clone(), ci.net_id(), - ci.policy(), + ci.group_carrier().is_some(), ) .await; } @@ -679,6 +679,24 @@ impl RegisteredServiceInfo { .count() } + /// Least-loaded reusable ingress carrier on this proxy. + pub(crate) fn group_carrier(&self, proxy_ip: IpAddr) -> Option { + let mut carriers = HashMap::new(); + for replica in &self.replicas { + for (client, info) in replica.clients.clients() { + if client.is_proxy() == Some(proxy_ip) + && let Some(carrier) = info.group_carrier() + { + *carriers.entry(carrier).or_insert(0usize) += 1; + } + } + } + carriers + .into_iter() + .min_by_key(|(carrier, users)| (*users, *carrier)) + .map(|(carrier, _)| carrier) + } + /// 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 30226a2..d04e908 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 edges are dedicated; later edges share a host-pair transport. +// Standard ingress groups seed carriers; overflow balances across them. // =========================================================================== const MAX_NETWORKS: &str = "max_networks"; #[tokio::test(flavor = "multi_thread", worker_threads = 4)] -async fn max_networks_shares_transport_across_concurrent_overflow() { +async fn max_networks_balances_groups_across_standard_carriers() { let config: ServicesToml = toml::from_str( r#"[[services]] name = "A" @@ -1851,18 +1851,18 @@ proxy_dependencies = [["B"]] let groups: Vec<_> = messages .iter() .filter_map(|message| match &message.message { - Some(net_message::Message::VxlanSetup(setup)) => setup.policy_transport.as_ref(), + Some(net_message::Message::VxlanSetup(setup)) => setup.group_transport.as_ref(), _ => None, }) .collect(); - assert_eq!(groups.len(), 190); + assert_eq!(groups.len(), 200); assert_eq!( groups .iter() .map(|group| group.id) .collect::>() .len(), - 190 + 200 ); assert_eq!( groups @@ -1870,7 +1870,7 @@ proxy_dependencies = [["B"]] .map(|group| group.encryption_key.clone()) .collect::>() .len(), - 190 + 200 ); assert_eq!( groups @@ -1878,8 +1878,14 @@ proxy_dependencies = [["B"]] .map(|group| group.generation) .collect::>() .len(), - 190 + 200 ); + let mut carrier_users = HashMap::new(); + for group in &groups { + *carrier_users.entry(group.carrier_id).or_insert(0usize) += 1; + } + assert_eq!(carrier_users.len(), 10); + assert!(carrier_users.values().all(|users| *users == 20)); drop(messages); let guard = server.services().read().await; let ServiceInfo::Registered(dep) = &stack_view(&guard)["B"] else { @@ -1937,16 +1943,26 @@ async fn max_networks_reuses_one_overflow_group_for_the_same_client_ip() { .filter(|message| matches!( &message.message, Some(net_message::Message::VxlanSetup(setup)) - if setup.policy_transport.is_some() + if setup.group_transport.is_some() )) .count(), - 1 + 2 ); + let groups: Vec<_> = messages + .iter() + .filter_map(|message| match &message.message { + Some(net_message::Message::VxlanSetup(setup)) => setup.group_transport.as_ref(), + _ => None, + }) + .collect(); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].carrier_id, groups[1].carrier_id); + assert_ne!(groups[0].encryption_key, groups[1].encryption_key); } /// Full lifecycle: /// 1. First client creates network (2 net IDs: proxy→A, A→B) -/// 2. Second client gets a policy group (3 IDs including the dependency) +/// 2. Second client gets an overflow 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] @@ -1992,17 +2008,13 @@ async fn max_networks_reuse_lifecycle() { panic!("A should be registered"); }; assert_eq!(reg_a.client_count(), 2, "A should have 2 proxy clients"); - // Every overflow connection is its own policy group and net id. + // Every overflow connection is its own group and net id. let net_ids: HashSet = reg_a .replicas() .iter() .flat_map(|r| r.clients().values().map(|ci| ci.net_id())) .collect(); - assert_eq!( - net_ids.len(), - 2, - "overflow clients must not share a policy group" - ); + assert_eq!(net_ids.len(), 2, "overflow clients must not share a group"); let ServiceInfo::Registered(reg_b) = &stack_view(&guard)["B"] else { panic!("B should be registered"); @@ -4512,7 +4524,7 @@ async fn an_ingress_session_opens_and_closes_a_row() { assert!(rows[0].ended_at.is_some(), "should have been closed"); } -/// The dedicated client and each overflow policy group have separate sessions. +/// The standard client and each overflow group have separate sessions. /// Expiring either must close exactly its own row. #[tokio::test] async fn max_networks_overflow_closes_each_client_session() { @@ -4535,7 +4547,7 @@ async fn max_networks_overflow_closes_each_client_session() { 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(), 2, "overflow has its own policy group"); + assert_eq!(net_ids.len(), 2, "overflow has its own group"); assert!(rows.iter().all(|r| r.ended_at.is_none())); // First client expires while the overflow client remains active. diff --git a/shared/policy.rs b/shared/policy.rs index 0056e55..678615e 100644 --- a/shared/policy.rs +++ b/shared/policy.rs @@ -1,7 +1,7 @@ //! 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_PORT: u16 = 4791; pub const GROUP: u32 = 0x4e70_0000; pub const MAX_GENERATION: u64 = (1 << 40) - 1; @@ -16,6 +16,7 @@ pub struct OutKey { #[derive(Clone, Copy, Default)] pub struct Rule { pub generation: u64, + pub vni: u32, pub local: u32, pub peer: u32, pub underlay_local: u32, From 09e6fdad59907feb6f45cb45aa2d27ae829157f9 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 28 Oct 2026 10:39:58 +0100 Subject: [PATCH 2/6] docs: describe reusable ingress VXLAN carriers --- CHANGELOG.md | 1 + README.md | 6 +++--- SETUP.md | 27 ++++++++++++++++----------- docs/architecture.md | 2 +- 4 files changed, 21 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fec400..1f1d870 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 +- Use independently keyed client groups for all encrypted ingress and balance multiplexed groups across reusable standard VXLAN carriers. - Keep routine daemon diagnostics debug-only to avoid release logging overhead under multiplexed load ([#215](https://github.com/NullNet-ai/nullnet/pull/215)). - Give each multiplexed cross-host ingress group its own IPsec key while retaining the shared VXLAN transport ([#215](https://github.com/NullNet-ai/nullnet/pull/215)). - Allow GeoIP/ASN enrichment and fallback database downloads to be disabled with `IPINFO_DISABLED` ([#215](https://github.com/NullNet-ai/nullnet/pull/215)). diff --git a/README.md b/README.md index 746e2ce..8045826 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ Three binaries coordinate over a gRPC control plane: ## What you get -- **On-demand tunnels** — point-to-point VXLAN links, built atomically across a whole - dependency chain, encrypted by default (MACsec / XFRM), and reclaimed once the connections - they carry are gone. +- **On-demand tunnels** — isolated VXLAN groups, built atomically across a whole dependency chain, + encrypted by default (MACsec / XFRM), and reclaimed once the connections they carry are gone. + Ingress groups can share a reusable carrier without sharing keys. - **Default-deny host firewall** — an eBPF classifier on every node's uplink NIC, with one global allowlist decided server-side so there's a single point of configuration. - **Ingress that knows what it's serving** — Let's Encrypt certificates with automatic diff --git a/SETUP.md b/SETUP.md index 033ea06..15c1e11 100644 --- a/SETUP.md +++ b/SETUP.md @@ -29,6 +29,10 @@ plus the shared gRPC interface. curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh ``` +Encrypted VXLAN ingress requires Linux VXLAN VNI filtering (`vnifilter`, available in mainline +Linux since 5.18). Nullnet uses it to run several metadata VXLAN carriers on UDP 4791 and assign +each live client group to exactly one carrier. + The repository should be cloned under `/root` so the provided `setup-*.sh` scripts and `.service` units work without changes. Run the setup scripts as root. The supplied systemd services also run as root; privileged commands execute directly without sudo. @@ -260,17 +264,17 @@ 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` is an optional hard cap on dedicated ingress networks for a proxy-reachable +- `max_networks` is an optional hard cap on ingress carrier VXLANs 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 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 and encryption key per client IP over a shared VXLAN. Cross-host groups use marked - per-group IPsec associations over the shared VXLAN; 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. + not count as recovery). Every client IP gets its own group, generation, encryption key, and + marked IPsec associations. A standard group seeds a VNI-filtered carrier; multiplexed groups are + assigned to the least-loaded live carrier, so both paths use the same dataplane and UDP 4791. + Packet metadata and endpoint maps preserve per-client isolation. Same-host groups use 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 @@ -441,14 +445,15 @@ details. Stop observation to restore the saved config and review added/unused backends. Applying updates only backend lists. Completed observations remain available; results cannot overwrite a configuration edited since activation. -VXLAN edges create fresh dedicated interfaces and use fixed TC redirects; -Nullnet shares UDP 47890, separate from Docker Swarm’s UDP 4789. Egress policy rules use priorities 1000–1015 and protocol 242; reserve these for Nullnet. +Dependency, backend-trigger, and egress VXLAN edges create dedicated interfaces on UDP 47890. +Encrypted ingress uses VNI-filtered group carriers on UDP 4791; each carrier can host multiple +independently keyed client groups. Both ports are separate from Docker Swarm's UDP 4789. Egress +policy rules use priorities 1000–1015 and protocol 242; reserve these for Nullnet. Reserve TC blocks 7801–7832 and interface names `nn-tc-0` through `nn-tc-15` -for Nullnet's shared classifiers. These down dummy interfaces carry no traffic; -each edge still has dedicated interfaces and encryption keys. Startup removes +for Nullnet's shared classifiers. These down dummy interfaces carry no traffic. Startup removes stale holders using their reserved name, group, and device type before rebuilding the classifiers. `br__` is the gateway interface name, not a Linux bridge. Startup keeps routed overlay traffic allowed by owned interface groups while preserving -the host's existing FORWARD policy. Pooling and policy multiplexing are deferred. +the host's existing FORWARD policy. The client and proxy services set `LimitNOFILE=524288`: fresh endpoints retain netlink sockets, and each proxied connection needs downstream and upstream sockets. Update both installed units and run `systemctl daemon-reload` when upgrading. diff --git a/docs/architecture.md b/docs/architecture.md index 885c73f..add7967 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ NullNet architecture is made of three components that coordinate over a gRPC con Each time a service is requested — through the proxy or by another service — the server walks the whole chain that request will travel.
If service A needs B, and B in turn needs C, that one request opens every link along the way at the same time, so the full path is ready atomically.
-Each link is a dedicated VXLAN tunnel, private between the two machines, and only stays open while it's being used; once detected as idle via a configurable timeout, the server tears it down. +Each link is an isolated VXLAN group and only stays open while it is being used; once detected as idle via a configurable timeout, the server tears it down. Encrypted ingress groups have independent keys and policy state while standard and multiplexed groups share the same reusable carrier implementation. The result: there is no broad "internal network" for an attacker to roam around inside if they break in somewhere.
The only paths that exist are the narrow, temporary ones carrying legitimate traffic as determined by the current network activity and defined topology.
From 18c0aee9de0031d9b01287fadb27aeabeab152bf Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 28 Oct 2026 11:54:26 +0100 Subject: [PATCH 3/6] perf: remove shared carrier lifecycle bottlenecks --- members/nullnet-client/src/commands/policy.rs | 192 +++++++++++------- .../nullnet-server/src/services/clients.rs | 100 ++++++++- .../src/services/service_info.rs | 17 +- 3 files changed, 227 insertions(+), 82 deletions(-) diff --git a/members/nullnet-client/src/commands/policy.rs b/members/nullnet-client/src/commands/policy.rs index cf6f2e6..a2b1aca 100644 --- a/members/nullnet-client/src/commands/policy.rs +++ b/members/nullnet-client/src/commands/policy.rs @@ -25,7 +25,7 @@ use std::{ os::{fd::AsRawFd, unix::fs::MetadataExt}, sync::{ Arc, LazyLock, Mutex, - atomic::{AtomicU32, Ordering}, + atomic::{AtomicU32, AtomicUsize, Ordering}, }, }; use tokio::sync::{Mutex as AsyncMutex, OnceCell, Semaphore}; @@ -67,11 +67,12 @@ struct Engine { edges: Mutex>, } struct Carrier { - state: AsyncMutex>, + state: AsyncMutex>>, } struct CarrierState { index: u32, - vnis: HashSet, + vnis: Mutex>, + operations: AtomicUsize, } struct Attachment { namespace: Arc, @@ -196,88 +197,135 @@ impl Engine { }) }) .clone(); - let mut state = carrier.state.lock().await; - if let Some(state) = state.as_mut() { - native_vxlan::add_vni(state.index, vni) - .await - .map_err(error)?; - state.vnis.insert(vni); - return Ok(state.index); - } - let name = format!("{VXLAN_PREFIX}{id:x}"); - let link = LinkVxlan::new(&name, 0) - .collect_metadata(true) - .vnifilter(true) - .gbp() - .learning(false) - .port(GROUP_PORT) - .mtu(1080) - .link_group(GROUP) - .build(); - handle.link().add(link).execute().await.map_err(error)?; - let index = get_link_by_name(handle, &name) - .await - .map_err(|e| e.to_str().to_owned())? - .header - .index; - let setup = async { - self.attach(&name, "nullnet_policy_in")?; - native_vxlan::add_vni(index, vni).await.map_err(error)?; - handle - .link() - .set(LinkUnspec::new_with_index(index).up().build()) - .execute() - .await - .map_err(error)?; - Ok::(index) - } - .await; - let index = match setup { - Ok(index) => index, - Err(setup_error) => { - let cleanup = handle - .link() - .del(index) - .execute() + let active = { + let mut state = carrier.state.lock().await; + if let Some(active) = state.as_ref() { + active.operations.fetch_add(1, Ordering::Relaxed); + active.clone() + } else { + let name = format!("{VXLAN_PREFIX}{id:x}"); + let link = LinkVxlan::new(&name, 0) + .collect_metadata(true) + .vnifilter(true) + .gbp() + .learning(false) + .port(GROUP_PORT) + .mtu(1080) + .link_group(GROUP) + .build(); + handle.link().add(link).execute().await.map_err(error)?; + let index = get_link_by_name(handle, &name) .await - .err() - .map(error) - .into_iter() - .collect(); - return Err(cleanup_error(setup_error, cleanup)); + .map_err(|e| e.to_str().to_owned())? + .header + .index; + let setup = async { + self.attach(&name, "nullnet_policy_in")?; + handle + .link() + .set(LinkUnspec::new_with_index(index).up().build()) + .execute() + .await + .map_err(error) + } + .await; + if let Err(setup_error) = setup { + let cleanup = handle + .link() + .del(index) + .execute() + .await + .err() + .map(error) + .into_iter() + .collect(); + return Err(cleanup_error(setup_error, cleanup)); + } + let active = Arc::new(CarrierState { + index, + vnis: Mutex::new(HashSet::new()), + operations: AtomicUsize::new(1), + }); + *state = Some(active.clone()); + active } }; - *state = Some(CarrierState { - index, - vnis: HashSet::from([vni]), - }); - Ok(index) + let result = native_vxlan::add_vni(active.index, vni) + .await + .map_err(error); + if result.is_ok() { + active.vnis.lock().unwrap().insert(vni); + } + let cleanup = self.finish_carrier_operation(id, &carrier, &active).await; + match result { + Ok(()) => cleanup.map(|()| active.index), + Err(setup_error) => Err(cleanup_error( + setup_error, + cleanup.err().into_iter().collect(), + )), + } } async fn release_carrier(&self, id: u64, vni: u32) -> Result<(), String> { let Some(carrier) = self.carriers.lock().unwrap().get(&id).cloned() else { return Ok(()); }; + let active = { + let state = carrier.state.lock().await; + let Some(active) = state.as_ref() else { + return Ok(()); + }; + active.operations.fetch_add(1, Ordering::Relaxed); + active.clone() + }; + let result = native_vxlan::remove_vni(active.index, vni) + .await + .map_err(error); + if result.is_ok() { + active.vnis.lock().unwrap().remove(&vni); + } + let cleanup = self.finish_carrier_operation(id, &carrier, &active).await; + match result { + Ok(()) => cleanup, + Err(remove_error) => Err(cleanup_error( + remove_error, + cleanup.err().into_iter().collect(), + )), + } + } + + async fn finish_carrier_operation( + &self, + id: u64, + carrier: &Arc, + active: &Arc, + ) -> Result<(), String> { + if active.operations.fetch_sub(1, Ordering::AcqRel) != 1 + || !active.vnis.lock().unwrap().is_empty() + { + return Ok(()); + } let mut state = carrier.state.lock().await; - let Some(active) = state.as_mut() else { + if state + .as_ref() + .is_none_or(|current| !Arc::ptr_eq(current, active)) + || active.operations.load(Ordering::Acquire) != 0 + || !active.vnis.lock().unwrap().is_empty() + { return Ok(()); - }; - native_vxlan::remove_vni(active.index, vni) + } + native_vxlan::remove_link(active.index) .await .map_err(error)?; - active.vnis.remove(&vni); - if active.vnis.is_empty() { - let index = active.index; - native_vxlan::remove_link(index).await.map_err(error)?; - *state = None; - let mut carriers = self.carriers.lock().unwrap(); - if carriers - .get(&id) - .is_some_and(|current| Arc::ptr_eq(current, &carrier)) - && Arc::strong_count(&carrier) == 2 - { - carriers.remove(&id); - } + *state = None; + drop(state); + let mut carriers = self.carriers.lock().unwrap(); + if carriers + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, carrier)) + && Arc::strong_count(carrier) == 2 + { + carriers.remove(&id); } Ok(()) } diff --git a/members/nullnet-server/src/services/clients.rs b/members/nullnet-server/src/services/clients.rs index 8bd56ab..1e8f236 100644 --- a/members/nullnet-server/src/services/clients.rs +++ b/members/nullnet-server/src/services/clients.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time::{Instant, SystemTime}; @@ -8,6 +8,7 @@ use tokio::sync::Notify; pub(super) struct Clients { /// Mapping from service client to client info. clients: HashMap, + group_carriers: HashMap>, } impl Clients { @@ -25,6 +26,24 @@ impl Clients { } None => None, }; + let previous_carrier = self + .clients + .get(&client) + .and_then(|info| client.is_proxy().zip(info.group_carrier())); + let next_carrier = client.is_proxy().zip(client_info.group_carrier()); + if previous_carrier != next_carrier { + if let Some((proxy, carrier)) = previous_carrier { + self.remove_group_carrier(proxy, carrier); + } + if let Some((proxy, carrier)) = next_carrier { + *self + .group_carriers + .entry(proxy) + .or_default() + .entry(carrier) + .or_default() += 1; + } + } self.clients.insert(client, client_info); // The edge is live now: release anything blocked on the reservation. if let Some(notify) = waiters { @@ -40,11 +59,40 @@ impl Clients { let Some(notify) = self.clients.get(client).and_then(|ci| ci.pending.clone()) else { return false; }; - self.clients.remove(client); + self.remove_client(client); notify.notify_waiters(); true } + pub(super) fn remove_client(&mut self, client: &Client) -> Option { + let info = self.clients.remove(client)?; + if let Some((proxy, carrier)) = client.is_proxy().zip(info.group_carrier()) { + self.remove_group_carrier(proxy, carrier); + } + Some(info) + } + + fn remove_group_carrier(&mut self, proxy: IpAddr, carrier: u64) { + let carriers = self + .group_carriers + .get_mut(&proxy) + .expect("group carrier proxy is indexed"); + let users = carriers + .get_mut(&carrier) + .expect("group carrier is indexed"); + *users -= 1; + if *users == 0 { + carriers.remove(&carrier); + } + if carriers.is_empty() { + self.group_carriers.remove(&proxy); + } + } + + pub(super) fn group_carriers(&self, proxy: IpAddr) -> Option<&BTreeMap> { + self.group_carriers.get(&proxy) + } + /// The live overlay IP for `client`, or `None` while the edge is only /// reserved. A reservation names no network yet, so answering with one /// hands out an upstream for a tunnel that does not exist. @@ -297,3 +345,51 @@ impl ClientInfo { self.created_at } } + +#[cfg(test)] +mod tests { + use super::*; + + fn reservation(ip: IpAddr, carrier: u64) -> ClientInfo { + let mut info = ClientInfo::reservation(ip, true); + info.set_group_carrier(carrier); + info + } + + #[test] + fn group_carrier_index_tracks_reservations_promotions_and_removals() { + let proxy = IpAddr::from(Ipv4Addr::new(192, 0, 2, 1)); + let client_ip = IpAddr::from(Ipv4Addr::new(198, 18, 0, 1)); + let first = Client::new("first".into(), Some(proxy)); + let second = Client::new("second".into(), Some(proxy)); + let third = Client::new("third".into(), Some(proxy)); + let mut clients = Clients::default(); + + clients.add_client(first.clone(), reservation(client_ip, 7)); + assert_eq!(clients.group_carriers(proxy).unwrap()[&7], 1); + + let mut promoted = ClientInfo::new( + client_ip, + Ipv4Addr::new(10, 0, 0, 3), + Ipv4Addr::new(10, 0, 0, 1), + 1, + 1, + None, + ); + promoted.set_group_carrier(7); + clients.add_client(first.clone(), promoted); + assert_eq!(clients.group_carriers(proxy).unwrap()[&7], 1); + + clients.add_client(second.clone(), reservation(client_ip, 7)); + clients.add_client(third.clone(), reservation(client_ip, 9)); + assert_eq!(clients.group_carriers(proxy).unwrap()[&7], 2); + assert_eq!(clients.group_carriers(proxy).unwrap()[&9], 1); + + assert!(clients.remove_pending_client(&second)); + assert_eq!(clients.group_carriers(proxy).unwrap()[&7], 1); + clients.remove_client(&first).unwrap(); + assert!(!clients.group_carriers(proxy).unwrap().contains_key(&7)); + clients.remove_client(&third).unwrap(); + assert!(clients.group_carriers(proxy).is_none()); + } +} diff --git a/members/nullnet-server/src/services/service_info.rs b/members/nullnet-server/src/services/service_info.rs index 2d9832e..351f4f5 100644 --- a/members/nullnet-server/src/services/service_info.rs +++ b/members/nullnet-server/src/services/service_info.rs @@ -575,7 +575,7 @@ impl RegisteredServiceInfo { } ci.remove_active_chains(1); if ci.active_chains() == 0 - && let Some(ci) = replica.clients.clients_mut().remove(client) + && let Some(ci) = replica.clients.remove_client(client) { orchestrator .send_net_teardown_kind( @@ -683,12 +683,13 @@ impl RegisteredServiceInfo { pub(crate) fn group_carrier(&self, proxy_ip: IpAddr) -> Option { let mut carriers = HashMap::new(); for replica in &self.replicas { - for (client, info) in replica.clients.clients() { - if client.is_proxy() == Some(proxy_ip) - && let Some(carrier) = info.group_carrier() - { - *carriers.entry(carrier).or_insert(0usize) += 1; - } + for (carrier, users) in replica + .clients + .group_carriers(proxy_ip) + .into_iter() + .flatten() + { + *carriers.entry(*carrier).or_insert(0usize) += users; } } carriers @@ -824,7 +825,7 @@ impl RegisteredServiceInfo { pub(crate) fn remove_client(&mut self, client: &Client) { for replica in &mut self.replicas { - if replica.clients.clients_mut().remove(client).is_some() { + if replica.clients.remove_client(client).is_some() { return; } } From 125c622950ce7c9fa2dcfc9ad79e3358755bb7f7 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 28 Oct 2026 13:44:25 +0100 Subject: [PATCH 4/6] fix: complete reusable carrier lifecycle --- members/nullnet-client/src/commands/policy.rs | 93 +++++++++++++------ members/nullnet-client/src/control_channel.rs | 4 +- 2 files changed, 65 insertions(+), 32 deletions(-) diff --git a/members/nullnet-client/src/commands/policy.rs b/members/nullnet-client/src/commands/policy.rs index a2b1aca..98ec798 100644 --- a/members/nullnet-client/src/commands/policy.rs +++ b/members/nullnet-client/src/commands/policy.rs @@ -213,10 +213,9 @@ impl Engine { .mtu(1080) .link_group(GROUP) .build(); - handle.link().add(link).execute().await.map_err(error)?; - let index = get_link_by_name(handle, &name) + let index = add_link_echo(handle, link) .await - .map_err(|e| e.to_str().to_owned())? + .map_err(error)? .header .index; let setup = async { @@ -271,10 +270,29 @@ impl Engine { return Ok(()); }; let active = { - let state = carrier.state.lock().await; + let mut state = carrier.state.lock().await; let Some(active) = state.as_ref() else { return Ok(()); }; + let last = active.operations.load(Ordering::Acquire) == 0 && { + let vnis = active.vnis.lock().unwrap(); + vnis.len() == 1 && vnis.contains(&vni) + }; + if last { + native_vxlan::remove_link(active.index) + .await + .map_err(error)?; + *state = None; + let mut carriers = self.carriers.lock().unwrap(); + if carriers + .get(&id) + .is_some_and(|current| Arc::ptr_eq(current, &carrier)) + && Arc::strong_count(&carrier) == 2 + { + carriers.remove(&id); + } + return Ok(()); + } active.operations.fetch_add(1, Ordering::Relaxed); active.clone() }; @@ -318,7 +336,6 @@ impl Engine { .await .map_err(error)?; *state = None; - drop(state); let mut carriers = self.carriers.lock().unwrap(); if carriers .get(&id) @@ -795,7 +812,7 @@ pub(crate) fn heartbeat_valid() -> Option { (deadline != 0).then(|| deadline > clock()) } -pub(crate) async fn heartbeat(server_epoch: &[u8]) -> Result<(), String> { +pub(crate) async fn heartbeat(server_epoch: &[u8], cache: &BridgeIpCache) -> Result<(), String> { if server_epoch.len() != 16 { return Err("Invalid policy server epoch".into()); } @@ -815,7 +832,7 @@ pub(crate) async fn heartbeat(server_epoch: &[u8]) -> Result<(), String> { .unwrap_or_default(); let mut first_error = None; for name in names { - if let Err(error) = retire(&name).await { + if let Err(error) = retire(&name, cache).await { first_error.get_or_insert(error); } } @@ -886,7 +903,7 @@ pub(crate) async fn ready(id: u32) -> Result { Ok(found) } -pub(crate) async fn retire(name: &str) -> Result { +pub(crate) async fn retire(name: &str, cache: &BridgeIpCache) -> Result { let engine = ENGINE.lock().await.clone(); let Some(engine) = engine else { return Ok(false); @@ -961,6 +978,10 @@ pub(crate) async fn retire(name: &str) -> Result { for (name, edge) in removed { edges.entry(name).or_insert(edge); } + } else { + for (name, _) in removed { + cache.remove_overlay(&name); + } } result } @@ -1199,14 +1220,14 @@ mod tests { message } - async fn wait_for(path: &str) { + async fn wait_for(path: &str, cache: &BridgeIpCache) { 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(); + heartbeat(&[1; 16], cache).await.unwrap(); tokio::time::sleep(std::time::Duration::from_millis(100)).await; } } @@ -1226,10 +1247,10 @@ mod tests { super::super::vxlan_environment::initialize_test() .await .unwrap(); - heartbeat(&[1; 16]).await.unwrap(); + let cache = BridgeIpCache::new(); + heartbeat(&[1; 16], &cache).await.unwrap(); let handle = RtNetLinkHandle::new().unwrap(); recover(&handle.handle).await.unwrap(); - let cache = BridgeIpCache::new(); let keys = if side == "s" { [0x42, 0x43, 0x44] } else { @@ -1251,7 +1272,7 @@ mod tests { messages[1].group_transport.as_ref().unwrap().encryption_key ); std::fs::write("/tmp/nn-policy-cross-ready", "ok").unwrap(); - wait_for("/tmp/nn-policy-cross-go").await; + wait_for("/tmp/nn-policy-cross-go", &cache).await; for net_id in [711, 712, 713] { assert!(ready(net_id).await.unwrap()); @@ -1262,7 +1283,7 @@ mod tests { None }; std::fs::write("/tmp/nn-policy-cross-activated", "ok").unwrap(); - wait_for("/tmp/nn-policy-cross-traffic-go").await; + wait_for("/tmp/nn-policy-cross-traffic-go", &cache).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) @@ -1277,16 +1298,20 @@ mod tests { finish_tcp_listener(tcp.unwrap()).await; } - assert!(retire(&format!("ns_711_{side}")).await.unwrap()); + assert!(retire(&format!("ns_711_{side}"), &cache).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(); + wait_for("/tmp/nn-policy-cross-survivor-go", &cache).await; + heartbeat(&[1; 16], &cache).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; + wait_for("/tmp/nn-policy-cross-cleanup-go", &cache).await; for net_id in [712, 713] { - assert!(retire(&format!("ns_{net_id}_{side}")).await.unwrap()); + assert!( + retire(&format!("ns_{net_id}_{side}"), &cache) + .await + .unwrap() + ); } destroy_all(&handle).await.unwrap(); for message in messages { @@ -1302,9 +1327,9 @@ mod tests { super::super::vxlan_environment::initialize_test() .await .unwrap(); - heartbeat(&[1; 16]).await.unwrap(); - let handle = RtNetLinkHandle::new().unwrap(); let cache = BridgeIpCache::new(); + heartbeat(&[1; 16], &cache).await.unwrap(); + let handle = RtNetLinkHandle::new().unwrap(); for name in ["nnpc1", "nnps1", "nnpc2", "nnps2"] { let namespace = super::super::endpoint_namespace::create(name) .await @@ -1329,11 +1354,19 @@ mod tests { assert!(tcp_echo(Some("nnpc2"), server_ip(702), 24702).await); finish_tcp_listener(tcp).await; - assert!(retire("ns_701_c").await.unwrap()); + let client_701 = Ipv4Addr::from(u32::from(server_ip(701)) + 2); + assert_eq!(cache.get(server_ip(701)).as_deref(), Some("nnps1")); + assert_eq!(cache.get(client_701).as_deref(), Some("nnpc1")); + assert!(retire("ns_701_c", &cache).await.unwrap()); + assert!(cache.get(server_ip(701)).is_none()); + assert!(cache.get(client_701).is_none()); assert!(!ping("nnpc1", server_ip(701)).await); - heartbeat(&[2; 16]).await.unwrap(); + let client_702 = Ipv4Addr::from(u32::from(server_ip(702)) + 2); + heartbeat(&[2; 16], &cache).await.unwrap(); + assert!(cache.get(server_ip(702)).is_none()); + assert!(cache.get(client_702).is_none()); assert!(!ping("nnpc2", server_ip(702)).await); - assert!(!retire("ns_702_s").await.unwrap()); + assert!(!retire("ns_702_s", &cache).await.unwrap()); destroy_all(&handle).await.unwrap(); for name in ["nnpc1", "nnps1", "nnpc2", "nnps2"] { super::super::endpoint_namespace::remove(name) @@ -1348,9 +1381,9 @@ mod tests { super::super::vxlan_environment::initialize_test() .await .unwrap(); - heartbeat(&[1; 16]).await.unwrap(); - let handle = RtNetLinkHandle::new().unwrap(); let cache = BridgeIpCache::new(); + heartbeat(&[1; 16], &cache).await.unwrap(); + let handle = RtNetLinkHandle::new().unwrap(); 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; @@ -1374,7 +1407,7 @@ mod tests { assert!(tcp_echo(None, destination, 24703).await); finish_tcp_listener(tcp).await; - assert!(retire("ns_703_c").await.unwrap()); + assert!(retire("ns_703_c", &cache).await.unwrap()); assert!(!tcp_echo(None, destination, 24703).await); destroy_all(&handle).await.unwrap(); } @@ -1426,7 +1459,7 @@ mod tests { .await .unwrap(); assert!(Arc::ptr_eq(&first, &second)); - heartbeat(&[1; 16]).await.unwrap(); + heartbeat(&[1; 16], &cache).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); @@ -1457,8 +1490,8 @@ mod tests { .incoming .insert(rule.local, rule, 0) .unwrap(); - assert!(retire("ns_192_c").await.unwrap()); - assert!(!retire("ns_192_c").await.unwrap()); + assert!(retire("ns_192_c", &cache).await.unwrap()); + assert!(!retire("ns_192_c", &cache).await.unwrap()); assert!(get_link_by_name(&handle.handle, "nng_1").await.is_err()); assert!(get_link_by_name(&handle.handle, "nng_2").await.is_ok()); destroy_all(&handle).await.unwrap(); diff --git a/members/nullnet-client/src/control_channel.rs b/members/nullnet-client/src/control_channel.rs index bdfa45e..beaf4ae 100644 --- a/members/nullnet-client/src/control_channel.rs +++ b/members/nullnet-client/src/control_channel.rs @@ -135,7 +135,7 @@ pub(crate) async fn control_channel( let firewall_peers = firewall_peers.clone(); match message.message { Some(net_message::Message::PolicyHeartbeat(heartbeat)) => { - crate::commands::policy::heartbeat(&heartbeat.server_epoch) + crate::commands::policy::heartbeat(&heartbeat.server_epoch, &bridge_cache) .await .handle_err(location!())?; } @@ -864,7 +864,7 @@ async fn handle_vxlan_teardown( ) { let ack_id = message.msg_id.clone(); let group_cleanup_ok = if message.group { - match crate::commands::policy::retire(&message.ns_name).await { + match crate::commands::policy::retire(&message.ns_name, &bridge_cache).await { Ok(_) => true, Err(error) => { eprintln!("Group revocation failed: {error}"); From 843b65944f62a3e3427e5ed8166fc052b90cf08c Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 28 Oct 2026 13:44:25 +0100 Subject: [PATCH 5/6] docs: clarify reusable carrier scope --- README.md | 6 +++--- docs/architecture.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8045826..c645ade 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ Three binaries coordinate over a gRPC control plane: ## What you get -- **On-demand tunnels** — isolated VXLAN groups, built atomically across a whole dependency chain, - encrypted by default (MACsec / XFRM), and reclaimed once the connections they carry are gone. - Ingress groups can share a reusable carrier without sharing keys. +- **On-demand tunnels** — encrypted links built atomically across a whole dependency chain and + reclaimed once the connections they carry are gone. Encrypted ingress uses isolated client + groups that can share a reusable VXLAN carrier without sharing keys. - **Default-deny host firewall** — an eBPF classifier on every node's uplink NIC, with one global allowlist decided server-side so there's a single point of configuration. - **Ingress that knows what it's serving** — Let's Encrypt certificates with automatic diff --git a/docs/architecture.md b/docs/architecture.md index add7967..d927cb9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ NullNet architecture is made of three components that coordinate over a gRPC con Each time a service is requested — through the proxy or by another service — the server walks the whole chain that request will travel.
If service A needs B, and B in turn needs C, that one request opens every link along the way at the same time, so the full path is ready atomically.
-Each link is an isolated VXLAN group and only stays open while it is being used; once detected as idle via a configurable timeout, the server tears it down. Encrypted ingress groups have independent keys and policy state while standard and multiplexed groups share the same reusable carrier implementation. +Each link is isolated and only stays open while it is being used; once detected as idle via a configurable timeout, the server tears it down. Encrypted ingress groups have independent keys and policy state while standard and multiplexed groups share the same reusable VXLAN carrier implementation; dependency, backend-trigger, and egress links retain dedicated VXLAN interfaces. The result: there is no broad "internal network" for an attacker to roam around inside if they break in somewhere.
The only paths that exist are the narrow, temporary ones carrying legitimate traffic as determined by the current network activity and defined topology.
From 44271c75b7a68e1508580ca4ed0906e9492422d0 Mon Sep 17 00:00:00 2001 From: GyulyVGC Date: Wed, 28 Oct 2026 15:12:39 +0100 Subject: [PATCH 6/6] docs: link reusable carriers changelog entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f1d870..5c34ff6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +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 -- Use independently keyed client groups for all encrypted ingress and balance multiplexed groups across reusable standard VXLAN carriers. +- Use independently keyed client groups for all encrypted ingress and balance multiplexed groups across reusable standard VXLAN carriers ([#216](https://github.com/NullNet-ai/nullnet/pull/216)). - Keep routine daemon diagnostics debug-only to avoid release logging overhead under multiplexed load ([#215](https://github.com/NullNet-ai/nullnet/pull/215)). - Give each multiplexed cross-host ingress group its own IPsec key while retaining the shared VXLAN transport ([#215](https://github.com/NullNet-ai/nullnet/pull/215)). - Allow GeoIP/ASN enrichment and fallback database downloads to be disabled with `IPINFO_DISABLED` ([#215](https://github.com/NullNet-ai/nullnet/pull/215)).