fix(client): clear TAP before resolving relay

The previous startup ordering loaded the virtual MAC before touching TAP, but it
also resolved the relay endpoint before clearing stale TAP media state. That
left a post-crash TAP adapter able to influence DNS or route selection before
the client had pinned the relay path.

Split Windows client startup config into a local phase and a resolved runtime
phase. The local phase reads the certificate, room, TAP adapter selection, and
client identity without performing DNS. Windows startup now writes the TAP
NetworkAddress value and marks TAP media disconnected before resolving the relay
endpoint or opening the QUIC connection.

A regression test uses an intentionally unresolved relay hostname to prove that
building the startup config does not resolve DNS. The client still resolves the
relay before activation and still validates the driver-reported TAP MAC before
bridging.

Test Plan:
- cargo test -p lanparty-client-win
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- cargo fmt --check
- git diff --check
- cargo check -p lanparty-client-win --target x86_64-pc-windows-gnu
  - blocked by missing x86_64-w64-mingw32-gcc for ring on this host

Refs: PLAN.md Windows routing / metric handling
This commit is contained in:
2026-05-22 05:47:27 +02:00
parent 563a073d24
commit 2c490b2693
2 changed files with 92 additions and 18 deletions
+11 -10
View File
@@ -217,16 +217,17 @@ cargo run -p lanparty-client-win -- \
The Windows client binary currently connects to the relay as `role = client`
with a generated locally administered virtual MAC persisted in
`lanparty-client-identity.json`. It resolves the relay DNS name before TAP
activation, writes the generated tunnel MAC to the selected TAP driver's
`NetworkAddress` registry setting, and marks TAP media disconnected before
connecting to the relay. That clears stale connected state from a previous
crashed run without letting the TAP adapter influence relay routing. The client
then completes the control-stream hello/welcome handshake, pins a host route
for the resolved relay IP on the current pre-TAP interface, verifies that the
relay route still uses that pinned host route after TAP activation, and bridges
Ethernet frames between the relay and the TAP-Windows6 adapter until shutdown.
`--relay` accepts a DNS name or socket address; bare hosts default to UDP/443.
`lanparty-client-identity.json`. Before resolving or connecting to the relay,
it writes the generated tunnel MAC to the selected TAP driver's
`NetworkAddress` registry setting and marks TAP media disconnected. That clears
stale connected state from a previous crashed run without letting the TAP
adapter influence relay DNS or route selection. The client then resolves the
relay endpoint, completes the control-stream hello/welcome handshake, pins a
host route for the resolved relay IP on the current pre-TAP interface, verifies
that the relay route still uses that pinned host route after TAP activation,
and bridges Ethernet frames between the relay and the TAP-Windows6 adapter
until shutdown. `--relay` accepts a DNS name or socket address; bare hosts
default to UDP/443.
TAP frames whose source MAC does not match that generated tunnel MAC are
dropped locally before they can consume relay bandwidth; the relay still
enforces the same source-MAC rule.
+81 -8
View File
@@ -90,6 +90,17 @@ struct ClientArgs {
max_datagram_size: u16,
}
#[derive(Debug)]
struct ClientStartupConfig {
relay: RelayEndpoint,
server_name: String,
relay_ca_cert: Vec<u8>,
room: RoomCode,
identity: ClientIdentity,
tap_instance_id: Option<String>,
max_datagram_size: u16,
}
#[derive(Debug)]
struct ClientRuntimeConfig {
session: ClientSessionConfig,
@@ -97,7 +108,7 @@ struct ClientRuntimeConfig {
}
impl ClientArgs {
fn into_config(self) -> Result<ClientRuntimeConfig> {
fn into_startup_config(self) -> Result<ClientStartupConfig> {
if self.list_tap_adapters {
bail!("--list-tap-adapters exits before building a tunnel config");
}
@@ -117,16 +128,31 @@ impl ClientArgs {
None => ClientIdentityStore::new(self.identity_file)?.load_or_create()?,
};
let relay_addr = relay
Ok(ClientStartupConfig {
relay,
server_name: self.server_name,
relay_ca_cert,
room,
identity,
tap_instance_id: self.tap_instance_id,
max_datagram_size: self.max_datagram_size,
})
}
}
impl ClientStartupConfig {
fn into_runtime_config(self) -> Result<ClientRuntimeConfig> {
let relay_addr = self
.relay
.resolve()
.context("failed to resolve relay endpoint")?;
let session = ClientSessionConfig::new(
relay_addr,
self.server_name,
relay_ca_cert,
room,
identity.virtual_mac(),
self.relay_ca_cert,
self.room,
self.identity.virtual_mac(),
self.max_datagram_size,
)?;
@@ -145,12 +171,13 @@ async fn main() -> Result<()> {
return Ok(());
}
let config = args.into_config()?;
let startup_config = args.into_startup_config()?;
#[cfg(windows)]
prepare_tap_before_relay_connect(
config.tap_instance_id.as_deref(),
config.session.virtual_mac(),
startup_config.tap_instance_id.as_deref(),
startup_config.identity.virtual_mac(),
)?;
let config = startup_config.into_runtime_config()?;
println!(
"lanparty-client-win connecting virtual MAC {} to relay {} room {}",
config.session.virtual_mac(),
@@ -1053,6 +1080,11 @@ fn open_tap_adapter(_session: &ClientSession) {
#[cfg(test)]
mod tests {
use std::{
fs,
time::{SystemTime, UNIX_EPOCH},
};
use super::*;
use lanparty_ctrl::{DisconnectReason, PeerInfo};
use lanparty_net::DEFAULT_RELAY_PORT;
@@ -1181,6 +1213,38 @@ mod tests {
assert_eq!(relay.port(), DEFAULT_RELAY_PORT);
}
#[test]
fn builds_startup_config_without_resolving_relay() {
let cert_path = unique_temp_file("lanparty-client-cert");
fs::write(&cert_path, [1_u8, 2, 3]).unwrap();
let cert_path_string = cert_path.display().to_string();
let args = ClientArgs::parse_from([
"lanparty-client-win",
"--relay",
"unresolved.invalid",
"--relay-ca-cert",
&cert_path_string,
"--room",
"ROOM1",
"--virtual-mac",
"02:00:00:00:00:09",
"--tap-instance-id",
"{01234567-89AB-CDEF-0123-456789ABCDEF}",
]);
let startup = args.into_startup_config().unwrap();
fs::remove_file(cert_path).unwrap();
assert_eq!(startup.relay.host(), "unresolved.invalid");
assert_eq!(startup.relay_ca_cert, vec![1, 2, 3]);
assert_eq!(startup.room.as_str(), "ROOM1");
assert_eq!(startup.identity.virtual_mac(), mac(9));
assert_eq!(
startup.tap_instance_id.as_deref(),
Some("{01234567-89AB-CDEF-0123-456789ABCDEF}")
);
}
#[test]
fn accepts_tap_adapter_listing_without_tunnel_args() {
let args = ClientArgs::parse_from(["lanparty-client-win", "--list-tap-adapters"]);
@@ -1323,4 +1387,13 @@ mod tests {
fn tap_info(instance_id: &str) -> TapAdapterInfo {
TapAdapterInfo::new(instance_id, "tap0901").unwrap()
}
fn unique_temp_file(prefix: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
}
}