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
This commit is contained in:
2026-05-21 19:06:55 +02:00
parent 89989c195a
commit 5001f3c35c
6 changed files with 297 additions and 0 deletions
+15
View File
@@ -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",
] }
+137
View File
@@ -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<IpAddr>,
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<IpAddr>,
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<IpAddr> {
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<RouteSnapshot> {
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()
}
}
+127
View File
@@ -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<RouteSnapshot> {
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<IpAddr> {
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))
}
}