diff --git a/CHANGELOG.md b/CHANGELOG.md index 5db305b..a1a5532 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +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 +- 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/SETUP.md b/SETUP.md index 588c56a..ac6bb24 100644 --- a/SETUP.md +++ b/SETUP.md @@ -256,6 +256,16 @@ 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 + 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 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 diff --git a/ebpf/src/main.rs b/ebpf/src/main.rs index f5be322..9f9caaa 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}, @@ -48,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; @@ -159,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 new file mode 100644 index 0000000..ec09288 --- /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, XFRM_ROUTE_BIT}; + +#[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 | XFRM_ROUTE_BIT); + 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..b2dd2d5 100644 --- a/members/nullnet-client/src/commands/mod.rs +++ b/members/nullnet-client/src/commands/mod.rs @@ -16,9 +16,12 @@ 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; +mod xfrm_shards; pub(crate) async fn setup_br0(rtnetlink_handle: &RtNetLinkHandle) { // create the bridge @@ -114,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, @@ -151,6 +169,12 @@ 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!())?; vxlan_recovery::recover(&rtnetlink_handle.handle) .await .handle_err(location!())?; @@ -171,31 +195,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 +226,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 +324,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..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; @@ -9,6 +10,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,9 +20,11 @@ static EDGES: LazyLock>> = LazyLock::new(|| Mutex::new( #[derive(Clone, Copy)] struct Edge { id: u32, + generation: u32, local: Ipv4Addr, remote: Ipv4Addr, port: u16, + if_id: u32, } impl Edge { fn mark(self) -> u32 { @@ -29,9 +33,29 @@ 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 } + 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() @@ -45,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 { @@ -70,13 +108,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) @@ -90,9 +122,16 @@ fn state( 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 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)); @@ -100,6 +139,7 @@ fn state( 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(); @@ -116,14 +156,21 @@ fn state( 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())); } - socket.request(16, CREATE, &body)?; - Ok(()) + 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) } fn policy_mark(edge: Edge, inbound: bool) -> Vec { @@ -133,25 +180,42 @@ 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() + }) } } -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 { (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([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)); + if !inbound && edge.multiplexed() { + body.extend(attr(31, &edge.if_id.to_ne_bytes())); + } + 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, @@ -167,9 +231,42 @@ fn policy(socket: &mut NativeSocket, edge: Edge, inbound: bool, delete: bool) -> 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)); - socket.request(19, CREATE, &body)?; + if !inbound && edge.multiplexed() { + body.extend(attr(31, &edge.if_id.to_ne_bytes())); + } + (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(()) } @@ -178,22 +275,30 @@ pub(super) fn mark(id: u32) -> u32 { } pub(super) async fn install_port( + owner: u32, id: u32, - local: Ipv4Addr, - remote: Ipv4Addr, + generation: u32, + hosts: (Ipv4Addr, Ipv4Addr), key: [u8; 32], port: u16, + if_id: u32, ) -> io::Result<()> { let edge = Edge { id, - local, - remote, + generation, + 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 _)?, @@ -204,33 +309,74 @@ pub(super) async fn install_port( }) .await?; } - owners.insert(id); - 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) - }) - .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(()); }; - 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) - }) - .await?; let owners = edge.owners(); let mut owners = owners.lock().await; - if owners.contains(&id) && owners.len() == 1 { + 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( &mut NativeSocket::new(libc::NETLINK_XFRM as _)?, edge, @@ -238,11 +384,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(&owner); + EDGES.lock().unwrap().remove(&owner); + Ok(()) } - owners.remove(&id); - EDGES.lock().unwrap().remove(&id); - Ok(()) } fn missing_ok(result: io::Result>) -> io::Result<()> { @@ -257,13 +409,23 @@ 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 { id: 101, + generation: 7, 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, @@ -273,11 +435,104 @@ 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!(selector(a.local, a.remote, a.port).len(), 56); + 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, 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))); } } @@ -312,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)); } } @@ -331,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 new file mode 100644 index 0000000..46aa608 --- /dev/null +++ b/members/nullnet-client/src/commands/policy.rs @@ -0,0 +1,1241 @@ +//! 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, +} +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 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), + key, + POLICY_PORT, + if_id, + ) + .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, + }, + ); + } else { + maps.outgoing.insert(key, rule, 0).map_err(error)?; + edges.insert( + message.ns_name.clone(), + PolicyEdge { + key, + rule, + attachment: output.clone(), + local: true, + }, + ); + } + } 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, + }, + ); + } + Ok(()) + })() + }; + map_result?; + 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(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); + } + } + *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(); + 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) +} + +pub(crate) async fn retire(name: &str) -> Result { + let engine = ENGINE.lock().await.clone(); + let Some(engine) = engine else { + return Ok(false); + }; + 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 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)); + } + (removed, map_error) + }; + let result = match map_error { + Some(value) => Err(value), + 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(); + 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] + } 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(); + super::super::xfrm_shards::recover(&handle.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, + }, + ); + 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..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,10 +370,12 @@ async fn activate(endpoint: Arc, params: &VxlanSetupParams) -> Result< } else { super::native_xfrm::install_port( params.vxlan_id, - params.local_ip, - params.remote_ip, + params.xfrm_transport_id, + 0, + (params.local_ip, params.remote_ip), key, params.dstport, + 0, ) .await .handle_err(location!())?; @@ -713,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(), @@ -817,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}"), @@ -912,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 new file mode 100644 index 0000000..fe2bc0d --- /dev/null +++ b/members/nullnet-client/src/commands/vxlan_environment.rs @@ -0,0 +1,133 @@ +//! 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; + } + } + 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) +} + +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/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 6ae8f6e..b6c4668 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,12 +553,15 @@ 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(); 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(), @@ -526,8 +581,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 +726,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 +851,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 +895,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 +940,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..45129c0 100644 --- a/members/nullnet-client/src/main.rs +++ b/members/nullnet-client/src/main.rs @@ -139,6 +139,13 @@ async fn main() -> Result<(), Error> { setup_br0(&rtnetlink_handle).await; } + if net_type.net() == Net::Vxlan { + commands::initialize_policy_shards(&rtnetlink_handle).await?; + 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..23fbdbb 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; @@ -210,22 +222,21 @@ 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 // `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 cross-host XFRM carrier. Absent means legacy per-edge transport. + optional uint32 xfrm_transport_id = 18; } message VxlanTeardown { @@ -243,6 +254,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 { @@ -295,6 +307,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 a6871e6..23073dc 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 { @@ -157,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 @@ -176,6 +190,11 @@ 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, + /// 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 { @@ -201,6 +220,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 { @@ -271,6 +292,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..b3f410d --- /dev/null +++ b/members/nullnet-server/src/congestion.rs @@ -0,0 +1,945 @@ +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(10); +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 { + 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, 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, + 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, + last_observed: Instant, + history: VecDeque, +} + +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, + 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) { + 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.last_observed = now; + 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; + + 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 { + 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; + 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; + 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, + } + } +} + +#[derive(Clone, Copy)] +struct ControlTransition { + at: Instant, + mode: ControlMode, + source_proxy: IpAddr, +} + +struct HostRate { + rate: Option, + tokens: f64, + last_refill: Instant, + last_denied: Option, + 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 }, +} + +pub(crate) struct AdmissionDecision { + pub(crate) dedicated: bool, + pub(crate) transitions: Vec, +} + +impl HostRate { + fn new(now: Instant) -> Self { + Self { + rate: None, + tokens: 0.0, + last_refill: now, + last_denied: None, + 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) { + while self + .completions + .front() + .is_some_and(|at| now.duration_since(*at) > COMPLETION_HISTORY) + { + self.completions.pop_front(); + } + } + + fn record_completion(&mut self, now: Instant) { + self.trim_completions(now); + if self + .completions + .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( + &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 != 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 }); + } + } + Trend::Healthy => { + if self.rate.is_some() { + self.healthy_windows = self.healthy_windows.saturating_add(1); + 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 + .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; + 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 => { + 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.denied(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.record_completion(now); + if proxy == destination { + break; + } + } + } + + 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)] +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 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(); + 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); + 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)); + 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)); + 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( + 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(1.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(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), + proxy, + PressureView { + sequence: 11, + trend: Trend::Healthy, + }, + start + Duration::from_secs(13), + ), + Some(RateTransition::Recovered { host: ip(2) }) + ); + 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)); + } +} diff --git a/members/nullnet-server/src/events.rs b/members/nullnet-server/src/events.rs index f9c8220..0150bdc 100644 --- a/members/nullnet-server/src/events.rs +++ b/members/nullnet-server/src/events.rs @@ -233,6 +233,15 @@ pub(crate) enum Event { limit: u32, timestamp: u64, }, + IngressCongestionControlChanged { + node_ip: String, + active: bool, + timestamp: u64, + }, + IngressCongestionRecoveryStarted { + node_ip: String, + timestamp: u64, + }, NetIdPoolExhausted { service: String, client_ip: String, @@ -563,6 +572,8 @@ 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::IngressCongestionRecoveryStarted { .. } => "ingress_congestion_recovery_started", Self::NetIdPoolExhausted { .. } => "net_id_pool_exhausted", Self::ProxyChainSetupFailed { .. } => "proxy_chain_setup_failed", Self::BackendTriggerSetupBailed { .. } => "backend_trigger_setup_bailed", @@ -628,6 +639,9 @@ impl Event { Self::DeviceEventBypassChanged { available: false, .. } => Severity::Warning, + Self::IngressCongestionControlChanged { active: true, .. } => Severity::Warning, + Self::IngressCongestionControlChanged { active: false, .. } => Severity::Info, + Self::IngressCongestionRecoveryStarted { .. } => Severity::Info, Self::UiTlsCertificateUnavailable { using_self_signed, .. } => { @@ -957,6 +971,21 @@ 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 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 253f512..60ba48e 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; @@ -111,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/net.rs b/members/nullnet-server/src/net.rs index 6a1c4da..a8b1c85 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}; @@ -34,9 +34,11 @@ 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, + policy_transport: Option, ) -> Option<(Ipv4Addr, NetMessage)>; #[allow(clippy::too_many_arguments)] @@ -49,6 +51,7 @@ pub(crate) trait NetExt { remote_ip: IpAddr, dstport: Option, msg_id: String, + policy: bool, ) -> NetMessage; } @@ -63,9 +66,11 @@ 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, + policy_transport: Option, ) -> Option<(Ipv4Addr, NetMessage)> { match self { Net::Vlan => vlan_setup( @@ -86,9 +91,11 @@ impl NetExt for Net { docker_containers, dnat_port, encryption_key, + xfrm_transport_id, dstport.unwrap_or(u32::from(DEFAULT_VXLAN_DSTPORT)), encrypted, egress, + policy_transport, ), } } @@ -102,6 +109,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 +129,7 @@ impl NetExt for Net { remote_ip: remote_ip.to_string(), dstport: u32::from(dstport.unwrap_or(DEFAULT_VXLAN_DSTPORT)), msg_id, + policy, })), }, } @@ -186,9 +195,11 @@ fn vxlan_setup( docker_containers: (Option, Option), dnat_port: Option, encryption_key: [u8; 32], + xfrm_transport_id: Option, 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 +280,8 @@ fn vxlan_setup( egress_steer, egress_intercept, encrypted, + policy_transport, + xfrm_transport_id, })), }, )) @@ -320,9 +333,11 @@ mod tests { (None, None), None, key, + None, 4789, false, EgressRole::None, + None, ) .unwrap(); let (client_addr, _) = vxlan_setup( @@ -334,9 +349,11 @@ mod tests { (None, None), None, key, + None, 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..95a2f6d 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, @@ -67,6 +68,8 @@ pub(crate) struct NullnetGrpcImpl { /// are serialized rather than each building the same chain. See /// [`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, @@ -84,6 +87,40 @@ 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), +} + +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 + .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 +450,8 @@ 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())), + 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, @@ -449,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; @@ -526,6 +577,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 +608,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 +644,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 +695,20 @@ 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 = *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; let reuse = services.get_mut(&stack).and_then(|stack_map| { let ServiceInfo::Registered(reg) = stack_map.get_mut(service_name)? else { @@ -703,12 +785,119 @@ 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::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" + ); + (*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) + .new_proxy_chain( + ProxyChainRequest { + stack: &stack, + service_name, + proxy_ip, + client_ip, + replica: &replica, + policy: policy_overflow, + }, + dedicated_admission.take(), + ) .await { - Ok(Some(response)) => return Ok(response.into_inner()), - Ok(None) if attempt == 0 => {} + Ok(Some(response)) => { + 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 + .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_count = current; + } + } Ok(None) => { self.orchestrator .events @@ -833,39 +1022,24 @@ 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, + 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 = ( + request.service_name.to_string(), + request.client_ip.to_string(), + request.proxy_ip, + ); let outcome = self .setup_proxy_chain( - stack, - service_name, - proxy_ip, - client_ip, - service_ip, - service_docker.as_deref(), + request.stack, + &key, + request.replica.ip, + request.replica.docker.as_deref(), + request.policy, + admission, ) .await?; @@ -879,10 +1053,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, @@ -929,29 +1128,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 +1509,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(_) ); @@ -1678,6 +1882,10 @@ impl NullnetGrpcImpl { &self.match_index } + pub(crate) fn congestion(&self) -> &Arc { + &self.congestion + } + pub(crate) fn orchestrator(&self) -> &Orchestrator { &self.orchestrator } @@ -1773,13 +1981,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 +1998,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 +2006,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 +2128,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 +2150,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 +2202,8 @@ async fn run_net_chain_setup( EdgeTarget::Dependency, entry_port, false, + false, + None, ) .await; match outcome { @@ -2158,6 +2382,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 +2434,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 +2463,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 +2510,34 @@ 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(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(); + 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(); @@ -2295,9 +2550,11 @@ async fn setup_edge( (cd, sd), None, encryption_key, + xfrm_transport_id, dstport, encrypted, server_egress, + server_policy, ); let orch2 = orchestrator.clone(); let cd = client_docker.clone(); @@ -2310,9 +2567,11 @@ async fn setup_edge( (cd, sd), backend_entry_port, encryption_key, + xfrm_transport_id, dstport, encrypted, client_egress, + client_policy, ); let (server_ok, client_ok) = tokio::join!(server_res, client_res); @@ -2320,10 +2579,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 +2596,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 +2645,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 +2653,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 +2686,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 +2762,8 @@ impl NullnetGrpcImpl { port_mappings, 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/src/orchestrator.rs b/members/nullnet-server/src/orchestrator.rs index 77e5ad7..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, - net_message, + 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::{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,10 @@ 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, pub(crate) sessions: SessionStore, } @@ -178,6 +183,10 @@ 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()), events, } @@ -195,7 +204,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; @@ -331,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 @@ -345,9 +367,11 @@ impl Orchestrator { dockers.clone(), None, encryption_key, + xfrm_transport_id, dstport, encrypted, EgressRole::Intercept, + None, ); let init_res = self.send_net_setup( initiator_ip, @@ -357,9 +381,11 @@ impl Orchestrator { dockers, None, encryption_key, + xfrm_transport_id, dstport, encrypted, EgressRole::Steer, + None, ); let (proxy_ok, init_ok) = tokio::join!(proxy_res, init_res); @@ -968,9 +994,11 @@ impl Orchestrator { docker_containers: (Option, Option), dnat_port: Option, encryption_key: [u8; 32], + xfrm_transport_id: Option, dstport: Option, encrypted: bool, egress: EgressRole, + policy_transport: Option, ) -> Option { self.send_control(dest, |msg_id| { NET_TYPE.setup( @@ -982,9 +1010,11 @@ impl Orchestrator { docker_containers, dnat_port, encryption_key, + xfrm_transport_id, dstport, encrypted, egress, + policy_transport, ) }) .await @@ -1095,6 +1125,76 @@ impl Orchestrator { self.net_id_pool.lock().await.allocate() } + 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, + encryption_key: encryption_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 +1248,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 +1275,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 +1626,34 @@ mod teardown_ack_tests { } } +#[cfg(test)] +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 overflow_edges_share_host_pair_transport_but_not_generation() { + let orchestrator = Orchestrator::new(); + 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); + } +} + #[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..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. -// Two proxy clients on the same proxy: second reuses the first's network. +// 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_balances_shared_networks_under_concurrent_requests() { +async fn max_networks_shares_transport_across_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,53 @@ 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(), + 1 + ); + assert_eq!( + groups + .iter() + .map(|group| group.encryption_key.clone()) + .collect::>() + .len(), + 1 + ); + assert_eq!( + groups + .iter() + .map(|group| group.generation) + .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 +1897,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 +1976,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 +1992,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 +2000,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 +2042,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 +2074,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 +2087,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 +4512,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 +4535,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; 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 8d4206d..e5addf7 100644 --- a/members/nullnet-server/ui/src/lib/eventKinds.ts +++ b/members/nullnet-server/ui/src/lib/eventKinds.ts @@ -37,6 +37,8 @@ export const EVENT_KIND_GROUPS = defineEventKindGroups([ 'edge_promotion_lost', '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 561208c..b301242 100644 --- a/members/nullnet-server/ui/src/pages/Events.tsx +++ b/members/nullnet-server/ui/src/pages/Events.tsx @@ -72,6 +72,12 @@ 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 '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 73edcb2..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 { @@ -170,6 +206,8 @@ 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: '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 } diff --git a/shared/policy.rs b/shared/policy.rs new file mode 100644 index 0000000..89730bc --- /dev/null +++ b/shared/policy.rs @@ -0,0 +1,39 @@ +//! 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 XFRM_ROUTE_BIT: u32 = 0x8000_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, +}