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
This commit is contained in:
2026-05-21 23:11:01 +02:00
parent b17b6f0683
commit e619866112
4 changed files with 185 additions and 19 deletions
+4
View File
@@ -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
+15
View File
@@ -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
+1 -1
View File
@@ -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" }
+165 -18
View File
@@ -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<MacAddr>,
/// TAP-Windows6 NetCfgInstanceId/InterfaceGuid to open.
#[arg(long, value_name = "GUID")]
tap_instance_id: Option<String>,
/// 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<String>,
}
impl ClientArgs {
fn into_config(self) -> Result<ClientSessionConfig> {
fn into_config(self) -> Result<ClientRuntimeConfig> {
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<OpenedTapAdapter> {
let tap = open_configured_tap_adapter(session.config().virtual_mac())?;
fn open_tap_adapter(
session: &ClientSession,
tap_instance_id: Option<&str>,
) -> Result<OpenedTapAdapter> {
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<OpenedTapAdapter> {
}
#[cfg(windows)]
fn open_configured_tap_adapter(virtual_mac: MacAddr) -> Result<TapAdapter> {
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<TapAdapter> {
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> {
TapAdapter::open(info)
}
#[cfg(any(windows, test))]
fn select_tap_adapter(
mut adapters: Vec<TapAdapterInfo>,
requested_instance_id: Option<&str>,
) -> Result<TapAdapterInfo> {
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::<Vec<_>>()
.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()
}
}