diff --git a/src/ipv6tun/mod.rs b/src/ipv6tun/mod.rs index 4c75dc00..47379156 100644 --- a/src/ipv6tun/mod.rs +++ b/src/ipv6tun/mod.rs @@ -10,6 +10,7 @@ pub mod dns; pub mod icmp; pub mod icmp_rate_limit; pub mod ipv6_shim; +pub(crate) mod outbound; pub mod tcp_mss; pub mod tun; diff --git a/src/ipv6tun/outbound.rs b/src/ipv6tun/outbound.rs new file mode 100644 index 00000000..0c51b6b7 --- /dev/null +++ b/src/ipv6tun/outbound.rs @@ -0,0 +1,302 @@ +//! Host-side half of forwarding an IPv6 packet read from the TUN. +//! +//! This side checks the packet, answers the host with ICMPv6 when the mesh +//! cannot take it, and turns the destination address into the 15-byte +//! prefix the mesh resolves. What happens to the packet inside the mesh +//! (session lookup, queueing while a session is set up, discovery) is the +//! mesh's business, reached through the [`Mesh`] trait. +//! +//! The decisions are the synchronous functions [`admit`] and +//! [`path_too_big`]; [`forward`] drives them against a [`Mesh`]. + +use super::icmp::{IcmpContext, effective_ipv6_mtu}; +use std::future::Future; + +/// What the outbound path needs from the mesh side. +pub(crate) trait Mesh { + /// The mesh's handle on a resolved destination, passed back to `send`. + type Dest; + + /// Largest IPv6 packet, header included, the mesh carries on its + /// narrowest transport. + fn ipv6_mtu(&self) -> u16; + + /// Resolve a destination from bytes 1-15 of its IPv6 address, with the + /// session's current path MTU if a session to it is established. + fn resolve(&mut self, prefix: &[u8; 15]) -> Option>; + + /// Send the packet on the destination's session, or hold it while one + /// is set up. + fn send(&mut self, dest: Self::Dest, packet: Vec) -> impl Future + Send; + + /// Borrow the sender of ICMPv6 replies to the host. + fn icmp(&mut self) -> IcmpContext<'_>; +} + +/// A destination the mesh resolved from an address prefix. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Route { + /// The mesh's handle on the destination. + pub dest: D, + /// Current path MTU of an established session to it, `None` when no + /// session is established or it has no path MTU state. + pub path_mtu: Option, +} + +/// What the mesh did with a packet handed to [`Mesh::send`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Outcome { + /// Handed to the established session. A send error is logged by the + /// mesh and not answered to the host. + Sent, + /// Held until the destination's session is established. + Queued, + /// Refused because the session table is full; the packet comes back so + /// the host can be told the destination is unreachable. + TableFull(Vec), +} + +/// The verdict on a packet read from the TUN, before the mesh is asked. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Admit { + /// Not an IPv6 packet, or shorter than its fixed header. + Drop, + /// Larger than the mesh carries; answer Packet Too Big with this MTU. + TooBig(u32), + /// Forward to the destination with this address prefix. + Forward([u8; 15]), +} + +/// Check a packet from the TUN against the node-wide IPv6 MTU and extract +/// its destination prefix. +pub(crate) fn admit(packet: &[u8], ipv6_mtu: u16) -> Admit { + if packet.len() < 40 || packet[0] >> 4 != 6 { + return Admit::Drop; + } + + // Check if packet will fit after FIPS encapsulation + let effective_mtu = ipv6_mtu as usize; + if packet.len() > effective_mtu { + return Admit::TooBig(effective_mtu as u32); + } + + // Extract destination FipsAddress prefix (IPv6 dest bytes 1-15) + // IPv6 header: bytes 24-39 are dest addr, so prefix = bytes 25-39 + let mut prefix = [0u8; 15]; + prefix.copy_from_slice(&packet[25..40]); + Admit::Forward(prefix) +} + +/// The Packet Too Big MTU for a packet of `len` bytes on a session whose +/// path MTU is `path_mtu`, or `None` if it fits. +/// +/// Applies only when the path is narrower than the node-wide `ipv6_mtu`, +/// which [`admit`] has already enforced. +pub(crate) fn path_too_big(len: usize, path_mtu: u16, ipv6_mtu: u16) -> Option { + let path_ipv6_mtu = effective_ipv6_mtu(path_mtu) as usize; + if path_ipv6_mtu < ipv6_mtu as usize && len > path_ipv6_mtu { + Some(path_ipv6_mtu as u32) + } else { + None + } +} + +/// Forward one IPv6 packet read from the TUN into the mesh. +/// +/// Packets that are not IPv6, too large for the node, too large for an +/// established session's path, to an unknown destination, or refused for a +/// full session table are answered or dropped here. The rest go to +/// [`Mesh::send`]. +pub(crate) async fn forward(mesh: &mut M, packet: Vec) { + let ipv6_mtu = mesh.ipv6_mtu(); + let prefix = match admit(&packet, ipv6_mtu) { + Admit::Drop => return, + Admit::TooBig(mtu) => { + mesh.icmp().packet_too_big(&packet, mtu); + return; + } + Admit::Forward(prefix) => prefix, + }; + + let Some(route) = mesh.resolve(&prefix) else { + mesh.icmp().dest_unreachable(&packet); + return; + }; + + // Check per-destination path MTU learned from MtuExceeded signals. + // The first oversized packet is forwarded normally and triggers + // the MtuExceeded signal; subsequent packets are caught here and + // generate ICMPv6 Packet Too Big back to the application. + if let Some(mtu) = route + .path_mtu + .and_then(|path_mtu| path_too_big(packet.len(), path_mtu, ipv6_mtu)) + { + mesh.icmp().packet_too_big(&packet, mtu); + return; + } + + if let Outcome::TableFull(packet) = mesh.send(route.dest, packet).await { + mesh.icmp().dest_unreachable(&packet); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ipv6tun::icmp_rate_limit::IcmpRateLimiter; + use crate::ipv6tun::tun::TunTx; + use std::net::Ipv6Addr; + use std::sync::mpsc; + + /// A mesh with one known destination prefix and a scripted send outcome. + struct FakeMesh { + ipv6_mtu: u16, + known: [u8; 15], + path_mtu: Option, + refuse: bool, + sent: Vec>, + tun_tx: TunTx, + limiter: IcmpRateLimiter, + } + + impl Mesh for FakeMesh { + type Dest = (); + + fn ipv6_mtu(&self) -> u16 { + self.ipv6_mtu + } + + fn resolve(&mut self, prefix: &[u8; 15]) -> Option> { + (*prefix == self.known).then_some(Route { + dest: (), + path_mtu: self.path_mtu, + }) + } + + fn send(&mut self, _dest: (), packet: Vec) -> impl Future + Send { + let outcome = if self.refuse { + Outcome::TableFull(packet) + } else { + self.sent.push(packet); + Outcome::Sent + }; + async move { outcome } + } + + fn icmp(&mut self) -> IcmpContext<'_> { + IcmpContext::new( + Some(&self.tun_tx), + "fd00::1".parse().unwrap(), + &mut self.limiter, + ) + } + } + + /// Build a fake mesh and the receiving end of its TUN channel. + fn fake(ipv6_mtu: u16) -> (FakeMesh, mpsc::Receiver>) { + let (tun_tx, rx) = mpsc::channel(); + let mesh = FakeMesh { + ipv6_mtu, + known: dest().octets()[1..16].try_into().unwrap(), + path_mtu: None, + refuse: false, + sent: Vec::new(), + tun_tx, + limiter: IcmpRateLimiter::new(), + }; + (mesh, rx) + } + + /// The destination the fake mesh knows. + fn dest() -> Ipv6Addr { + "fd12:3456:789a::2".parse().unwrap() + } + + /// An IPv6 UDP packet of `len` bytes from a host address to `dst`. + fn packet(dst: Ipv6Addr, len: usize) -> Vec { + let mut p = vec![0u8; len]; + p[0] = 0x60; + p[4..6].copy_from_slice(&((len - 40) as u16).to_be_bytes()); + p[6] = 17; + p[7] = 64; + let src: Ipv6Addr = "fd00::5".parse().unwrap(); + p[8..24].copy_from_slice(&src.octets()); + p[24..40].copy_from_slice(&dst.octets()); + p + } + + /// The ICMPv6 type of a reply written to the TUN. + fn icmp_type(reply: &[u8]) -> u8 { + reply[40] + } + + #[test] + fn admit_drops_short_and_non_ipv6_packets() { + assert_eq!(admit(&[0x60; 39], 1280), Admit::Drop); + let mut p = packet(dest(), 60); + p[0] = 0x45; + assert_eq!(admit(&p, 1280), Admit::Drop); + } + + #[test] + fn admit_reports_the_node_mtu_for_an_oversized_packet() { + assert_eq!(admit(&packet(dest(), 1281), 1280), Admit::TooBig(1280)); + assert!(matches!( + admit(&packet(dest(), 1280), 1280), + Admit::Forward(_) + )); + } + + #[test] + fn admit_extracts_destination_bytes_one_to_fifteen() { + let expected: [u8; 15] = dest().octets()[1..16].try_into().unwrap(); + assert_eq!(admit(&packet(dest(), 60), 1280), Admit::Forward(expected)); + } + + #[test] + fn path_too_big_applies_only_below_the_node_mtu() { + let node = 1280; + let narrow = effective_ipv6_mtu(1000); + assert_eq!( + path_too_big(narrow as usize + 1, 1000, node), + Some(narrow as u32) + ); + assert_eq!(path_too_big(narrow as usize, 1000, node), None); + // A path at least as wide as the node MTU never answers. + assert_eq!(path_too_big(1280, 1280 + 77, node), None); + } + + #[tokio::test] + async fn forward_answers_unknown_destination_with_unreachable() { + let (mut mesh, rx) = fake(1280); + forward(&mut mesh, packet("fd99::1".parse().unwrap(), 60)).await; + assert!(mesh.sent.is_empty()); + assert_eq!(icmp_type(&rx.try_recv().unwrap()), 1); + } + + #[tokio::test] + async fn forward_answers_oversized_for_session_path_with_packet_too_big() { + let (mut mesh, rx) = fake(1280); + mesh.path_mtu = Some(1000); + forward(&mut mesh, packet(dest(), 1200)).await; + assert!(mesh.sent.is_empty()); + assert_eq!(icmp_type(&rx.try_recv().unwrap()), 2); + } + + #[tokio::test] + async fn forward_sends_a_fitting_packet_without_a_reply() { + let (mut mesh, rx) = fake(1280); + mesh.path_mtu = Some(1400); + forward(&mut mesh, packet(dest(), 1200)).await; + assert_eq!(mesh.sent.len(), 1); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn forward_answers_a_full_session_table_with_unreachable() { + let (mut mesh, rx) = fake(1280); + mesh.refuse = true; + forward(&mut mesh, packet(dest(), 60)).await; + assert_eq!(icmp_type(&rx.try_recv().unwrap()), 1); + } +} diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index ce65171e..022d7b9b 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -7,6 +7,7 @@ use crate::NodeAddr; use crate::ipv6tun::icmp::IcmpContext; +use crate::ipv6tun::outbound::{Mesh, Outcome, Route}; use crate::node::handlers::mmp::format_throughput; use crate::node::rate_limit::Msg1Class; use crate::node::reject::{RejectReason, SessionReject}; @@ -3062,63 +3063,44 @@ impl Node { /// Handle an outbound IPv6 packet from the TUN reader. /// - /// Extracts the destination FipsAddress, looks up the NodeAddr and PublicKey - /// from the identity cache, and either sends through an established session - /// or initiates a new one (queuing the packet until established). + /// The host-side checks and ICMPv6 replies are in + /// [`crate::ipv6tun::outbound::forward`], which reaches back into the + /// node through its [`Mesh`] impl: the destination prefix is resolved + /// in the identity cache, and the packet is sent on an established + /// session or queued while one is set up. /// /// Also performs MTU checking: if the packet (plus FIPS overhead) exceeds /// the transport MTU, an ICMP Packet Too Big message is sent back to the /// source and the packet is dropped. pub(in crate::node) async fn handle_tun_outbound(&mut self, ipv6_packet: Vec) { - // Validate IPv6 header - if ipv6_packet.len() < 40 || ipv6_packet[0] >> 4 != 6 { - return; - } - - // Check if packet will fit after FIPS encapsulation - let effective_mtu = self.effective_ipv6_mtu() as usize; - if ipv6_packet.len() > effective_mtu { - self.send_icmpv6_packet_too_big(&ipv6_packet, effective_mtu as u32); - return; - } - - // Extract destination FipsAddress prefix (IPv6 dest bytes 1-15) - // IPv6 header: bytes 24-39 are dest addr, so prefix = bytes 25-39 - let mut prefix = [0u8; 15]; - prefix.copy_from_slice(&ipv6_packet[25..40]); - - // Look up in identity cache - let (dest_addr, dest_pubkey) = match self.lookup_by_fips_prefix(&prefix) { - Some((addr, pk)) => (addr, pk), - None => { - self.send_icmpv6_dest_unreachable(&ipv6_packet); - return; - } - }; + crate::ipv6tun::outbound::forward(self, ipv6_packet).await; + } + /// Send a TUN packet to a resolved destination, or queue it while the + /// destination's session is set up. + /// + /// Sends through an established session, queues behind one still being + /// set up, and otherwise initiates a session and queues. With no route + /// for the initiation it starts discovery and still queues. Refuses the + /// packet, handing it back, when a new session would exceed the session + /// table. + async fn send_tun_packet( + &mut self, + dest_addr: NodeAddr, + dest_pubkey: PublicKey, + ipv6_packet: Vec, + ) -> Outcome { // Check for established session if let Some(entry) = self.sessions.get(&dest_addr) { if entry.is_established() { - // Check per-destination path MTU learned from MtuExceeded signals. - // The first oversized packet is forwarded normally and triggers - // the MtuExceeded signal; subsequent packets are caught here and - // generate ICMPv6 Packet Too Big back to the application. - if let Some(mmp) = entry.mmp() { - let path_mtu = mmp.path_mtu.current_mtu(); - let path_ipv6_mtu = crate::upper::icmp::effective_ipv6_mtu(path_mtu) as usize; - if path_ipv6_mtu < effective_mtu && ipv6_packet.len() > path_ipv6_mtu { - self.send_icmpv6_packet_too_big(&ipv6_packet, path_ipv6_mtu as u32); - return; - } - } if let Err(e) = self.send_ipv6_packet(&dest_addr, &ipv6_packet).await { debug!(dest = %self.peer_display_name(&dest_addr), error = %e, "Failed to send TUN packet via session"); } - return; + return Outcome::Sent; } // Session exists but not yet established — queue the packet self.queue_pending_packet(dest_addr, ipv6_packet); - return; + return Outcome::Queued; } // No session, so this one would grow the table. Answer the local @@ -3128,8 +3110,7 @@ impl Node { // queued packet, which is outbound traffic on a node already at its // limit. if !self.admit_new_session(&dest_addr) { - self.send_icmpv6_dest_unreachable(&ipv6_packet); - return; + return Outcome::TableFull(ipv6_packet); } // No session: initiate one and queue the packet. @@ -3139,9 +3120,10 @@ impl Node { debug!(dest = %self.peer_display_name(&dest_addr), error = %e, "Failed to initiate session, trying discovery"); self.maybe_initiate_lookup(&dest_addr).await; self.queue_pending_packet(dest_addr, ipv6_packet); - return; + return Outcome::Queued; } self.queue_pending_packet(dest_addr, ipv6_packet); + Outcome::Queued } /// Borrow the host-facing ICMPv6 sender: the TUN channel, our address @@ -3155,19 +3137,6 @@ impl Node { ) } - /// Send ICMPv6 Destination Unreachable back through TUN. - pub(in crate::node) fn send_icmpv6_dest_unreachable(&mut self, original_packet: &[u8]) { - self.host_icmp().dest_unreachable(original_packet); - } - - /// Send ICMPv6 Packet Too Big back through TUN. - /// - /// Rate-limited per source address to prevent ICMP floods from - /// misconfigured applications sending repeated oversized packets. - pub(in crate::node) fn send_icmpv6_packet_too_big(&mut self, original_packet: &[u8], mtu: u32) { - self.host_icmp().packet_too_big(original_packet, mtu); - } - /// Queue a packet while waiting for session establishment. fn queue_pending_packet(&mut self, dest_addr: NodeAddr, packet: Vec) { // Reject if we already have too many pending destinations @@ -3235,3 +3204,36 @@ impl Node { } } } + +/// The mesh side of TUN outbound forwarding. +impl Mesh for Node { + type Dest = (NodeAddr, PublicKey); + + fn ipv6_mtu(&self) -> u16 { + self.effective_ipv6_mtu() + } + + fn resolve(&mut self, prefix: &[u8; 15]) -> Option> { + // Look up in identity cache + let (dest_addr, dest_pubkey) = self.lookup_by_fips_prefix(prefix)?; + let path_mtu = self + .sessions + .get(&dest_addr) + .filter(|entry| entry.is_established()) + .and_then(|entry| entry.mmp()) + .map(|mmp| mmp.path_mtu.current_mtu()); + Some(Route { + dest: (dest_addr, dest_pubkey), + path_mtu, + }) + } + + fn send(&mut self, dest: Self::Dest, packet: Vec) -> impl Future + Send { + let (dest_addr, dest_pubkey) = dest; + self.send_tun_packet(dest_addr, dest_pubkey, packet) + } + + fn icmp(&mut self) -> IcmpContext<'_> { + self.host_icmp() + } +}