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:
Generated
+8
@@ -446,6 +446,14 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lanparty-client-route"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "lanparty-client-tap"
|
name = "lanparty-client-tap"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
resolver = "3"
|
resolver = "3"
|
||||||
members = [
|
members = [
|
||||||
"crates/lanparty-client-core",
|
"crates/lanparty-client-core",
|
||||||
|
"crates/lanparty-client-route",
|
||||||
"crates/lanparty-client-tap",
|
"crates/lanparty-client-tap",
|
||||||
"crates/lanparty-client-win",
|
"crates/lanparty-client-win",
|
||||||
"crates/lanparty-ctrl",
|
"crates/lanparty-ctrl",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ Monorepo for a Layer 2 over QUIC LAN party bridge.
|
|||||||
- `lanparty-ctrl`: control-plane messages (join/hello/role/version).
|
- `lanparty-ctrl`: control-plane messages (join/hello/role/version).
|
||||||
- `lanparty-obs`: shared diagnostics/logging event models.
|
- `lanparty-obs`: shared diagnostics/logging event models.
|
||||||
- `lanparty-client-core`: platform-agnostic client session state.
|
- `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-tap`: TAP-Windows6 adapter discovery and frame I/O.
|
||||||
- `lanparty-client-win`: Windows TAP + route/metric handling binary.
|
- `lanparty-client-win`: Windows TAP + route/metric handling binary.
|
||||||
- `lanparty-gateway`: Linux AF_PACKET gateway 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
|
- welcome/reject handling with assigned peer id and effective TAP MTU
|
||||||
- Ethernet frame send/receive helpers over QUIC DATAGRAM
|
- 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`
|
### `lanparty-client-tap`
|
||||||
|
|
||||||
Windows TAP adapter boundary:
|
Windows TAP adapter boundary:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
] }
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user