feat(client): list TAP adapters before connecting

Add a --list-tap-adapters mode to the Windows client. The command prints the
TAP-Windows6 adapter instance ids and exits without requiring relay, room, or
certificate arguments.

This makes the manual MVP test smoother on machines with multiple TAP adapters:
the operator can ask the binary for the exact ids, then rerun with
--tap-instance-id instead of relying on a separate PowerShell query or waiting
for the ambiguous-adapter startup error.

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:14:50 +02:00
parent e619866112
commit 2f0802dfcf
3 changed files with 100 additions and 19 deletions
+2 -1
View File
@@ -227,7 +227,8 @@ reinstalled before it reloads the configured `NetworkAddress`.
If exactly one TAP-Windows6 adapter is installed, the client opens it If exactly one TAP-Windows6 adapter is installed, the client opens it
automatically. If multiple TAP-Windows6 adapters are installed, startup fails automatically. If multiple TAP-Windows6 adapters are installed, startup fails
until `--tap-instance-id` selects the intended adapter by NetCfgInstanceId / until `--tap-instance-id` selects the intended adapter by NetCfgInstanceId /
InterfaceGuid. InterfaceGuid. `--list-tap-adapters` prints the TAP adapter ids and exits
without connecting.
It prints and reports client diagnostics snapshots with relay reachability, It prints and reports client diagnostics snapshots with relay reachability,
LAN-gateway presence, route-pinning, QUIC datagram budget, relay RTT, TAP LAN-gateway presence, route-pinning, QUIC datagram budget, relay RTT, TAP
status/IP, broadcast frame flow, frame/datagram counters, and drops. The status/IP, broadcast frame flow, frame/datagram counters, and drops. The
+1 -2
View File
@@ -104,8 +104,7 @@ If the Windows machine has multiple TAP-Windows6 adapters, select the intended
one explicitly: one explicitly:
```powershell ```powershell
Get-NetAdapter | Where-Object InterfaceDescription -Like "*TAP*" | .\target\release\lanparty-client-win.exe --list-tap-adapters
Select-Object Name, InterfaceGuid, InterfaceDescription
.\target\release\lanparty-client-win.exe ` .\target\release\lanparty-client-win.exe `
--relay relay.example.net:8443 ` --relay relay.example.net:8443 `
+97 -16
View File
@@ -20,7 +20,6 @@ use lanparty_client_route::{
}; };
#[cfg(windows)] #[cfg(windows)]
use lanparty_client_tap::TapAdapter; use lanparty_client_tap::TapAdapter;
#[cfg(any(windows, test))]
use lanparty_client_tap::TapAdapterInfo; use lanparty_client_tap::TapAdapterInfo;
use lanparty_ctrl::{ControlMessage, PeerInfo, Role, RoomCode}; use lanparty_ctrl::{ControlMessage, PeerInfo, Role, RoomCode};
use lanparty_net::RelayEndpoint; use lanparty_net::RelayEndpoint;
@@ -42,21 +41,33 @@ const CLIENT_DIAGNOSTICS_INTERVAL: Duration = Duration::from_secs(10);
about = "Windows TAP client for the LAN party L2 tunnel" about = "Windows TAP client for the LAN party L2 tunnel"
)] )]
struct ClientArgs { struct ClientArgs {
/// List TAP-Windows6 adapter ids and exit without connecting.
#[arg(long)]
list_tap_adapters: bool,
/// Relay DNS name or UDP socket address; bare hosts default to UDP/443. /// Relay DNS name or UDP socket address; bare hosts default to UDP/443.
#[arg(long, value_name = "HOST[:PORT]")] #[arg(
relay: RelayEndpoint, long,
value_name = "HOST[:PORT]",
required_unless_present = "list_tap_adapters"
)]
relay: Option<RelayEndpoint>,
/// TLS server name expected in the relay certificate. /// TLS server name expected in the relay certificate.
#[arg(long, default_value = "lanparty-relay.local")] #[arg(long, default_value = "lanparty-relay.local")]
server_name: String, server_name: String,
/// DER-encoded relay CA/certificate to trust. /// DER-encoded relay CA/certificate to trust.
#[arg(long, value_name = "PATH")] #[arg(
relay_ca_cert: PathBuf, long,
value_name = "PATH",
required_unless_present = "list_tap_adapters"
)]
relay_ca_cert: Option<PathBuf>,
/// Room code to join as a remote client. /// Room code to join as a remote client.
#[arg(long)] #[arg(long, required_unless_present = "list_tap_adapters")]
room: RoomCode, room: Option<RoomCode>,
/// Identity JSON file used to persist the generated virtual MAC. /// Identity JSON file used to persist the generated virtual MAC.
#[arg( #[arg(
@@ -87,10 +98,17 @@ struct ClientRuntimeConfig {
impl ClientArgs { impl ClientArgs {
fn into_config(self) -> Result<ClientRuntimeConfig> { fn into_config(self) -> Result<ClientRuntimeConfig> {
let relay_ca_cert = fs::read(&self.relay_ca_cert).with_context(|| { if self.list_tap_adapters {
bail!("--list-tap-adapters exits before building a tunnel config");
}
let relay = self.relay.context("--relay is required")?;
let relay_ca_cert_path = self.relay_ca_cert.context("--relay-ca-cert is required")?;
let room = self.room.context("--room is required")?;
let relay_ca_cert = fs::read(&relay_ca_cert_path).with_context(|| {
format!( format!(
"failed to read relay CA certificate {}", "failed to read relay CA certificate {}",
self.relay_ca_cert.display() relay_ca_cert_path.display()
) )
})?; })?;
@@ -99,16 +117,15 @@ impl ClientArgs {
None => ClientIdentityStore::new(self.identity_file)?.load_or_create()?, None => ClientIdentityStore::new(self.identity_file)?.load_or_create()?,
}; };
let relay_addr = self let relay_addr = relay
.relay
.resolve() .resolve()
.with_context(|| format!("failed to resolve relay endpoint {}", self.relay))?; .context("failed to resolve relay endpoint")?;
let session = ClientSessionConfig::new( let session = ClientSessionConfig::new(
relay_addr, relay_addr,
self.server_name, self.server_name,
relay_ca_cert, relay_ca_cert,
self.room, room,
identity.virtual_mac(), identity.virtual_mac(),
self.max_datagram_size, self.max_datagram_size,
)?; )?;
@@ -122,7 +139,13 @@ impl ClientArgs {
#[tokio::main] #[tokio::main]
async fn main() -> Result<()> { async fn main() -> Result<()> {
let config = ClientArgs::parse().into_config()?; let args = ClientArgs::parse();
if args.list_tap_adapters {
print_available_tap_adapters()?;
return Ok(());
}
let config = args.into_config()?;
println!( println!(
"lanparty-client-win connecting virtual MAC {} to relay {} room {}", "lanparty-client-win connecting virtual MAC {} to relay {} room {}",
config.session.virtual_mac(), config.session.virtual_mac(),
@@ -165,6 +188,15 @@ async fn main() -> Result<()> {
run_result run_result
} }
fn print_available_tap_adapters() -> Result<()> {
let adapters = lanparty_client_tap::available_adapters()?;
for line in format_tap_adapter_list(&adapters) {
println!("{line}");
}
Ok(())
}
#[cfg(windows)] #[cfg(windows)]
async fn run_client( async fn run_client(
session: &ClientSession, session: &ClientSession,
@@ -488,6 +520,29 @@ fn available_tap_adapter_label(adapters: &[TapAdapterInfo]) -> String {
.join(", ") .join(", ")
} }
fn format_tap_adapter_list(adapters: &[TapAdapterInfo]) -> Vec<String> {
if adapters.is_empty() {
return vec!["No TAP-Windows6 adapters found".to_owned()];
}
let mut lines = Vec::with_capacity(adapters.len() + 1);
lines.push("TAP-Windows6 adapters:".to_owned());
lines.extend(adapters.iter().map(|adapter| {
let driver_key = adapter
.driver_key_name()
.map(|driver_key| format!(" driver-key={driver_key}"))
.unwrap_or_default();
format!(
" {} component={}{}",
adapter.instance_id(),
adapter.component_id(),
driver_key
)
}));
lines
}
fn client_diagnostics_snapshot( fn client_diagnostics_snapshot(
session: &ClientSession, session: &ClientSession,
route_pinned: bool, route_pinned: bool,
@@ -1059,8 +1114,19 @@ mod tests {
"ROOM1", "ROOM1",
]); ]);
assert_eq!(args.relay.host(), "relay.example.test"); let relay = args.relay.unwrap();
assert_eq!(args.relay.port(), DEFAULT_RELAY_PORT); assert_eq!(relay.host(), "relay.example.test");
assert_eq!(relay.port(), DEFAULT_RELAY_PORT);
}
#[test]
fn accepts_tap_adapter_listing_without_tunnel_args() {
let args = ClientArgs::parse_from(["lanparty-client-win", "--list-tap-adapters"]);
assert!(args.list_tap_adapters);
assert!(args.relay.is_none());
assert!(args.relay_ca_cert.is_none());
assert!(args.room.is_none());
} }
#[test] #[test]
@@ -1129,6 +1195,21 @@ mod tests {
assert!(error.contains("tap-two")); assert!(error.contains("tap-two"));
} }
#[test]
fn formats_tap_adapter_listing() {
assert_eq!(
format_tap_adapter_list(&[]),
vec!["No TAP-Windows6 adapters found".to_owned()]
);
assert_eq!(
format_tap_adapter_list(&[tap_info("tap-one")]),
vec![
"TAP-Windows6 adapters:".to_owned(),
" tap-one component=tap0901".to_owned(),
]
);
}
#[test] #[test]
fn formats_relay_lifecycle_events() { fn formats_relay_lifecycle_events() {
let gateway = ControlMessage::PeerJoined(PeerInfo::new(1, Role::Gateway, None).unwrap()); let gateway = ControlMessage::PeerJoined(PeerInfo::new(1, Role::Gateway, None).unwrap());