From c6dbb78cfc2affe75ce08c029fd10bba1e2b1e8a Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 20:20:55 +0200 Subject: [PATCH] feat(client): report TAP IP in diagnostics Add InterfaceUnicastAddress snapshots to client-route, backed by the Windows unicast IP address table. The Windows client samples the TAP interface after it resolves the adapter identity, preferring IPv4 for diagnostics and falling back to the first address or unknown on lookup failure. This keeps Win32 IP table handling in the route crate and fills the existing TapDiagnostics IP field without making bridging depend on DHCP being present. If DHCP has not assigned an address yet, diagnostics still make that visible as unknown. Test Plan: - cargo fmt --check - cargo test -p lanparty-client-route - cargo test -p lanparty-client-win - cargo check -p lanparty-client-route --target x86_64-pc-windows-gnu - cargo check -p lanparty-client-route --target x86_64-pc-windows-gnu --tests - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Attempted: - cargo check -p lanparty-client-route -p lanparty-client-win --target x86_64-pc-windows-gnu Blocked because ring needs missing x86_64-w64-mingw32-gcc here. Refs: PLAN.md --- README.md | 3 +- crates/lanparty-client-route/src/lib.rs | 69 +++++++++- crates/lanparty-client-route/src/windows.rs | 145 +++++++++++++++++++- crates/lanparty-client-win/src/main.rs | 29 +++- 4 files changed, 235 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 66e1643..a95a891 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ Windows route-table boundary: - scoped IP interface MTU overrides with restore-on-drop behavior - scoped IP interface metric overrides with restore-on-drop behavior - scoped default-route suppression with restore-on-drop behavior +- unicast IP address snapshots for TAP diagnostics - scoped host-route pinning for the relay IP on the pre-TAP interface - non-Windows builds return a clear unsupported-platform error @@ -156,4 +157,4 @@ rechecks that the relay route remains pinned, then restores the previous route policy on exit. Until automatic TAP MAC configuration is wired, startup fails before bridging if the driver-reported MAC does not match the tunnel identity. It prints client diagnostics snapshots with relay reachability, route-pinning, -QUIC datagram budget, TAP status, frame/datagram counters, and drops. +QUIC datagram budget, TAP status/IP, frame/datagram counters, and drops. diff --git a/crates/lanparty-client-route/src/lib.rs b/crates/lanparty-client-route/src/lib.rs index b8100ef..78710cb 100644 --- a/crates/lanparty-client-route/src/lib.rs +++ b/crates/lanparty-client-route/src/lib.rs @@ -66,6 +66,14 @@ pub struct InterfaceMtuSnapshot { mtu: u32, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InterfaceUnicastAddress { + identity: NetworkInterfaceIdentity, + family: IpInterfaceFamily, + address: IpAddr, + prefix_len: u8, +} + impl InterfaceMetricSnapshot { #[cfg_attr(not(windows), allow(dead_code))] const fn new( @@ -136,6 +144,43 @@ impl InterfaceMtuSnapshot { } } +impl InterfaceUnicastAddress { + #[cfg_attr(not(windows), allow(dead_code))] + const fn new( + identity: NetworkInterfaceIdentity, + family: IpInterfaceFamily, + address: IpAddr, + prefix_len: u8, + ) -> Self { + Self { + identity, + family, + address, + prefix_len, + } + } + + #[must_use] + pub const fn identity(self) -> NetworkInterfaceIdentity { + self.identity + } + + #[must_use] + pub const fn family(self) -> IpInterfaceFamily { + self.family + } + + #[must_use] + pub const fn address(self) -> IpAddr { + self.address + } + + #[must_use] + pub const fn prefix_len(self) -> u8 { + self.prefix_len + } +} + impl RouteSnapshot { #[cfg_attr(not(windows), allow(dead_code))] #[allow(clippy::too_many_arguments)] @@ -227,8 +272,8 @@ pub use windows::{PinnedRelayRoute, best_route_to, interface_identity_from_guid, #[cfg(windows)] pub use windows::{ ScopedDefaultRoutes, ScopedInterfaceMetric, ScopedInterfaceMtu, interface_metric, - interface_mtu, set_scoped_default_routes_disabled, set_scoped_interface_metric, - set_scoped_interface_mtu, + interface_mtu, interface_unicast_addresses, set_scoped_default_routes_disabled, + set_scoped_interface_metric, set_scoped_interface_mtu, }; #[cfg(not(windows))] @@ -298,6 +343,13 @@ pub fn interface_mtu( bail!("Windows interface MTU lookup is only available on Windows"); } +#[cfg(not(windows))] +pub fn interface_unicast_addresses( + _identity: NetworkInterfaceIdentity, +) -> Result> { + bail!("Windows interface address lookup is only available on Windows"); +} + #[cfg(not(windows))] #[derive(Debug)] pub struct ScopedInterfaceMtu { @@ -404,6 +456,18 @@ mod tests { assert_eq!(snapshot.mtu(), 1200); } + #[test] + fn exposes_interface_unicast_address_fields() { + let identity = NetworkInterfaceIdentity::new(12, 34); + let snapshot = + InterfaceUnicastAddress::new(identity, IpInterfaceFamily::Ipv4, ip("10.73.42.51"), 24); + + assert_eq!(snapshot.identity(), identity); + assert_eq!(snapshot.family(), IpInterfaceFamily::Ipv4); + assert_eq!(snapshot.address(), ip("10.73.42.51")); + assert_eq!(snapshot.prefix_len(), 24); + } + #[cfg(not(windows))] #[test] fn rejects_route_inspection_on_non_windows() { @@ -444,6 +508,7 @@ mod tests { set_scoped_default_routes_disabled(identity, IpInterfaceFamily::Ipv4, true).is_err() ); assert!(interface_mtu(identity, IpInterfaceFamily::Ipv4).is_err()); + assert!(interface_unicast_addresses(identity).is_err()); assert!(set_scoped_interface_mtu(identity, IpInterfaceFamily::Ipv4, 1200).is_err()); } diff --git a/crates/lanparty-client-route/src/windows.rs b/crates/lanparty-client-route/src/windows.rs index ed6ae33..ae26b53 100644 --- a/crates/lanparty-client-route/src/windows.rs +++ b/crates/lanparty-client-route/src/windows.rs @@ -1,7 +1,8 @@ use std::{ fmt, io, net::{IpAddr, Ipv4Addr, Ipv6Addr}, - ptr::null, + ptr::{null, null_mut}, + slice, }; use anyhow::{Context, Result, bail}; @@ -10,20 +11,23 @@ use windows_sys::Win32::{ NetworkManagement::{ IpHelper::{ ConvertInterfaceGuidToLuid, ConvertInterfaceLuidToIndex, CreateIpForwardEntry2, - DeleteIpForwardEntry2, GetBestRoute2, GetIpInterfaceEntry, IP_ADDRESS_PREFIX, - InitializeIpForwardEntry, InitializeIpInterfaceEntry, MIB_IPFORWARD_ROW2, - MIB_IPINTERFACE_ROW, SetIpInterfaceEntry, + DeleteIpForwardEntry2, FreeMibTable, GetBestRoute2, GetIpInterfaceEntry, + GetUnicastIpAddressTable, IP_ADDRESS_PREFIX, InitializeIpForwardEntry, + InitializeIpInterfaceEntry, MIB_IPFORWARD_ROW2, MIB_IPINTERFACE_ROW, + MIB_UNICASTIPADDRESS_ROW, MIB_UNICASTIPADDRESS_TABLE, SetIpInterfaceEntry, }, Ndis::NET_LUID_LH, }, Networking::WinSock::{ - AF_INET, AF_INET6, IN_ADDR, IN_ADDR_0, IN6_ADDR, IN6_ADDR_0, RouteProtocolNetMgmt, - SOCKADDR_IN, SOCKADDR_IN6, SOCKADDR_IN6_0, SOCKADDR_INET, + AF_INET, AF_INET6, AF_UNSPEC, IN_ADDR, IN_ADDR_0, IN6_ADDR, IN6_ADDR_0, + RouteProtocolNetMgmt, SOCKADDR_IN, SOCKADDR_IN6, SOCKADDR_IN6_0, SOCKADDR_INET, }, }; use windows_sys::core::GUID; -use crate::{InterfaceMetricSnapshot, InterfaceMtuSnapshot, IpInterfaceFamily}; +use crate::{ + InterfaceMetricSnapshot, InterfaceMtuSnapshot, InterfaceUnicastAddress, IpInterfaceFamily, +}; use crate::{NetworkInterfaceIdentity, RouteSnapshot}; pub fn interface_identity_from_guid(interface_guid: &str) -> Result { @@ -69,6 +73,33 @@ pub fn interface_mtu( Ok(mtu_snapshot(identity, family, row)) } +pub fn interface_unicast_addresses( + identity: NetworkInterfaceIdentity, +) -> Result> { + let mut table = null_mut(); + let status = unsafe { + // SAFETY: table points to writable storage for the returned heap table pointer. + GetUnicastIpAddressTable(AF_UNSPEC, &mut table) + }; + windows_status(status).with_context(|| { + format!( + "failed to read unicast IP address table for interface index {} LUID {}", + identity.index(), + identity.luid() + ) + })?; + if table.is_null() { + bail!("Windows returned a null unicast IP address table"); + } + let table = MibUnicastAddressTable(table); + let rows = table.rows(); + + Ok(rows + .iter() + .filter_map(|row| unicast_address_snapshot(identity, row)) + .collect()) +} + pub fn set_scoped_interface_metric( identity: NetworkInterfaceIdentity, family: IpInterfaceFamily, @@ -345,6 +376,55 @@ fn mtu_snapshot( InterfaceMtuSnapshot::new(identity, family, row.NlMtu) } +fn unicast_address_snapshot( + identity: NetworkInterfaceIdentity, + row: &MIB_UNICASTIPADDRESS_ROW, +) -> Option { + if row.InterfaceIndex != identity.index() || luid_value(row.InterfaceLuid) != identity.luid() { + return None; + } + let address = ip_from_sockaddr(&row.Address)?; + if address.is_unspecified() { + return None; + } + let family = match address { + IpAddr::V4(_) => IpInterfaceFamily::Ipv4, + IpAddr::V6(_) => IpInterfaceFamily::Ipv6, + }; + + Some(InterfaceUnicastAddress::new( + identity, + family, + address, + row.OnLinkPrefixLength, + )) +} + +struct MibUnicastAddressTable(*mut MIB_UNICASTIPADDRESS_TABLE); + +impl MibUnicastAddressTable { + fn rows(&self) -> &[MIB_UNICASTIPADDRESS_ROW] { + let table = unsafe { + // SAFETY: self.0 is checked non-null after GetUnicastIpAddressTable succeeds and is + // owned by this guard until Drop frees it. + &*self.0 + }; + unsafe { + // SAFETY: Windows allocates NumEntries contiguous rows starting at Table. + slice::from_raw_parts(table.Table.as_ptr(), table.NumEntries as usize) + } + } +} + +impl Drop for MibUnicastAddressTable { + fn drop(&mut self) { + unsafe { + // SAFETY: self.0 was allocated by GetUnicastIpAddressTable and is freed exactly once. + FreeMibTable(self.0.cast()); + } + } +} + const fn address_family(family: IpInterfaceFamily) -> u16 { match family { IpInterfaceFamily::Ipv4 => AF_INET, @@ -658,6 +738,37 @@ mod tests { assert_eq!(snapshot.mtu(), 1200); } + #[test] + fn builds_unicast_address_snapshots_from_rows() { + let identity = NetworkInterfaceIdentity::new(12, 34); + let row = unicast_row(identity, ip("10.73.42.51"), 24); + + let snapshot = unicast_address_snapshot(identity, &row).unwrap(); + + assert_eq!(snapshot.identity(), identity); + assert_eq!(snapshot.family(), IpInterfaceFamily::Ipv4); + assert_eq!(snapshot.address(), ip("10.73.42.51")); + assert_eq!(snapshot.prefix_len(), 24); + } + + #[test] + fn filters_unicast_address_rows_for_other_interfaces() { + let identity = NetworkInterfaceIdentity::new(12, 34); + let mut row = unicast_row(identity, ip("10.73.42.51"), 24); + + row.InterfaceIndex = 99; + + assert_eq!(unicast_address_snapshot(identity, &row), None); + } + + #[test] + fn filters_unspecified_unicast_addresses() { + let identity = NetworkInterfaceIdentity::new(12, 34); + let row = unicast_row(identity, ip("0.0.0.0"), 0); + + assert_eq!(unicast_address_snapshot(identity, &row), None); + } + #[test] fn builds_ipv6_on_link_host_route_row() { let route = RouteSnapshot::new( @@ -682,4 +793,24 @@ mod tests { fn ip(value: &str) -> IpAddr { value.parse().unwrap() } + + fn unicast_row( + identity: NetworkInterfaceIdentity, + address: IpAddr, + prefix_len: u8, + ) -> MIB_UNICASTIPADDRESS_ROW { + let mut row = unsafe { + // SAFETY: MIB_UNICASTIPADDRESS_ROW is a plain Win32 data structure; tests populate + // the fields read by unicast_address_snapshot before using it. + std::mem::zeroed::() + }; + row.Address = sockaddr_from_ip(address); + row.InterfaceLuid = NET_LUID_LH { + Value: identity.luid(), + }; + row.InterfaceIndex = identity.index(); + row.OnLinkPrefixLength = prefix_len; + + row + } } diff --git a/crates/lanparty-client-win/src/main.rs b/crates/lanparty-client-win/src/main.rs index dd537cb..b8e92f4 100644 --- a/crates/lanparty-client-win/src/main.rs +++ b/crates/lanparty-client-win/src/main.rs @@ -1,3 +1,5 @@ +#[cfg(windows)] +use std::net::IpAddr; use std::{fs, net::SocketAddr, path::PathBuf}; #[cfg(windows)] use std::{ @@ -342,11 +344,12 @@ fn open_tap_adapter(session: &ClientSession) -> Result { tap_interface.index(), tap_interface.luid() ); + let tap_ip = tap_unicast_ip(tap_interface); let tap_diagnostics = TapDiagnostics::new( true, Some(driver_mac), Some(session.welcome().effective_tap_mtu()), - None, + tap_ip, ); Ok(OpenedTapAdapter { @@ -402,6 +405,30 @@ fn optional_label(value: Option) -> String { value.map_or_else(|| "unknown".to_string(), |value| value.to_string()) } +#[cfg(windows)] +fn tap_unicast_ip(identity: NetworkInterfaceIdentity) -> Option { + match lanparty_client_route::interface_unicast_addresses(identity) { + Ok(addresses) => preferred_tap_ip(&addresses), + Err(error) => { + eprintln!( + "failed to inspect TAP IP address; diagnostics will report unknown: {error:#}" + ); + None + } + } +} + +#[cfg(windows)] +fn preferred_tap_ip( + addresses: &[lanparty_client_route::InterfaceUnicastAddress], +) -> Option { + addresses + .iter() + .find(|address| matches!(address.address(), IpAddr::V4(_))) + .or_else(|| addresses.first()) + .map(|address| address.address()) +} + #[cfg_attr(not(windows), allow(dead_code))] fn validate_tap_driver_mac(expected_mac: MacAddr, driver_mac: MacAddr) -> Result<()> { if driver_mac != expected_mac {