From 5001f3c35ce366867247a154da2dc467ff6178d7 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 19:06:55 +0200 Subject: [PATCH] feat(client): add Windows route snapshot helper Add `lanparty-client-route` as the Win32 boundary for route-table work. The first API is intentionally read-only: `best_route_to` wraps `GetBestRoute2` and returns the selected source address, next hop, route prefix, interface index/LUID, and route metric for a relay destination IP. This keeps the route-protection work separate from the QUIC client binary, so we can typecheck the Windows IP Helper calls on this Linux host without pulling in the `ring` build path that currently blocks full Windows binary checks. Actual route pinning and metric mutation remain later slices. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - cargo check -p lanparty-client-route --target x86_64-pc-windows-msvc - cargo clippy -p lanparty-client-route --target x86_64-pc-windows-msvc -- -D warnings - git diff --check Refs: PLAN.md --- Cargo.lock | 8 ++ Cargo.toml | 1 + README.md | 9 ++ crates/lanparty-client-route/Cargo.toml | 15 +++ crates/lanparty-client-route/src/lib.rs | 137 ++++++++++++++++++++ crates/lanparty-client-route/src/windows.rs | 127 ++++++++++++++++++ 6 files changed, 297 insertions(+) create mode 100644 crates/lanparty-client-route/Cargo.toml create mode 100644 crates/lanparty-client-route/src/lib.rs create mode 100644 crates/lanparty-client-route/src/windows.rs diff --git a/Cargo.lock b/Cargo.lock index 7299457..0fd5080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,14 @@ dependencies = [ "tokio", ] +[[package]] +name = "lanparty-client-route" +version = "0.1.0" +dependencies = [ + "anyhow", + "windows-sys 0.61.2", +] + [[package]] name = "lanparty-client-tap" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3613762..54706aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/lanparty-client-core", + "crates/lanparty-client-route", "crates/lanparty-client-tap", "crates/lanparty-client-win", "crates/lanparty-ctrl", diff --git a/README.md b/README.md index 05ff834..89541f2 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Monorepo for a Layer 2 over QUIC LAN party bridge. - `lanparty-ctrl`: control-plane messages (join/hello/role/version). - `lanparty-obs`: shared diagnostics/logging event models. - `lanparty-client-core`: platform-agnostic client session state. +- `lanparty-client-route`: Windows relay-route inspection. - `lanparty-client-tap`: TAP-Windows6 adapter discovery and frame I/O. - `lanparty-client-win`: Windows TAP + route/metric handling binary. - `lanparty-gateway`: Linux AF_PACKET gateway binary. @@ -48,6 +49,14 @@ Platform-neutral remote client relay session: - welcome/reject handling with assigned peer id and effective TAP MTU - Ethernet frame send/receive helpers over QUIC DATAGRAM +### `lanparty-client-route` + +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 +- non-Windows builds return a clear unsupported-platform error + ### `lanparty-client-tap` Windows TAP adapter boundary: diff --git a/crates/lanparty-client-route/Cargo.toml b/crates/lanparty-client-route/Cargo.toml new file mode 100644 index 0000000..16eaac0 --- /dev/null +++ b/crates/lanparty-client-route/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "lanparty-client-route" +version.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_NetworkManagement_IpHelper", + "Win32_NetworkManagement_Ndis", + "Win32_Networking_WinSock", +] } diff --git a/crates/lanparty-client-route/src/lib.rs b/crates/lanparty-client-route/src/lib.rs new file mode 100644 index 0000000..e4f7cc3 --- /dev/null +++ b/crates/lanparty-client-route/src/lib.rs @@ -0,0 +1,137 @@ +//! 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. + +use std::net::IpAddr; + +#[cfg(not(windows))] +use anyhow::{Result, bail}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RouteSnapshot { + destination: IpAddr, + source: IpAddr, + next_hop: Option, + route_prefix: IpAddr, + route_prefix_len: u8, + interface_index: u32, + interface_luid: u64, + metric: u32, +} + +impl RouteSnapshot { + #[cfg_attr(not(windows), allow(dead_code))] + #[allow(clippy::too_many_arguments)] + const fn new( + destination: IpAddr, + source: IpAddr, + next_hop: Option, + route_prefix: IpAddr, + route_prefix_len: u8, + interface_index: u32, + interface_luid: u64, + metric: u32, + ) -> Self { + Self { + destination, + source, + next_hop, + route_prefix, + route_prefix_len, + interface_index, + interface_luid, + metric, + } + } + + #[must_use] + pub const fn destination(&self) -> IpAddr { + self.destination + } + + #[must_use] + pub const fn source(&self) -> IpAddr { + self.source + } + + #[must_use] + pub const fn next_hop(&self) -> Option { + self.next_hop + } + + #[must_use] + pub const fn route_prefix(&self) -> IpAddr { + self.route_prefix + } + + #[must_use] + pub const fn route_prefix_len(&self) -> u8 { + self.route_prefix_len + } + + #[must_use] + pub const fn interface_index(&self) -> u32 { + self.interface_index + } + + #[must_use] + pub const fn interface_luid(&self) -> u64 { + self.interface_luid + } + + #[must_use] + pub const fn metric(&self) -> u32 { + self.metric + } +} + +#[cfg(windows)] +mod windows; + +#[cfg(windows)] +pub use windows::best_route_to; + +#[cfg(not(windows))] +pub fn best_route_to(_destination: IpAddr) -> Result { + bail!("Windows route inspection is only available on Windows"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exposes_route_snapshot_fields() { + 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_eq!(snapshot.destination(), ip("203.0.113.10")); + assert_eq!(snapshot.source(), ip("192.0.2.44")); + assert_eq!(snapshot.next_hop(), Some(ip("192.0.2.1"))); + assert_eq!(snapshot.route_prefix(), ip("0.0.0.0")); + assert_eq!(snapshot.route_prefix_len(), 0); + assert_eq!(snapshot.interface_index(), 12); + assert_eq!(snapshot.interface_luid(), 34); + assert_eq!(snapshot.metric(), 25); + } + + #[cfg(not(windows))] + #[test] + fn rejects_route_inspection_on_non_windows() { + assert!(best_route_to(ip("203.0.113.10")).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 new file mode 100644 index 0000000..33592a6 --- /dev/null +++ b/crates/lanparty-client-route/src/windows.rs @@ -0,0 +1,127 @@ +use std::{ + io, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + ptr::null, +}; + +use anyhow::{Context, Result}; +use windows_sys::Win32::{ + Foundation::ERROR_SUCCESS, + NetworkManagement::IpHelper::{GetBestRoute2, MIB_IPFORWARD_ROW2}, + 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, + }, +}; + +use crate::RouteSnapshot; + +pub fn best_route_to(destination: IpAddr) -> Result { + let destination_sockaddr = sockaddr_from_ip(destination); + let mut route = MIB_IPFORWARD_ROW2::default(); + let mut source = SOCKADDR_INET::default(); + let status = unsafe { + // SAFETY: destination_sockaddr, route, and source point to valid initialized storage. + // Null interface/source inputs ask Windows to choose the best current route. + GetBestRoute2( + null(), + 0, + null(), + &destination_sockaddr, + 0, + &mut route, + &mut source, + ) + }; + windows_status(status).with_context(|| format!("failed to find route to {destination}"))?; + + let source = ip_from_sockaddr(&source).context("best route returned no source address")?; + let route_prefix = ip_from_sockaddr(&route.DestinationPrefix.Prefix) + .context("best route returned no route prefix")?; + let next_hop = ip_from_sockaddr(&route.NextHop).filter(|next_hop| !next_hop.is_unspecified()); + let interface_luid = unsafe { + // SAFETY: GetBestRoute2 initialized the route row, including InterfaceLuid. + route.InterfaceLuid.Value + }; + + Ok(RouteSnapshot::new( + destination, + source, + next_hop, + route_prefix, + route.DestinationPrefix.PrefixLength, + route.InterfaceIndex, + interface_luid, + route.Metric, + )) +} + +fn sockaddr_from_ip(addr: IpAddr) -> SOCKADDR_INET { + match addr { + IpAddr::V4(addr) => SOCKADDR_INET { + Ipv4: SOCKADDR_IN { + sin_family: AF_INET, + sin_port: 0, + sin_addr: IN_ADDR { + S_un: IN_ADDR_0 { + S_addr: u32::from_ne_bytes(addr.octets()), + }, + }, + sin_zero: [0; 8], + }, + }, + IpAddr::V6(addr) => SOCKADDR_INET { + Ipv6: SOCKADDR_IN6 { + sin6_family: AF_INET6, + sin6_port: 0, + sin6_flowinfo: 0, + sin6_addr: IN6_ADDR { + u: IN6_ADDR_0 { + Byte: addr.octets(), + }, + }, + Anonymous: SOCKADDR_IN6_0 { sin6_scope_id: 0 }, + }, + }, + } +} + +fn ip_from_sockaddr(sockaddr: &SOCKADDR_INET) -> Option { + match unsafe { + // SAFETY: Reading the discriminator is valid for any SOCKADDR_INET initialized by us or + // by Windows APIs. + sockaddr.si_family + } { + AF_INET => { + let ipv4 = unsafe { + // SAFETY: si_family reports that the IPv4 union field is active. + sockaddr.Ipv4 + }; + let octets = unsafe { + // SAFETY: S_addr is the compact IPv4 representation in network byte order. + ipv4.sin_addr.S_un.S_addr.to_ne_bytes() + }; + Some(IpAddr::V4(Ipv4Addr::from(octets))) + } + AF_INET6 => { + let ipv6 = unsafe { + // SAFETY: si_family reports that the IPv6 union field is active. + sockaddr.Ipv6 + }; + let octets = unsafe { + // SAFETY: Byte is the compact IPv6 representation in network byte order. + ipv6.sin6_addr.u.Byte + }; + Some(IpAddr::V6(Ipv6Addr::from(octets))) + } + _ => None, + } +} + +fn windows_status(status: u32) -> io::Result<()> { + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(io::Error::from_raw_os_error(status as i32)) + } +}