feat(client): add scoped interface metric override

Add a reversible IP interface metric boundary to `lanparty-client-route`. The
crate can now read an IPv4 or IPv6 interface metric snapshot and temporarily set
a manual interface metric with an RAII guard that restores the previous metric
and automatic-metric state on drop.

This prepares the TAP metric handling without wiring policy into the Windows
client yet. Default-route disabling is captured in snapshots for diagnostics and
future decisions, but this slice deliberately changes only `UseAutomaticMetric`
and `Metric`.

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 --all-targets -- -D warnings
- git diff --check

Refs: PLAN.md
Refs: https://learn.microsoft.com/en-us/windows-hardware/drivers/network/initializeipinterfaceentry
Refs: https://learn.microsoft.com/en-us/windows/win32/api/netioapi/nf-netioapi-setipinterfaceentry
This commit is contained in:
2026-05-21 19:20:01 +02:00
parent 96bfbd0dbc
commit 61481eaf46
3 changed files with 271 additions and 2 deletions
+106
View File
@@ -44,6 +44,65 @@ impl NetworkInterfaceIdentity {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum IpInterfaceFamily {
Ipv4,
Ipv6,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InterfaceMetricSnapshot {
identity: NetworkInterfaceIdentity,
family: IpInterfaceFamily,
automatic_metric: bool,
metric: u32,
disable_default_routes: bool,
}
impl InterfaceMetricSnapshot {
#[cfg_attr(not(windows), allow(dead_code))]
const fn new(
identity: NetworkInterfaceIdentity,
family: IpInterfaceFamily,
automatic_metric: bool,
metric: u32,
disable_default_routes: bool,
) -> Self {
Self {
identity,
family,
automatic_metric,
metric,
disable_default_routes,
}
}
#[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 automatic_metric(self) -> bool {
self.automatic_metric
}
#[must_use]
pub const fn metric(self) -> u32 {
self.metric
}
#[must_use]
pub const fn disable_default_routes(self) -> bool {
self.disable_default_routes
}
}
impl RouteSnapshot {
#[cfg_attr(not(windows), allow(dead_code))]
#[allow(clippy::too_many_arguments)]
@@ -115,6 +174,8 @@ mod windows;
#[cfg(windows)]
pub use windows::{PinnedRelayRoute, best_route_to, interface_identity_from_guid, pin_relay_route};
#[cfg(windows)]
pub use windows::{ScopedInterfaceMetric, interface_metric, set_scoped_interface_metric};
#[cfg(not(windows))]
pub fn best_route_to(_destination: IpAddr) -> Result<RouteSnapshot> {
@@ -137,6 +198,29 @@ pub fn interface_identity_from_guid(_interface_guid: &str) -> Result<NetworkInte
bail!("Windows interface identity lookup is only available on Windows");
}
#[cfg(not(windows))]
#[derive(Debug)]
pub struct ScopedInterfaceMetric {
_private: (),
}
#[cfg(not(windows))]
pub fn interface_metric(
_identity: NetworkInterfaceIdentity,
_family: IpInterfaceFamily,
) -> Result<InterfaceMetricSnapshot> {
bail!("Windows interface metric lookup is only available on Windows");
}
#[cfg(not(windows))]
pub fn set_scoped_interface_metric(
_identity: NetworkInterfaceIdentity,
_family: IpInterfaceFamily,
_metric: u32,
) -> Result<ScopedInterfaceMetric> {
bail!("Windows interface metric updates are only available on Windows");
}
#[cfg(test)]
mod tests {
use super::*;
@@ -172,6 +256,19 @@ mod tests {
assert_eq!(identity.luid(), 34);
}
#[test]
fn exposes_interface_metric_snapshot_fields() {
let identity = NetworkInterfaceIdentity::new(12, 34);
let snapshot =
InterfaceMetricSnapshot::new(identity, IpInterfaceFamily::Ipv4, true, 25, false);
assert_eq!(snapshot.identity(), identity);
assert_eq!(snapshot.family(), IpInterfaceFamily::Ipv4);
assert!(snapshot.automatic_metric());
assert_eq!(snapshot.metric(), 25);
assert!(!snapshot.disable_default_routes());
}
#[cfg(not(windows))]
#[test]
fn rejects_route_inspection_on_non_windows() {
@@ -201,6 +298,15 @@ mod tests {
assert!(interface_identity_from_guid("{00112233-4455-6677-8899-AABBCCDDEEFF}").is_err());
}
#[cfg(not(windows))]
#[test]
fn rejects_interface_metric_operations_on_non_windows() {
let identity = NetworkInterfaceIdentity::new(12, 34);
assert!(interface_metric(identity, IpInterfaceFamily::Ipv4).is_err());
assert!(set_scoped_interface_metric(identity, IpInterfaceFamily::Ipv4, 500).is_err());
}
fn ip(value: &str) -> IpAddr {
value.parse().unwrap()
}