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
This commit is contained in:
2026-05-21 20:20:55 +02:00
parent 2afdae44c6
commit c6dbb78cfc
4 changed files with 235 additions and 11 deletions
+2 -1
View File
@@ -61,6 +61,7 @@ Windows route-table boundary:
- scoped IP interface MTU overrides with restore-on-drop behavior - scoped IP interface MTU overrides with restore-on-drop behavior
- scoped IP interface metric 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 - 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 - scoped host-route pinning for the relay IP on the pre-TAP interface
- non-Windows builds return a clear unsupported-platform error - 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 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. before bridging if the driver-reported MAC does not match the tunnel identity.
It prints client diagnostics snapshots with relay reachability, route-pinning, 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.
+67 -2
View File
@@ -66,6 +66,14 @@ pub struct InterfaceMtuSnapshot {
mtu: u32, mtu: u32,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InterfaceUnicastAddress {
identity: NetworkInterfaceIdentity,
family: IpInterfaceFamily,
address: IpAddr,
prefix_len: u8,
}
impl InterfaceMetricSnapshot { impl InterfaceMetricSnapshot {
#[cfg_attr(not(windows), allow(dead_code))] #[cfg_attr(not(windows), allow(dead_code))]
const fn new( 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 { impl RouteSnapshot {
#[cfg_attr(not(windows), allow(dead_code))] #[cfg_attr(not(windows), allow(dead_code))]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
@@ -227,8 +272,8 @@ pub use windows::{PinnedRelayRoute, best_route_to, interface_identity_from_guid,
#[cfg(windows)] #[cfg(windows)]
pub use windows::{ pub use windows::{
ScopedDefaultRoutes, ScopedInterfaceMetric, ScopedInterfaceMtu, interface_metric, ScopedDefaultRoutes, ScopedInterfaceMetric, ScopedInterfaceMtu, interface_metric,
interface_mtu, set_scoped_default_routes_disabled, set_scoped_interface_metric, interface_mtu, interface_unicast_addresses, set_scoped_default_routes_disabled,
set_scoped_interface_mtu, set_scoped_interface_metric, set_scoped_interface_mtu,
}; };
#[cfg(not(windows))] #[cfg(not(windows))]
@@ -298,6 +343,13 @@ pub fn interface_mtu(
bail!("Windows interface MTU lookup is only available on Windows"); bail!("Windows interface MTU lookup is only available on Windows");
} }
#[cfg(not(windows))]
pub fn interface_unicast_addresses(
_identity: NetworkInterfaceIdentity,
) -> Result<Vec<InterfaceUnicastAddress>> {
bail!("Windows interface address lookup is only available on Windows");
}
#[cfg(not(windows))] #[cfg(not(windows))]
#[derive(Debug)] #[derive(Debug)]
pub struct ScopedInterfaceMtu { pub struct ScopedInterfaceMtu {
@@ -404,6 +456,18 @@ mod tests {
assert_eq!(snapshot.mtu(), 1200); 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))] #[cfg(not(windows))]
#[test] #[test]
fn rejects_route_inspection_on_non_windows() { fn rejects_route_inspection_on_non_windows() {
@@ -444,6 +508,7 @@ mod tests {
set_scoped_default_routes_disabled(identity, IpInterfaceFamily::Ipv4, true).is_err() set_scoped_default_routes_disabled(identity, IpInterfaceFamily::Ipv4, true).is_err()
); );
assert!(interface_mtu(identity, IpInterfaceFamily::Ipv4).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()); assert!(set_scoped_interface_mtu(identity, IpInterfaceFamily::Ipv4, 1200).is_err());
} }
+138 -7
View File
@@ -1,7 +1,8 @@
use std::{ use std::{
fmt, io, fmt, io,
net::{IpAddr, Ipv4Addr, Ipv6Addr}, net::{IpAddr, Ipv4Addr, Ipv6Addr},
ptr::null, ptr::{null, null_mut},
slice,
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
@@ -10,20 +11,23 @@ use windows_sys::Win32::{
NetworkManagement::{ NetworkManagement::{
IpHelper::{ IpHelper::{
ConvertInterfaceGuidToLuid, ConvertInterfaceLuidToIndex, CreateIpForwardEntry2, ConvertInterfaceGuidToLuid, ConvertInterfaceLuidToIndex, CreateIpForwardEntry2,
DeleteIpForwardEntry2, GetBestRoute2, GetIpInterfaceEntry, IP_ADDRESS_PREFIX, DeleteIpForwardEntry2, FreeMibTable, GetBestRoute2, GetIpInterfaceEntry,
InitializeIpForwardEntry, InitializeIpInterfaceEntry, MIB_IPFORWARD_ROW2, GetUnicastIpAddressTable, IP_ADDRESS_PREFIX, InitializeIpForwardEntry,
MIB_IPINTERFACE_ROW, SetIpInterfaceEntry, InitializeIpInterfaceEntry, MIB_IPFORWARD_ROW2, MIB_IPINTERFACE_ROW,
MIB_UNICASTIPADDRESS_ROW, MIB_UNICASTIPADDRESS_TABLE, SetIpInterfaceEntry,
}, },
Ndis::NET_LUID_LH, Ndis::NET_LUID_LH,
}, },
Networking::WinSock::{ Networking::WinSock::{
AF_INET, AF_INET6, IN_ADDR, IN_ADDR_0, IN6_ADDR, IN6_ADDR_0, RouteProtocolNetMgmt, AF_INET, AF_INET6, AF_UNSPEC, IN_ADDR, IN_ADDR_0, IN6_ADDR, IN6_ADDR_0,
SOCKADDR_IN, SOCKADDR_IN6, SOCKADDR_IN6_0, SOCKADDR_INET, RouteProtocolNetMgmt, SOCKADDR_IN, SOCKADDR_IN6, SOCKADDR_IN6_0, SOCKADDR_INET,
}, },
}; };
use windows_sys::core::GUID; use windows_sys::core::GUID;
use crate::{InterfaceMetricSnapshot, InterfaceMtuSnapshot, IpInterfaceFamily}; use crate::{
InterfaceMetricSnapshot, InterfaceMtuSnapshot, InterfaceUnicastAddress, IpInterfaceFamily,
};
use crate::{NetworkInterfaceIdentity, RouteSnapshot}; use crate::{NetworkInterfaceIdentity, RouteSnapshot};
pub fn interface_identity_from_guid(interface_guid: &str) -> Result<NetworkInterfaceIdentity> { pub fn interface_identity_from_guid(interface_guid: &str) -> Result<NetworkInterfaceIdentity> {
@@ -69,6 +73,33 @@ pub fn interface_mtu(
Ok(mtu_snapshot(identity, family, row)) Ok(mtu_snapshot(identity, family, row))
} }
pub fn interface_unicast_addresses(
identity: NetworkInterfaceIdentity,
) -> Result<Vec<InterfaceUnicastAddress>> {
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( pub fn set_scoped_interface_metric(
identity: NetworkInterfaceIdentity, identity: NetworkInterfaceIdentity,
family: IpInterfaceFamily, family: IpInterfaceFamily,
@@ -345,6 +376,55 @@ fn mtu_snapshot(
InterfaceMtuSnapshot::new(identity, family, row.NlMtu) InterfaceMtuSnapshot::new(identity, family, row.NlMtu)
} }
fn unicast_address_snapshot(
identity: NetworkInterfaceIdentity,
row: &MIB_UNICASTIPADDRESS_ROW,
) -> Option<InterfaceUnicastAddress> {
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 { const fn address_family(family: IpInterfaceFamily) -> u16 {
match family { match family {
IpInterfaceFamily::Ipv4 => AF_INET, IpInterfaceFamily::Ipv4 => AF_INET,
@@ -658,6 +738,37 @@ mod tests {
assert_eq!(snapshot.mtu(), 1200); 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] #[test]
fn builds_ipv6_on_link_host_route_row() { fn builds_ipv6_on_link_host_route_row() {
let route = RouteSnapshot::new( let route = RouteSnapshot::new(
@@ -682,4 +793,24 @@ mod tests {
fn ip(value: &str) -> IpAddr { fn ip(value: &str) -> IpAddr {
value.parse().unwrap() 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::<MIB_UNICASTIPADDRESS_ROW>()
};
row.Address = sockaddr_from_ip(address);
row.InterfaceLuid = NET_LUID_LH {
Value: identity.luid(),
};
row.InterfaceIndex = identity.index();
row.OnLinkPrefixLength = prefix_len;
row
}
} }
+28 -1
View File
@@ -1,3 +1,5 @@
#[cfg(windows)]
use std::net::IpAddr;
use std::{fs, net::SocketAddr, path::PathBuf}; use std::{fs, net::SocketAddr, path::PathBuf};
#[cfg(windows)] #[cfg(windows)]
use std::{ use std::{
@@ -342,11 +344,12 @@ fn open_tap_adapter(session: &ClientSession) -> Result<OpenedTapAdapter> {
tap_interface.index(), tap_interface.index(),
tap_interface.luid() tap_interface.luid()
); );
let tap_ip = tap_unicast_ip(tap_interface);
let tap_diagnostics = TapDiagnostics::new( let tap_diagnostics = TapDiagnostics::new(
true, true,
Some(driver_mac), Some(driver_mac),
Some(session.welcome().effective_tap_mtu()), Some(session.welcome().effective_tap_mtu()),
None, tap_ip,
); );
Ok(OpenedTapAdapter { Ok(OpenedTapAdapter {
@@ -402,6 +405,30 @@ fn optional_label<T: std::fmt::Display>(value: Option<T>) -> String {
value.map_or_else(|| "unknown".to_string(), |value| value.to_string()) value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
} }
#[cfg(windows)]
fn tap_unicast_ip(identity: NetworkInterfaceIdentity) -> Option<IpAddr> {
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<IpAddr> {
addresses
.iter()
.find(|address| matches!(address.address(), IpAddr::V4(_)))
.or_else(|| addresses.first())
.map(|address| address.address())
}
#[cfg_attr(not(windows), allow(dead_code))] #[cfg_attr(not(windows), allow(dead_code))]
fn validate_tap_driver_mac(expected_mac: MacAddr, driver_mac: MacAddr) -> Result<()> { fn validate_tap_driver_mac(expected_mac: MacAddr, driver_mac: MacAddr) -> Result<()> {
if driver_mac != expected_mac { if driver_mac != expected_mac {