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() + } }