From e6198661120dc95a2a060ab03830eb9019c4b1bb Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 23:11:01 +0200 Subject: [PATCH] feat(client): require explicit TAP selection when ambiguous Add a Windows client --tap-instance-id option for selecting a specific TAP-Windows6 adapter by NetCfgInstanceId / InterfaceGuid. The client still opens the only installed TAP adapter automatically, but now refuses to choose an arbitrary adapter when multiple TAP-Windows6 adapters are present. This keeps the MVP test run from silently configuring and opening the wrong TAP adapter on machines that already have VPN or test TAP devices installed. The error lists available adapter instance ids so the operator can rerun with the intended value. Test Plan: - cargo fmt --check - cargo test -p lanparty-client-win - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: PLAN.md Windows client TAP adapter responsibility --- README.md | 4 + TESTING.md | 15 ++ crates/lanparty-client-win/Cargo.toml | 2 +- crates/lanparty-client-win/src/main.rs | 183 ++++++++++++++++++++++--- 4 files changed, 185 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index ed642e8..dba34f1 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,10 @@ before the scoped protection was applied. Startup still fails before bridging if the driver-reported MAC does not match the tunnel identity, because an already-initialized Windows TAP adapter may need to be disabled/enabled or reinstalled before it reloads the configured `NetworkAddress`. +If exactly one TAP-Windows6 adapter is installed, the client opens it +automatically. If multiple TAP-Windows6 adapters are installed, startup fails +until `--tap-instance-id` selects the intended adapter by NetCfgInstanceId / +InterfaceGuid. It prints and reports client diagnostics snapshots with relay reachability, LAN-gateway presence, route-pinning, QUIC datagram budget, relay RTT, TAP status/IP, broadcast frame flow, frame/datagram counters, and drops. The diff --git a/TESTING.md b/TESTING.md index 8256065..897c57e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -100,6 +100,21 @@ In an Administrator terminal on Windows: --room ROOM1 ``` +If the Windows machine has multiple TAP-Windows6 adapters, select the intended +one explicitly: + +```powershell +Get-NetAdapter | Where-Object InterfaceDescription -Like "*TAP*" | + Select-Object Name, InterfaceGuid, InterfaceDescription + +.\target\release\lanparty-client-win.exe ` + --relay relay.example.net:8443 ` + --server-name lanparty-relay.local ` + --relay-ca-cert .\relay-cert.der ` + --room ROOM1 ` + --tap-instance-id "{InterfaceGuid-from-the-command-above}" +``` + Expected client output: ```text diff --git a/crates/lanparty-client-win/Cargo.toml b/crates/lanparty-client-win/Cargo.toml index bf2d620..82dcca4 100644 --- a/crates/lanparty-client-win/Cargo.toml +++ b/crates/lanparty-client-win/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true anyhow.workspace = true clap.workspace = true lanparty-client-core = { path = "../lanparty-client-core" } +lanparty-client-tap = { path = "../lanparty-client-tap" } lanparty-ctrl = { path = "../lanparty-ctrl" } lanparty-net = { path = "../lanparty-net" } lanparty-obs = { path = "../lanparty-obs" } @@ -15,4 +16,3 @@ tokio.workspace = true [target.'cfg(windows)'.dependencies] lanparty-client-route = { path = "../lanparty-client-route" } -lanparty-client-tap = { path = "../lanparty-client-tap" } diff --git a/crates/lanparty-client-win/src/main.rs b/crates/lanparty-client-win/src/main.rs index fb357eb..0d7fa44 100644 --- a/crates/lanparty-client-win/src/main.rs +++ b/crates/lanparty-client-win/src/main.rs @@ -20,6 +20,8 @@ use lanparty_client_route::{ }; #[cfg(windows)] use lanparty_client_tap::TapAdapter; +#[cfg(any(windows, test))] +use lanparty_client_tap::TapAdapterInfo; use lanparty_ctrl::{ControlMessage, PeerInfo, Role, RoomCode}; use lanparty_net::RelayEndpoint; use lanparty_obs::{ @@ -68,13 +70,23 @@ struct ClientArgs { #[arg(long)] virtual_mac: Option, + /// TAP-Windows6 NetCfgInstanceId/InterfaceGuid to open. + #[arg(long, value_name = "GUID")] + tap_instance_id: Option, + /// Client's advertised QUIC datagram budget before relay clamping. #[arg(long, default_value_t = 1400)] max_datagram_size: u16, } +#[derive(Debug)] +struct ClientRuntimeConfig { + session: ClientSessionConfig, + tap_instance_id: Option, +} + impl ClientArgs { - fn into_config(self) -> Result { + fn into_config(self) -> Result { let relay_ca_cert = fs::read(&self.relay_ca_cert).with_context(|| { format!( "failed to read relay CA certificate {}", @@ -92,14 +104,19 @@ impl ClientArgs { .resolve() .with_context(|| format!("failed to resolve relay endpoint {}", self.relay))?; - ClientSessionConfig::new( + let session = ClientSessionConfig::new( relay_addr, self.server_name, relay_ca_cert, self.room, identity.virtual_mac(), self.max_datagram_size, - ) + )?; + + Ok(ClientRuntimeConfig { + session, + tap_instance_id: self.tap_instance_id, + }) } } @@ -108,12 +125,12 @@ async fn main() -> Result<()> { let config = ClientArgs::parse().into_config()?; println!( "lanparty-client-win connecting virtual MAC {} to relay {} room {}", - config.virtual_mac(), - config.relay_addr(), - config.room() + config.session.virtual_mac(), + config.session.relay_addr(), + config.session.room() ); - let session = connect_client(config).await?; + let session = connect_client(config.session).await?; println!( "lanparty-client-win connected as peer {} in room id {} with TAP MTU {} over {}; LAN gateway connected {}", session.welcome().peer_id(), @@ -131,7 +148,14 @@ async fn main() -> Result<()> { } }; #[cfg(windows)] - let run_result = run_client(&session, &relay_route_pin).await; + let run_result = run_client( + &session, + &relay_route_pin, + config.tap_instance_id.as_deref(), + ) + .await; + #[cfg(not(windows))] + let _ = config.tap_instance_id; #[cfg(not(windows))] let run_result = run_client(&session).await; session.shutdown("client shutting down").await; @@ -142,14 +166,18 @@ async fn main() -> Result<()> { } #[cfg(windows)] -async fn run_client(session: &ClientSession, relay_route_pin: &PinnedRelayRoute) -> Result<()> { +async fn run_client( + session: &ClientSession, + relay_route_pin: &PinnedRelayRoute, + tap_instance_id: Option<&str>, +) -> Result<()> { let relay_status = ClientRelayStatus::from_welcome(session); let OpenedTapAdapter { tap, tap_diagnostics, tap_interface, _route_guard, - } = open_tap_adapter(session)?; + } = open_tap_adapter(session, tap_instance_id)?; let relay_route = verify_relay_route_is_pinned(session.config().relay_addr().ip(), relay_route_pin) .context("relay route changed after TAP activation")?; @@ -353,8 +381,11 @@ struct TapRouteProtectionGuard { } #[cfg(windows)] -fn open_tap_adapter(session: &ClientSession) -> Result { - let tap = open_configured_tap_adapter(session.config().virtual_mac())?; +fn open_tap_adapter( + session: &ClientSession, + tap_instance_id: Option<&str>, +) -> Result { + let tap = open_configured_tap_adapter(session.config().virtual_mac(), tap_instance_id)?; let tap_interface = lanparty_client_route::interface_identity_from_guid(tap.info().instance_id()) .context("failed to resolve TAP interface identity")?; @@ -395,12 +426,12 @@ fn open_tap_adapter(session: &ClientSession) -> Result { } #[cfg(windows)] -fn open_configured_tap_adapter(virtual_mac: MacAddr) -> Result { - let mut adapters = lanparty_client_tap::available_adapters()?; - let info = adapters - .drain(..) - .next() - .context("no TAP-Windows6 adapters found")?; +fn open_configured_tap_adapter( + virtual_mac: MacAddr, + tap_instance_id: Option<&str>, +) -> Result { + let adapters = lanparty_client_tap::available_adapters()?; + let info = select_tap_adapter(adapters, tap_instance_id)?; lanparty_client_tap::configure_adapter_mac(&info, virtual_mac).with_context(|| { format!( "failed to persist TAP MAC {virtual_mac} for adapter {}", @@ -411,6 +442,52 @@ fn open_configured_tap_adapter(virtual_mac: MacAddr) -> Result { TapAdapter::open(info) } +#[cfg(any(windows, test))] +fn select_tap_adapter( + mut adapters: Vec, + requested_instance_id: Option<&str>, +) -> Result { + if let Some(requested) = requested_instance_id { + let requested = requested.trim(); + if requested.is_empty() { + bail!("TAP adapter instance id cannot be empty"); + } + if let Some(index) = adapters + .iter() + .position(|adapter| adapter.instance_id().eq_ignore_ascii_case(requested)) + { + return Ok(adapters.remove(index)); + } + + bail!( + "TAP adapter instance id {requested:?} was not found; available TAP adapters: {}", + available_tap_adapter_label(&adapters) + ); + } + + match adapters.len() { + 0 => bail!("no TAP-Windows6 adapters found"), + 1 => Ok(adapters.remove(0)), + _ => bail!( + "multiple TAP-Windows6 adapters found; pass --tap-instance-id with one of: {}", + available_tap_adapter_label(&adapters) + ), + } +} + +#[cfg(any(windows, test))] +fn available_tap_adapter_label(adapters: &[TapAdapterInfo]) -> String { + if adapters.is_empty() { + return "none".to_owned(); + } + + adapters + .iter() + .map(TapAdapterInfo::instance_id) + .collect::>() + .join(", ") +} + fn client_diagnostics_snapshot( session: &ClientSession, route_pinned: bool, @@ -986,6 +1063,72 @@ mod tests { assert_eq!(args.relay.port(), DEFAULT_RELAY_PORT); } + #[test] + fn parses_tap_instance_id() { + let args = ClientArgs::parse_from([ + "lanparty-client-win", + "--relay", + "relay.example.test", + "--relay-ca-cert", + "relay-cert.der", + "--room", + "ROOM1", + "--tap-instance-id", + "{01234567-89AB-CDEF-0123-456789ABCDEF}", + ]); + + assert_eq!( + args.tap_instance_id.as_deref(), + Some("{01234567-89AB-CDEF-0123-456789ABCDEF}") + ); + } + + #[test] + fn selects_only_tap_adapter_by_default() { + let selected = select_tap_adapter(vec![tap_info("tap-one")], None).unwrap(); + + assert_eq!(selected.instance_id(), "tap-one"); + } + + #[test] + fn rejects_multiple_tap_adapters_without_selection() { + let error = select_tap_adapter(vec![tap_info("tap-one"), tap_info("tap-two")], None) + .unwrap_err() + .to_string(); + + assert!(error.contains("multiple TAP-Windows6 adapters")); + assert!(error.contains("tap-one")); + assert!(error.contains("tap-two")); + } + + #[test] + fn selects_requested_tap_adapter_case_insensitively() { + let selected = select_tap_adapter( + vec![tap_info("{AAAAAAAA-0000-0000-0000-000000000001}")], + Some("{aaaaaaaa-0000-0000-0000-000000000001}"), + ) + .unwrap(); + + assert_eq!( + selected.instance_id(), + "{AAAAAAAA-0000-0000-0000-000000000001}" + ); + } + + #[test] + fn reports_available_tap_adapters_when_selection_is_missing() { + let error = select_tap_adapter( + vec![tap_info("tap-one"), tap_info("tap-two")], + Some("tap-missing"), + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("tap-missing")); + assert!(error.contains("tap-one")); + assert!(error.contains("tap-two")); + } + #[test] fn formats_relay_lifecycle_events() { let gateway = ControlMessage::PeerJoined(PeerInfo::new(1, Role::Gateway, None).unwrap()); @@ -1033,4 +1176,8 @@ mod tests { const fn mac(last_octet: u8) -> MacAddr { MacAddr::new([0x02, 0, 0, 0, 0, last_octet]) } + + fn tap_info(instance_id: &str) -> TapAdapterInfo { + TapAdapterInfo::new(instance_id, "tap0901").unwrap() + } }