Files
softlan-vpn/crates/lanparty-client-win/src/main.rs
T
ddidderr 3e2648abc1 feat(client): scope TAP interface MTU while running
The Windows client now sets the TAP IP-interface MTU to the relay-selected MTU
before it starts bridging frames. The override is scoped like the existing
metric and default-route guards, so the previous MTU is restored when the
client exits.

The route crate now exposes `InterfaceMtuSnapshot` and `ScopedInterfaceMtu`
around `MIB_IPINTERFACE_ROW.NlMtu`, reusing the same `GetIpInterfaceEntry` and
`SetIpInterfaceEntry` path already used for metrics and default-route policy.
IPv4 MTU setup is required for startup, while IPv6 MTU setup is best-effort to
match the existing IPv6 route-protection behavior.

This intentionally leaves TAP MAC configuration as fail-fast. TAP-Windows6 does
not expose a matching set-MAC IOCTL in the driver header, so that should remain
a separate design decision.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-client-route
- cargo test -p lanparty-client-win
- cargo clippy -p lanparty-client-route -p lanparty-client-win --all-targets
  -- -D warnings
- cargo check -p lanparty-client-route --target x86_64-pc-windows-gnu
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- git diff --check
- cargo check -p lanparty-client-win --target x86_64-pc-windows-gnu
  (fails before this crate in ring: missing x86_64-w64-mingw32-gcc)

Refs: PLAN.md
2026-05-21 20:00:58 +02:00

544 lines
17 KiB
Rust

use std::{fs, net::SocketAddr, path::PathBuf};
#[cfg(windows)]
use std::{
sync::{Arc, mpsc},
thread,
time::Duration,
};
use anyhow::{Context, Result, bail};
use clap::Parser;
#[cfg(windows)]
use lanparty_client_core::ClientRelayIo;
use lanparty_client_core::{
ClientIdentity, ClientIdentityStore, ClientSession, ClientSessionConfig, connect_client,
};
#[cfg(windows)]
use lanparty_client_route::{
IpInterfaceFamily, NetworkInterfaceIdentity, PinnedRelayRoute, RouteSnapshot,
ScopedDefaultRoutes, ScopedInterfaceMetric, ScopedInterfaceMtu,
};
#[cfg(windows)]
use lanparty_client_tap::TapAdapter;
use lanparty_ctrl::RoomCode;
use lanparty_proto::MacAddr;
#[cfg(windows)]
const TAP_INTERFACE_METRIC: u32 = 9_000;
#[cfg(windows)]
const RELAY_ROUTE_VERIFY_INTERVAL: Duration = Duration::from_secs(5);
#[derive(Debug, Parser)]
#[command(
name = "lanparty-client-win",
about = "Windows TAP client for the LAN party L2 tunnel"
)]
struct ClientArgs {
/// Relay UDP socket address, for example 203.0.113.10:443.
#[arg(long)]
relay: SocketAddr,
/// TLS server name expected in the relay certificate.
#[arg(long, default_value = "lanparty-relay.local")]
server_name: String,
/// DER-encoded relay CA/certificate to trust.
#[arg(long, value_name = "PATH")]
relay_ca_cert: PathBuf,
/// Room code to join as a remote client.
#[arg(long)]
room: RoomCode,
/// Identity JSON file used to persist the generated virtual MAC.
#[arg(
long = "identity-file",
value_name = "PATH",
default_value = "lanparty-client-identity.json"
)]
identity_file: PathBuf,
/// Override the generated locally administered TAP MAC address.
#[arg(long)]
virtual_mac: Option<MacAddr>,
/// Client's advertised QUIC datagram budget before relay clamping.
#[arg(long, default_value_t = 1400)]
max_datagram_size: u16,
}
impl ClientArgs {
fn into_config(self) -> Result<ClientSessionConfig> {
let relay_ca_cert = fs::read(&self.relay_ca_cert).with_context(|| {
format!(
"failed to read relay CA certificate {}",
self.relay_ca_cert.display()
)
})?;
let identity = match self.virtual_mac {
Some(virtual_mac) => ClientIdentity::new(virtual_mac)?,
None => ClientIdentityStore::new(self.identity_file)?.load_or_create()?,
};
ClientSessionConfig::new(
self.relay,
self.server_name,
relay_ca_cert,
self.room,
identity.virtual_mac(),
self.max_datagram_size,
)
}
}
#[tokio::main]
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()
);
let session = connect_client(config).await?;
println!(
"lanparty-client-win connected as peer {} in room id {} with TAP MTU {}",
session.welcome().peer_id(),
session.welcome().room_id(),
session.welcome().effective_tap_mtu()
);
#[cfg(windows)]
let relay_route_pin = match pin_relay_route_before_tap(session.config().relay_addr().ip()) {
Ok(pin) => pin,
Err(error) => {
session.shutdown("client startup failed").await;
return Err(error);
}
};
#[cfg(windows)]
let run_result = run_client(&session, &relay_route_pin).await;
#[cfg(not(windows))]
let run_result = run_client(&session).await;
session.shutdown("client shutting down").await;
#[cfg(windows)]
drop(relay_route_pin);
run_result
}
#[cfg(windows)]
async fn run_client(session: &ClientSession, relay_route_pin: &PinnedRelayRoute) -> Result<()> {
let OpenedTapAdapter { tap, _route_guard } = open_tap_adapter(session)?;
let relay_route =
verify_relay_route_is_pinned(session.config().relay_addr().ip(), relay_route_pin)
.context("relay route changed after TAP activation")?;
print_verified_relay_route(&relay_route);
println!(
"bridging TAP frames; relay route is pinned and TAP route policy is scoped; press Ctrl-C to stop"
);
let frame_pump = run_tap_frame_pump(session.relay_io(), tap);
tokio::pin!(frame_pump);
let shutdown = tokio::signal::ctrl_c();
tokio::pin!(shutdown);
let mut relay_route_check = tokio::time::interval_at(
tokio::time::Instant::now() + RELAY_ROUTE_VERIFY_INTERVAL,
RELAY_ROUTE_VERIFY_INTERVAL,
);
relay_route_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
loop {
tokio::select! {
result = &mut frame_pump => return result,
result = &mut shutdown => return result.context("failed to wait for Ctrl-C"),
_ = relay_route_check.tick() => {
verify_relay_route_is_pinned(
session.config().relay_addr().ip(),
relay_route_pin,
)
.context("relay route changed while bridging")?;
}
}
}
}
#[cfg(not(windows))]
async fn run_client(session: &ClientSession) -> Result<()> {
open_tap_adapter(session);
println!("TAP frame pump and route pinning are not wired yet; press Ctrl-C to stop");
tokio::signal::ctrl_c()
.await
.context("failed to wait for Ctrl-C")
}
#[cfg(windows)]
fn pin_relay_route_before_tap(destination: std::net::IpAddr) -> Result<PinnedRelayRoute> {
let route = lanparty_client_route::best_route_to(destination)
.context("failed to inspect relay route before TAP activation")?;
print_relay_route(&route);
let pin = lanparty_client_route::pin_relay_route(&route)
.context("failed to pin relay route before TAP activation")?;
print_pinned_relay_route(&pin);
Ok(pin)
}
#[cfg(windows)]
fn print_relay_route(route: &RouteSnapshot) {
println!(
"relay route before TAP: destination {} source {} next hop {} interface index {} LUID {} prefix {}/{} metric {}",
route.destination(),
route.source(),
route
.next_hop()
.map_or_else(|| "on-link".to_string(), |next_hop| next_hop.to_string()),
route.interface_index(),
route.interface_luid(),
route.route_prefix(),
route.route_prefix_len(),
route.metric()
);
}
#[cfg(windows)]
fn print_pinned_relay_route(route: &PinnedRelayRoute) {
println!(
"relay route pinned before TAP: destination {} next hop {} interface index {} LUID {}",
route.destination(),
route
.next_hop()
.map_or_else(|| "on-link".to_string(), |next_hop| next_hop.to_string()),
route.interface_index(),
route.interface_luid()
);
}
#[cfg(windows)]
fn verify_relay_route_is_pinned(
destination: std::net::IpAddr,
pin: &PinnedRelayRoute,
) -> Result<RouteSnapshot> {
let route = lanparty_client_route::best_route_to(destination)
.context("failed to inspect relay route")?;
if !relay_route_matches_pin(&route, pin) {
bail!(
"relay route to {} uses prefix {}/{} next hop {} interface index {} LUID {}, expected pinned host route via next hop {} interface index {} LUID {}",
route.destination(),
route.route_prefix(),
route.route_prefix_len(),
route_next_hop_label(route.next_hop()),
route.interface_index(),
route.interface_luid(),
route_next_hop_label(pin.next_hop()),
pin.interface_index(),
pin.interface_luid(),
);
}
Ok(route)
}
#[cfg(windows)]
fn relay_route_matches_pin(route: &RouteSnapshot, pin: &PinnedRelayRoute) -> bool {
route.destination() == pin.destination()
&& route.is_host_route_to(pin.destination())
&& route.next_hop() == pin.next_hop()
&& route.interface_index() == pin.interface_index()
&& route.interface_luid() == pin.interface_luid()
}
#[cfg(windows)]
fn print_verified_relay_route(route: &RouteSnapshot) {
println!(
"relay route verified after TAP activation: destination {} next hop {} interface index {} LUID {} prefix {}/{} metric {}",
route.destination(),
route_next_hop_label(route.next_hop()),
route.interface_index(),
route.interface_luid(),
route.route_prefix(),
route.route_prefix_len(),
route.metric()
);
}
#[cfg(windows)]
fn route_next_hop_label(next_hop: Option<std::net::IpAddr>) -> String {
next_hop.map_or_else(|| "on-link".to_string(), |next_hop| next_hop.to_string())
}
#[cfg(windows)]
struct OpenedTapAdapter {
tap: TapAdapter,
_route_guard: TapRouteProtectionGuard,
}
#[cfg(windows)]
struct TapRouteProtectionGuard {
_ipv4_metric: ScopedInterfaceMetric,
_ipv4_default_routes: ScopedDefaultRoutes,
_ipv4_mtu: ScopedInterfaceMtu,
_ipv6_metric: Option<ScopedInterfaceMetric>,
_ipv6_default_routes: Option<ScopedDefaultRoutes>,
_ipv6_mtu: Option<ScopedInterfaceMtu>,
}
#[cfg(windows)]
fn open_tap_adapter(session: &ClientSession) -> Result<OpenedTapAdapter> {
let tap = lanparty_client_tap::open_first_adapter()?;
let tap_interface =
lanparty_client_route::interface_identity_from_guid(tap.info().instance_id())
.context("failed to resolve TAP interface identity")?;
let route_guard = protect_tap_routes(tap_interface, session.welcome().effective_tap_mtu())?;
let driver_mac = tap.driver_mac()?;
let driver_mtu = tap.driver_mtu()?;
validate_tap_driver_mac(session.config().virtual_mac(), driver_mac)?;
tap.set_media_connected(true)?;
println!(
"lanparty-client-win opened TAP adapter {}",
tap.info().device_path()
);
println!(
"TAP driver reports MAC {} and MTU {}; relay selected TAP MTU {}",
driver_mac,
driver_mtu,
session.welcome().effective_tap_mtu()
);
println!(
"TAP interface index {} LUID {}",
tap_interface.index(),
tap_interface.luid()
);
Ok(OpenedTapAdapter {
tap,
_route_guard: route_guard,
})
}
#[cfg_attr(not(windows), allow(dead_code))]
fn validate_tap_driver_mac(expected_mac: MacAddr, driver_mac: MacAddr) -> Result<()> {
if driver_mac != expected_mac {
bail!(
"TAP driver MAC {driver_mac} does not match tunnel identity {expected_mac}; automatic MAC configuration is not wired yet"
);
}
Ok(())
}
#[cfg(windows)]
fn protect_tap_routes(
identity: NetworkInterfaceIdentity,
tap_mtu: u16,
) -> Result<TapRouteProtectionGuard> {
let tap_mtu = u32::from(tap_mtu);
let ipv4_mtu =
lanparty_client_route::set_scoped_interface_mtu(identity, IpInterfaceFamily::Ipv4, tap_mtu)
.context("failed to set TAP IPv4 MTU")?;
print_tap_mtu_override(IpInterfaceFamily::Ipv4, tap_mtu, &ipv4_mtu);
let ipv4_metric = lanparty_client_route::set_scoped_interface_metric(
identity,
IpInterfaceFamily::Ipv4,
TAP_INTERFACE_METRIC,
)
.context("failed to set TAP IPv4 interface metric")?;
print_tap_metric_override(IpInterfaceFamily::Ipv4, &ipv4_metric);
let ipv4_default_routes = lanparty_client_route::set_scoped_default_routes_disabled(
identity,
IpInterfaceFamily::Ipv4,
true,
)
.context("failed to disable TAP IPv4 default routes")?;
print_tap_default_routes_override(IpInterfaceFamily::Ipv4, &ipv4_default_routes);
let ipv6_mtu = match lanparty_client_route::set_scoped_interface_mtu(
identity,
IpInterfaceFamily::Ipv6,
tap_mtu,
) {
Ok(mtu) => {
print_tap_mtu_override(IpInterfaceFamily::Ipv6, tap_mtu, &mtu);
Some(mtu)
}
Err(error) => {
eprintln!("failed to set TAP IPv6 MTU; IPv6 may use the previous MTU: {error:#}");
None
}
};
let ipv6_metric = match lanparty_client_route::set_scoped_interface_metric(
identity,
IpInterfaceFamily::Ipv6,
TAP_INTERFACE_METRIC,
) {
Ok(metric) => {
print_tap_metric_override(IpInterfaceFamily::Ipv6, &metric);
Some(metric)
}
Err(error) => {
eprintln!(
"failed to set TAP IPv6 interface metric; IPv6 route protection may be incomplete: {error:#}"
);
None
}
};
let ipv6_default_routes = match lanparty_client_route::set_scoped_default_routes_disabled(
identity,
IpInterfaceFamily::Ipv6,
true,
) {
Ok(default_routes) => {
print_tap_default_routes_override(IpInterfaceFamily::Ipv6, &default_routes);
Some(default_routes)
}
Err(error) => {
eprintln!(
"failed to disable TAP IPv6 default routes; IPv6 route protection may be incomplete: {error:#}"
);
None
}
};
Ok(TapRouteProtectionGuard {
_ipv4_metric: ipv4_metric,
_ipv4_default_routes: ipv4_default_routes,
_ipv4_mtu: ipv4_mtu,
_ipv6_metric: ipv6_metric,
_ipv6_default_routes: ipv6_default_routes,
_ipv6_mtu: ipv6_mtu,
})
}
#[cfg(windows)]
fn print_tap_mtu_override(family: IpInterfaceFamily, mtu: u32, guard: &ScopedInterfaceMtu) {
let previous = guard.previous();
println!(
"TAP {family:?} MTU set to {mtu}; previous MTU {}",
previous.mtu()
);
}
#[cfg(windows)]
fn print_tap_metric_override(family: IpInterfaceFamily, metric: &ScopedInterfaceMetric) {
let previous = metric.previous();
println!(
"TAP {family:?} interface metric set to {TAP_INTERFACE_METRIC}; previous metric {} automatic {} default-routes-disabled {}",
previous.metric(),
previous.automatic_metric(),
previous.disable_default_routes()
);
}
#[cfg(windows)]
fn print_tap_default_routes_override(family: IpInterfaceFamily, routes: &ScopedDefaultRoutes) {
let previous = routes.previous();
println!(
"TAP {family:?} default routes disabled; previous default-routes-disabled {}",
previous.disable_default_routes()
);
}
#[cfg(windows)]
async fn run_tap_frame_pump(relay_io: ClientRelayIo, tap: TapAdapter) -> Result<()> {
let tap = Arc::new(tap);
let (tap_error_tx, tap_error_rx) = mpsc::channel();
spawn_tap_reader(Arc::clone(&tap), relay_io.clone(), tap_error_tx)?;
let tap_reader_error = tokio::task::spawn_blocking(move || tap_error_rx.recv());
tokio::pin!(tap_reader_error);
loop {
tokio::select! {
reader_result = &mut tap_reader_error => {
let reader_result = reader_result.context("TAP reader error watcher panicked")?;
let error = reader_result
.context("TAP reader thread stopped without reporting an error")?;
return Err::<(), _>(error).context("TAP reader thread stopped");
}
relay_frame = relay_io.recv_ethernet() => {
let relay_frame = relay_frame.context("failed to receive relay Ethernet frame")?;
let tap = Arc::clone(&tap);
let payload = relay_frame.payload().to_vec();
tokio::task::spawn_blocking(move || {
tap.write_ethernet_frame(&payload)
.context("failed to write relay Ethernet frame to TAP")
})
.await
.context("TAP writer task panicked")??;
}
}
}
}
#[cfg(windows)]
fn spawn_tap_reader(
tap: Arc<TapAdapter>,
relay_io: ClientRelayIo,
tap_error_tx: mpsc::Sender<anyhow::Error>,
) -> Result<()> {
thread::Builder::new()
.name("lanparty-tap-reader".to_string())
.spawn(move || {
let mut buffer = vec![0_u8; lanparty_client_tap::TAP_FRAME_BUFFER_LEN];
loop {
let result = read_and_relay_tap_frame(&tap, &relay_io, &mut buffer);
if let Err(error) = result {
let _ = tap_error_tx.send(error);
return;
}
}
})
.context("failed to spawn TAP reader thread")?;
Ok(())
}
#[cfg(windows)]
fn read_and_relay_tap_frame(
tap: &TapAdapter,
relay_io: &ClientRelayIo,
buffer: &mut [u8],
) -> Result<()> {
let len = tap
.read_ethernet_frame(buffer)
.context("failed to read TAP Ethernet frame")?;
relay_io
.send_ethernet(&buffer[..len])
.context("failed to send TAP Ethernet frame to relay")?;
Ok(())
}
#[cfg(not(windows))]
fn open_tap_adapter(_session: &ClientSession) {
println!("Windows TAP adapter opening is compiled only on Windows");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_matching_tap_driver_mac() {
assert!(validate_tap_driver_mac(mac(1), mac(1)).is_ok());
}
#[test]
fn rejects_tap_driver_mac_mismatch() {
let error = validate_tap_driver_mac(mac(1), mac(2)).unwrap_err();
assert!(error.to_string().contains("does not match tunnel identity"));
}
const fn mac(last_octet: u8) -> MacAddr {
MacAddr::new([0x02, 0, 0, 0, 0, last_octet])
}
}