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 {