diff --git a/README.md b/README.md index 74f9536..3af6938 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Windows route-table boundary: - read-only best-route lookup for a relay destination IP - selected source address, next hop, interface index/LUID, prefix, and metric +- scoped host-route pinning for the relay IP on the pre-TAP interface - non-Windows builds return a clear unsupported-platform error ### `lanparty-client-tap` diff --git a/crates/lanparty-client-route/src/lib.rs b/crates/lanparty-client-route/src/lib.rs index e4f7cc3..097985c 100644 --- a/crates/lanparty-client-route/src/lib.rs +++ b/crates/lanparty-client-route/src/lib.rs @@ -1,8 +1,8 @@ //! Windows route-table inspection for protecting the relay path. //! //! The client binary uses this crate to keep Win32 route/metric calls out of -//! the relay session code. This crate starts with read-only route snapshots; -//! later route pinning can build on the same typed boundary. +//! the relay session code. The crate can snapshot the current relay route and +//! install a scoped host route that pins the relay IP to the pre-TAP interface. use std::net::IpAddr; @@ -91,13 +91,24 @@ impl RouteSnapshot { mod windows; #[cfg(windows)] -pub use windows::best_route_to; +pub use windows::{PinnedRelayRoute, best_route_to, pin_relay_route}; #[cfg(not(windows))] pub fn best_route_to(_destination: IpAddr) -> Result { bail!("Windows route inspection is only available on Windows"); } +#[cfg(not(windows))] +#[derive(Debug)] +pub struct PinnedRelayRoute { + _private: (), +} + +#[cfg(not(windows))] +pub fn pin_relay_route(_route: &RouteSnapshot) -> Result { + bail!("Windows route pinning is only available on Windows"); +} + #[cfg(test)] mod tests { use super::*; @@ -131,6 +142,23 @@ mod tests { assert!(best_route_to(ip("203.0.113.10")).is_err()); } + #[cfg(not(windows))] + #[test] + fn rejects_route_pinning_on_non_windows() { + let snapshot = RouteSnapshot::new( + ip("203.0.113.10"), + ip("192.0.2.44"), + Some(ip("192.0.2.1")), + ip("0.0.0.0"), + 0, + 12, + 34, + 25, + ); + + assert!(pin_relay_route(&snapshot).is_err()); + } + fn ip(value: &str) -> IpAddr { value.parse().unwrap() } diff --git a/crates/lanparty-client-route/src/windows.rs b/crates/lanparty-client-route/src/windows.rs index 33592a6..186cdde 100644 --- a/crates/lanparty-client-route/src/windows.rs +++ b/crates/lanparty-client-route/src/windows.rs @@ -1,5 +1,5 @@ use std::{ - io, + fmt, io, net::{IpAddr, Ipv4Addr, Ipv6Addr}, ptr::null, }; @@ -7,10 +7,16 @@ use std::{ use anyhow::{Context, Result}; use windows_sys::Win32::{ Foundation::ERROR_SUCCESS, - NetworkManagement::IpHelper::{GetBestRoute2, MIB_IPFORWARD_ROW2}, + NetworkManagement::{ + IpHelper::{ + CreateIpForwardEntry2, DeleteIpForwardEntry2, GetBestRoute2, IP_ADDRESS_PREFIX, + InitializeIpForwardEntry, MIB_IPFORWARD_ROW2, + }, + Ndis::NET_LUID_LH, + }, Networking::WinSock::{ - AF_INET, AF_INET6, IN_ADDR, IN_ADDR_0, IN6_ADDR, IN6_ADDR_0, SOCKADDR_IN, SOCKADDR_IN6, - SOCKADDR_IN6_0, SOCKADDR_INET, + AF_INET, AF_INET6, IN_ADDR, IN_ADDR_0, IN6_ADDR, IN6_ADDR_0, RouteProtocolNetMgmt, + SOCKADDR_IN, SOCKADDR_IN6, SOCKADDR_IN6_0, SOCKADDR_INET, }, }; @@ -56,6 +62,131 @@ pub fn best_route_to(destination: IpAddr) -> Result { )) } +pub fn pin_relay_route(route: &RouteSnapshot) -> Result { + let mut pinned = pinned_route_row(route); + let status = unsafe { + // SAFETY: pinned.row was initialized with InitializeIpForwardEntry and populated with a + // valid host destination, next hop, and interface identity. + CreateIpForwardEntry2(&pinned.row) + }; + windows_status(status).with_context(|| { + format!( + "failed to pin relay route to {} via interface index {}", + route.destination(), + route.interface_index() + ) + })?; + pinned.active = true; + + Ok(pinned) +} + +pub struct PinnedRelayRoute { + row: MIB_IPFORWARD_ROW2, + destination: IpAddr, + next_hop: Option, + interface_index: u32, + interface_luid: u64, + active: bool, +} + +impl PinnedRelayRoute { + #[must_use] + pub const fn destination(&self) -> IpAddr { + self.destination + } + + #[must_use] + pub const fn next_hop(&self) -> Option { + self.next_hop + } + + #[must_use] + pub const fn interface_index(&self) -> u32 { + self.interface_index + } + + #[must_use] + pub const fn interface_luid(&self) -> u64 { + self.interface_luid + } +} + +impl fmt::Debug for PinnedRelayRoute { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PinnedRelayRoute") + .field("destination", &self.destination) + .field("next_hop", &self.next_hop) + .field("interface_index", &self.interface_index) + .field("interface_luid", &self.interface_luid) + .field("active", &self.active) + .finish_non_exhaustive() + } +} + +impl Drop for PinnedRelayRoute { + fn drop(&mut self) { + if !self.active { + return; + } + + unsafe { + // SAFETY: self.row is the same route row that was successfully created for this guard. + // Deletion failure is intentionally ignored during Drop. + DeleteIpForwardEntry2(&self.row); + } + } +} + +fn pinned_route_row(route: &RouteSnapshot) -> PinnedRelayRoute { + let destination = route.destination(); + let next_hop = route.next_hop(); + let mut row = MIB_IPFORWARD_ROW2::default(); + unsafe { + // SAFETY: row points to valid writable storage for Windows to initialize. + InitializeIpForwardEntry(&mut row); + } + row.InterfaceLuid = NET_LUID_LH { + Value: route.interface_luid(), + }; + row.InterfaceIndex = route.interface_index(); + row.DestinationPrefix = host_prefix(destination); + row.NextHop = sockaddr_from_ip(next_hop.unwrap_or_else(|| unspecified_for(destination))); + row.SitePrefixLength = host_prefix_len(destination); + row.Metric = 0; + row.Protocol = RouteProtocolNetMgmt; + + PinnedRelayRoute { + row, + destination, + next_hop, + interface_index: route.interface_index(), + interface_luid: route.interface_luid(), + active: false, + } +} + +fn host_prefix(addr: IpAddr) -> IP_ADDRESS_PREFIX { + IP_ADDRESS_PREFIX { + Prefix: sockaddr_from_ip(addr), + PrefixLength: host_prefix_len(addr), + } +} + +const fn host_prefix_len(addr: IpAddr) -> u8 { + match addr { + IpAddr::V4(_) => 32, + IpAddr::V6(_) => 128, + } +} + +const fn unspecified_for(addr: IpAddr) -> IpAddr { + match addr { + IpAddr::V4(_) => IpAddr::V4(Ipv4Addr::UNSPECIFIED), + IpAddr::V6(_) => IpAddr::V6(Ipv6Addr::UNSPECIFIED), + } +} + fn sockaddr_from_ip(addr: IpAddr) -> SOCKADDR_INET { match addr { IpAddr::V4(addr) => SOCKADDR_INET { @@ -125,3 +256,57 @@ fn windows_status(status: u32) -> io::Result<()> { Err(io::Error::from_raw_os_error(status as i32)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builds_ipv4_host_route_row() { + let route = RouteSnapshot::new( + ip("203.0.113.10"), + ip("192.0.2.44"), + Some(ip("192.0.2.1")), + ip("0.0.0.0"), + 0, + 12, + 34, + 25, + ); + let pinned = pinned_route_row(&route); + + assert_eq!(pinned.destination(), ip("203.0.113.10")); + assert_eq!(pinned.next_hop(), Some(ip("192.0.2.1"))); + assert_eq!(pinned.interface_index(), 12); + assert_eq!(pinned.interface_luid(), 34); + assert_eq!(pinned.row.DestinationPrefix.PrefixLength, 32); + assert_eq!(pinned.row.SitePrefixLength, 32); + assert_eq!(pinned.row.Metric, 0); + assert_eq!(pinned.row.Protocol, RouteProtocolNetMgmt); + } + + #[test] + fn builds_ipv6_on_link_host_route_row() { + let route = RouteSnapshot::new( + ip("2001:db8::10"), + ip("2001:db8::44"), + None, + ip("2001:db8::"), + 64, + 12, + 34, + 25, + ); + let pinned = pinned_route_row(&route); + + assert_eq!(pinned.destination(), ip("2001:db8::10")); + assert_eq!(pinned.next_hop(), None); + assert_eq!(pinned.row.DestinationPrefix.PrefixLength, 128); + assert_eq!(pinned.row.SitePrefixLength, 128); + assert_eq!(ip_from_sockaddr(&pinned.row.NextHop), Some(ip("::"))); + } + + fn ip(value: &str) -> IpAddr { + value.parse().unwrap() + } +}