use std::sync::{ Arc, atomic::{AtomicBool, Ordering}, }; use std::{collections::BTreeMap, fs, net::IpAddr, path::PathBuf}; #[cfg(windows)] use std::{sync::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_client_tap::TapAdapterInfo; use lanparty_ctrl::{ControlMessage, PeerInfo, Role, RoomCode}; use lanparty_net::RelayEndpoint; use lanparty_obs::{ ClientDiagnostics, DropReason, FrameAction, FrameDirection, FrameLog, RelayDiagnostics, TapDiagnostics, UserDiagnostic, UserDiagnosticLevel, }; use lanparty_proto::{EthernetFrame, MacAddr}; #[cfg(windows)] const TAP_INTERFACE_METRIC: u32 = 9_000; #[cfg(windows)] const RELAY_ROUTE_VERIFY_INTERVAL: Duration = Duration::from_secs(5); #[cfg(windows)] const CLIENT_DIAGNOSTICS_INTERVAL: Duration = Duration::from_secs(10); #[derive(Debug, Parser)] #[command( name = "lanparty-client-win", about = "Windows TAP client for the LAN party L2 tunnel" )] 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. #[arg( long, value_name = "HOST[:PORT]", required_unless_present = "list_tap_adapters" )] relay: Option, /// 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", required_unless_present = "list_tap_adapters" )] relay_ca_cert: Option, /// Room code to join as a remote client. #[arg(long, required_unless_present = "list_tap_adapters")] room: Option, /// 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, /// 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 ClientStartupConfig { relay: RelayEndpoint, server_name: String, relay_ca_cert: Vec, room: RoomCode, identity: ClientIdentity, tap_instance_id: Option, max_datagram_size: u16, } #[derive(Debug)] struct ClientRuntimeConfig { session: ClientSessionConfig, tap_instance_id: Option, } impl ClientArgs { fn into_startup_config(self) -> Result { 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!( "failed to read relay CA certificate {}", relay_ca_cert_path.display() ) })?; let identity = match self.virtual_mac { Some(virtual_mac) => ClientIdentity::new(virtual_mac)?, None => ClientIdentityStore::new(self.identity_file)?.load_or_create()?, }; 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 { let relay_addr = self .relay .resolve() .context("failed to resolve relay endpoint")?; let session = ClientSessionConfig::new( relay_addr, self.server_name, self.relay_ca_cert, self.room, self.identity.virtual_mac(), self.max_datagram_size, )?; Ok(ClientRuntimeConfig { session, tap_instance_id: self.tap_instance_id, }) } } #[tokio::main] async fn main() -> Result<()> { let args = ClientArgs::parse(); if args.list_tap_adapters { print_available_tap_adapters()?; return Ok(()); } let startup_config = args.into_startup_config()?; #[cfg(windows)] prepare_tap_before_relay_connect( 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(), config.session.relay_addr(), config.session.room() ); 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(), session.welcome().room_id(), session.welcome().effective_tap_mtu(), session.welcome().mode(), yes_no(session.welcome().gateway_connected()) ); #[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, 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; #[cfg(windows)] drop(relay_route_pin); 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)] fn prepare_tap_before_relay_connect( tap_instance_id: Option<&str>, virtual_mac: MacAddr, ) -> Result<()> { let adapters = lanparty_client_tap::available_adapters() .context("failed to list TAP-Windows6 adapters before relay connect")?; 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 {}", info.instance_id() ) })?; let device_path = info.device_path(); let tap = TapAdapter::open(info)?; tap.set_media_connected(false) .with_context(|| format!("failed to mark TAP media disconnected for {device_path}"))?; println!( "prepared TAP adapter {device_path}: MAC {virtual_mac} configured and media disconnected before relay connect" ); Ok(()) } #[cfg(windows)] 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, _media_guard, _route_guard, } = 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")?; print_verified_relay_route(&relay_route); print_and_report_client_diagnostics( session, &client_diagnostics_snapshot( session, true, &relay_status, current_tap_diagnostics(&tap_diagnostics, tap_interface), ), ) .await; 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 control_events = run_control_event_log(session, relay_status.clone()); tokio::pin!(control_events); 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); let mut diagnostics_check = tokio::time::interval_at( tokio::time::Instant::now() + CLIENT_DIAGNOSTICS_INTERVAL, CLIENT_DIAGNOSTICS_INTERVAL, ); diagnostics_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); loop { tokio::select! { result = &mut frame_pump => return result, result = &mut control_events => 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")?; } _ = diagnostics_check.tick() => { print_and_report_client_diagnostics( session, &client_diagnostics_snapshot( session, true, &relay_status, current_tap_diagnostics(&tap_diagnostics, tap_interface), ), ).await; } } } } #[cfg(not(windows))] async fn run_client(session: &ClientSession) -> Result<()> { let relay_status = ClientRelayStatus::from_welcome(session); open_tap_adapter(session); print_and_report_client_diagnostics( session, &client_diagnostics_snapshot( session, false, &relay_status, TapDiagnostics::new(false, None, None, None), ), ) .await; println!("TAP frame pump and route pinning are not wired yet; press Ctrl-C to stop"); let control_events = run_control_event_log(session, relay_status); tokio::pin!(control_events); let shutdown = tokio::signal::ctrl_c(); tokio::pin!(shutdown); tokio::select! { result = &mut control_events => result, result = &mut shutdown => result.context("failed to wait for Ctrl-C"), } } #[cfg(windows)] fn pin_relay_route_before_tap(destination: std::net::IpAddr) -> Result { 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(), if route.created_by_client() { "created" } else { "already existed" } ); } #[cfg(windows)] fn verify_relay_route_is_pinned( destination: std::net::IpAddr, pin: &PinnedRelayRoute, ) -> Result { 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) -> String { next_hop.map_or_else(|| "on-link".to_string(), |next_hop| next_hop.to_string()) } #[cfg(windows)] struct OpenedTapAdapter { tap: Arc, tap_diagnostics: TapDiagnostics, tap_interface: NetworkInterfaceIdentity, _media_guard: TapMediaStatusGuard, _route_guard: TapRouteProtectionGuard, } #[cfg(windows)] struct TapRouteProtectionGuard { _ipv4_metric: ScopedInterfaceMetric, _ipv4_default_routes: ScopedDefaultRoutes, _ipv4_mtu: ScopedInterfaceMtu, _ipv6_metric: Option, _ipv6_default_routes: Option, _ipv6_mtu: Option, } #[cfg(windows)] struct TapMediaStatusGuard { tap: Arc, } #[cfg(windows)] impl TapMediaStatusGuard { fn connected(tap: Arc) -> Result { tap.set_media_connected(true)?; Ok(Self { tap }) } } #[cfg(windows)] impl Drop for TapMediaStatusGuard { fn drop(&mut self) { if let Err(error) = self.tap.set_media_connected(false) { eprintln!("failed to mark TAP media disconnected during cleanup: {error:#}"); } } } #[cfg(windows)] fn open_tap_adapter( session: &ClientSession, tap_instance_id: Option<&str>, ) -> Result { let tap = Arc::new(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")?; 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)?; let media_guard = TapMediaStatusGuard::connected(Arc::clone(&tap))?; 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() ); let tap_diagnostics = TapDiagnostics::new( true, Some(driver_mac), Some(session.welcome().effective_tap_mtu()), None, ); Ok(OpenedTapAdapter { tap, tap_diagnostics, tap_interface, _media_guard: media_guard, _route_guard: route_guard, }) } #[cfg(windows)] 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 {}", info.instance_id() ) })?; 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 format_tap_adapter_list(adapters: &[TapAdapterInfo]) -> Vec { 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( session: &ClientSession, route_pinned: bool, relay_status: &ClientRelayStatus, tap: TapDiagnostics, ) -> ClientDiagnostics { ClientDiagnostics::new( RelayDiagnostics::new(true, route_pinned, relay_status.gateway_connected()), session.quic_diagnostics(), tap, session.stats_snapshot(), ) } #[cfg(windows)] fn current_tap_diagnostics( base: &TapDiagnostics, identity: NetworkInterfaceIdentity, ) -> TapDiagnostics { tap_diagnostics_with_ip(base, tap_unicast_ip(identity)) } #[cfg_attr(not(windows), allow(dead_code))] fn tap_diagnostics_with_ip(base: &TapDiagnostics, ip: Option) -> TapDiagnostics { TapDiagnostics::new(base.adapter_found(), base.mac(), base.mtu(), ip) } fn print_client_diagnostics(diagnostics: &ClientDiagnostics) { println!("{}", format_client_diagnostics(diagnostics)); for diagnostic in diagnostics.user_diagnostics() { println!("{}", format_user_diagnostic(&diagnostic)); } } async fn print_and_report_client_diagnostics( session: &ClientSession, diagnostics: &ClientDiagnostics, ) { print_client_diagnostics(diagnostics); if let Err(error) = session.send_stats_snapshot().await { eprintln!("failed to send client stats to relay: {error:#}"); } } fn format_client_diagnostics(diagnostics: &ClientDiagnostics) -> String { let stats = diagnostics.stats(); format!( "client diagnostics: relay reachable {} gateway connected {} route pinned {}; QUIC datagrams {} max {} relay RTT {}; TAP found {} MAC {} MTU {} IP {}; frames tx {} rx {} broadcast tx {} rx {} datagrams tx {} rx {} drops {} malformed {}", yes_no(diagnostics.relay().reachable()), yes_no(diagnostics.relay().gateway_connected()), yes_no(diagnostics.relay().route_pinned()), yes_no(diagnostics.quic().datagram_supported()), optional_label(diagnostics.quic().max_datagram_size()), optional_millis_label(diagnostics.quic().relay_rtt_ms()), yes_no(diagnostics.tap().adapter_found()), optional_label(diagnostics.tap().mac()), optional_label(diagnostics.tap().mtu()), optional_label(diagnostics.tap().ip()), stats.ethernet_frames_tx(), stats.ethernet_frames_rx(), stats.broadcast_frames_tx(), stats.broadcast_frames_rx(), stats.datagrams_tx(), stats.datagrams_rx(), stats.dropped_frames(), stats.malformed_frames() ) } #[cfg_attr(not(windows), allow(dead_code))] fn client_frame_log_line( direction: FrameDirection, peer_id: Option, frame_bytes: &[u8], action: FrameAction, drop_reason: Option, ) -> String { let log = match EthernetFrame::parse(frame_bytes) { Ok(frame) => FrameLog::from_ethernet(direction, peer_id, action, drop_reason, frame), Err(_) => FrameLog::malformed(direction, peer_id, frame_bytes.len()), }; let source_mac = log .source_mac() .map(|mac| mac.to_string()) .unwrap_or_else(|| "-".to_owned()); let destination_mac = log .destination_mac() .map(|mac| mac.to_string()) .unwrap_or_else(|| "-".to_owned()); let ethertype_or_len = log .ethertype_or_len() .map(|value| format!("0x{value:04x}")) .unwrap_or_else(|| "-".to_owned()); let peer_id = log .peer_id() .map(|peer_id| peer_id.to_string()) .unwrap_or_else(|| "-".to_owned()); let drop_reason = log .drop_reason() .map(|reason| format!("{reason:?}")) .unwrap_or_else(|| "-".to_owned()); format!( "client frame direction={:?} peer_id={} src={} dst={} ethertype_or_len={} len={} action={:?} drop_reason={}", log.direction(), peer_id, source_mac, destination_mac, ethertype_or_len, log.frame_len(), log.action(), drop_reason, ) } const fn yes_no(value: bool) -> &'static str { if value { "yes" } else { "no" } } fn optional_label(value: Option) -> String { value.map_or_else(|| "unknown".to_string(), |value| value.to_string()) } fn optional_millis_label(value: Option) -> String { value.map_or_else(|| "unknown".to_string(), |value| format!("{value} ms")) } fn format_user_diagnostic(diagnostic: &UserDiagnostic) -> String { match diagnostic.level() { UserDiagnosticLevel::Info => diagnostic.message().to_owned(), UserDiagnosticLevel::Warning => format!("Warning: {}", diagnostic.message()), UserDiagnosticLevel::Error => format!("Error: {}", diagnostic.message()), } } async fn run_control_event_log( session: &ClientSession, relay_status: ClientRelayStatus, ) -> Result<()> { let mut formatter = ControlEventFormatter::default(); loop { let event = session .recv_control_event() .await .context("failed to receive relay control event")?; println!("{}", formatter.format(&event, &relay_status)); } } #[cfg(test)] fn format_control_event(event: &ControlMessage) -> String { let relay_status = ClientRelayStatus::new(false); ControlEventFormatter::default().format(event, &relay_status) } #[derive(Debug, Clone)] struct ClientRelayStatus { gateway_connected: Arc, } impl ClientRelayStatus { fn new(gateway_connected: bool) -> Self { Self { gateway_connected: Arc::new(AtomicBool::new(gateway_connected)), } } fn from_welcome(session: &ClientSession) -> Self { Self::new(session.welcome().gateway_connected()) } fn gateway_connected(&self) -> bool { self.gateway_connected.load(Ordering::Relaxed) } fn set_gateway_connected(&self, gateway_connected: bool) { self.gateway_connected .store(gateway_connected, Ordering::Relaxed); } } #[derive(Debug, Default)] struct ControlEventFormatter { peers: BTreeMap, } impl ControlEventFormatter { fn format(&mut self, event: &ControlMessage, relay_status: &ClientRelayStatus) -> String { match event { ControlMessage::PeerJoined(peer) => { self.peers.insert(peer.peer_id(), peer.clone()); if peer.role() == Role::Gateway { relay_status.set_gateway_connected(true); } format_peer_joined(peer) } ControlMessage::PeerLeft { peer_id, reason } => { let Some(peer) = self.peers.remove(peer_id) else { return format!("relay event: peer {peer_id} left ({reason:?})"); }; match peer.role() { Role::Gateway => { relay_status.set_gateway_connected(false); format!( "relay event: LAN gateway disconnected (peer {}, {reason:?})", peer.peer_id() ) } Role::Client => { let mac = peer .mac() .map(|mac| mac.to_string()) .unwrap_or_else(|| "unknown".to_string()); format!( "relay event: client peer {} with MAC {} left ({reason:?})", peer.peer_id(), mac ) } } } _ => format!("relay event: {event:?}"), } } } fn format_peer_joined(peer: &PeerInfo) -> String { match peer.role() { Role::Gateway => { format!( "relay event: LAN gateway connected as peer {}", peer.peer_id() ) } Role::Client => { let mac = peer .mac() .map(|mac| mac.to_string()) .unwrap_or_else(|| "unknown".to_string()); format!( "relay event: client peer {} joined with MAC {}", peer.peer_id(), mac ) } } } #[cfg(windows)] fn tap_unicast_ip(identity: NetworkInterfaceIdentity) -> Option { match lanparty_client_route::interface_unicast_addresses(identity) { Ok(addresses) => preferred_tap_ip(&addresses), Err(error) => { eprintln!( "failed to inspect TAP IP address; diagnostics will report unknown: {error:#}" ); None } } } #[cfg(windows)] fn preferred_tap_ip( addresses: &[lanparty_client_route::InterfaceUnicastAddress], ) -> Option { preferred_tap_ip_address(addresses.iter().map(|address| address.address())) } #[cfg_attr(not(windows), allow(dead_code))] fn preferred_tap_ip_address(addresses: impl IntoIterator) -> Option { let addresses: Vec<_> = addresses.into_iter().collect(); addresses .iter() .copied() .find(|address| matches!(address, IpAddr::V4(ip) if !ip.is_link_local())) .or_else(|| { addresses .iter() .copied() .find(|address| matches!(address, IpAddr::V4(_))) }) .or_else(|| addresses.first().copied()) } #[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}; the NetworkAddress registry value was written before opening the adapter, but Windows may need the TAP adapter disabled/enabled or reinstalled before the driver reloads it" ); } Ok(()) } #[cfg(windows)] fn protect_tap_routes( identity: NetworkInterfaceIdentity, tap_mtu: u16, ) -> Result { 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(); let family = format!("{family:?}"); println!( "{}", tap_default_routes_override_message(&family, previous.disable_default_routes()) ); } #[cfg(any(windows, test))] fn tap_default_routes_override_message(family: &str, previous_disabled: bool) -> String { if previous_disabled { format!("TAP {family} default routes already disabled") } else { format!("Warning: TAP {family} default routes were enabled; disabled while tunnel runs") } } #[cfg(windows)] async fn run_tap_frame_pump(relay_io: ClientRelayIo, tap: Arc) -> Result<()> { 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 source_peer_id = relay_frame.source_peer_id(); let tap = Arc::clone(&tap); let payload = relay_frame.payload().to_vec(); let log_payload = payload.clone(); 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")??; println!( "{}", client_frame_log_line( FrameDirection::RelayToTap, Some(source_peer_id), &log_payload, FrameAction::Forwarded, None, ) ); } } } } #[cfg(windows)] fn spawn_tap_reader( tap: Arc, relay_io: ClientRelayIo, tap_error_tx: mpsc::Sender, ) -> 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_frame(buffer).context("failed to read TAP frame")?; match relay_io .send_ethernet_with_outcome(&buffer[..len]) .context("failed to send TAP Ethernet frame to relay")? { lanparty_client_core::ClientSendOutcome::Sent => { println!( "{}", client_frame_log_line( FrameDirection::TapToRelay, Some(relay_io.welcome().peer_id()), &buffer[..len], FrameAction::Forwarded, None, ) ); } lanparty_client_core::ClientSendOutcome::Dropped(reason) => { eprintln!( "{}", client_frame_log_line( FrameDirection::TapToRelay, Some(relay_io.welcome().peer_id()), &buffer[..len], FrameAction::Dropped, Some(reason), ) ); } } 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 std::{ fs, time::{SystemTime, UNIX_EPOCH}, }; use super::*; use lanparty_ctrl::{DisconnectReason, PeerInfo}; use lanparty_net::DEFAULT_RELAY_PORT; use lanparty_obs::{QuicDiagnostics, TunnelStats}; #[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")); } #[test] fn formats_client_diagnostics_status_line() { let diagnostics = ClientDiagnostics::new( RelayDiagnostics::new(true, true, true), QuicDiagnostics::new(true, Some(1400)).with_relay_rtt_ms(Some(23)), TapDiagnostics::new( true, Some(mac(1)), Some(1200), Some("10.73.42.51".parse().unwrap()), ), TunnelStats::new(1, 2, 3, 4, 5, 6).with_broadcast_frames(7, 8), ); assert_eq!( format_client_diagnostics(&diagnostics), "client diagnostics: relay reachable yes gateway connected yes route pinned yes; QUIC datagrams yes max 1400 relay RTT 23 ms; TAP found yes MAC 02:00:00:00:00:01 MTU 1200 IP 10.73.42.51; frames tx 1 rx 2 broadcast tx 7 rx 8 datagrams tx 3 rx 4 drops 5 malformed 6" ); } #[test] fn formats_missing_client_diagnostics_as_unknown() { let diagnostics = ClientDiagnostics::new( RelayDiagnostics::new(true, false, false), QuicDiagnostics::new(false, None), TapDiagnostics::new(false, None, None, None), TunnelStats::default(), ); assert_eq!( format_client_diagnostics(&diagnostics), "client diagnostics: relay reachable yes gateway connected no route pinned no; QUIC datagrams no max unknown relay RTT unknown; TAP found no MAC unknown MTU unknown IP unknown; frames tx 0 rx 0 broadcast tx 0 rx 0 datagrams tx 0 rx 0 drops 0 malformed 0" ); } #[test] fn formats_client_frame_log_lines() { let frame = ethernet_frame(mac(2), mac(1)); assert_eq!( client_frame_log_line( FrameDirection::TapToRelay, Some(2), &frame, FrameAction::Forwarded, None, ), "client frame direction=TapToRelay peer_id=2 src=02:00:00:00:00:01 dst=02:00:00:00:00:02 ethertype_or_len=0x0800 len=21 action=Forwarded drop_reason=-" ); assert_eq!( client_frame_log_line( FrameDirection::TapToRelay, Some(2), &[0; 4], FrameAction::Dropped, Some(DropReason::Malformed), ), "client frame direction=TapToRelay peer_id=2 src=- dst=- ethertype_or_len=- len=4 action=Dropped drop_reason=Malformed" ); } #[test] fn formats_user_diagnostic_levels() { assert_eq!( format_user_diagnostic(&UserDiagnostic::new( UserDiagnosticLevel::Info, "Connected to relay" )), "Connected to relay" ); assert_eq!( format_user_diagnostic(&UserDiagnostic::new( UserDiagnosticLevel::Warning, "Waiting for LAN gateway" )), "Warning: Waiting for LAN gateway" ); assert_eq!( format_user_diagnostic(&UserDiagnostic::new( UserDiagnosticLevel::Error, "Relay not reachable" )), "Error: Relay not reachable" ); } #[test] fn refreshes_tap_diagnostics_ip_without_losing_static_fields() { let base = TapDiagnostics::new( true, Some(mac(1)), Some(1200), Some("169.254.10.20".parse().unwrap()), ); let refreshed = tap_diagnostics_with_ip(&base, Some("10.73.42.51".parse::().unwrap())); assert!(refreshed.adapter_found()); assert_eq!(refreshed.mac(), Some(mac(1))); assert_eq!(refreshed.mtu(), Some(1200)); assert_eq!(refreshed.ip().unwrap().to_string(), "10.73.42.51"); } #[test] fn prefers_non_link_local_tap_ipv4_for_diagnostics() { assert_eq!( preferred_tap_ip_address([ "169.254.10.20".parse().unwrap(), "10.73.42.51".parse().unwrap(), "fe80::1".parse().unwrap(), ]), Some("10.73.42.51".parse().unwrap()) ); assert_eq!( preferred_tap_ip_address([ "169.254.10.20".parse().unwrap(), "fe80::1".parse().unwrap() ]), Some("169.254.10.20".parse().unwrap()) ); assert_eq!( preferred_tap_ip_address(["fe80::1".parse().unwrap()]), Some("fe80::1".parse().unwrap()) ); assert_eq!(preferred_tap_ip_address([]), None); } #[test] fn formats_tap_default_route_warning() { assert_eq!( tap_default_routes_override_message("Ipv4", false), "Warning: TAP Ipv4 default routes were enabled; disabled while tunnel runs" ); } #[test] fn formats_already_disabled_tap_default_routes() { assert_eq!( tap_default_routes_override_message("Ipv6", true), "TAP Ipv6 default routes already disabled" ); } #[test] fn accepts_relay_domain_with_default_port() { let args = ClientArgs::parse_from([ "lanparty-client-win", "--relay", "relay.example.test", "--relay-ca-cert", "relay-cert.der", "--room", "ROOM1", ]); let relay = args.relay.unwrap(); assert_eq!(relay.host(), "relay.example.test"); 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"]); assert!(args.list_tap_adapters); assert!(args.relay.is_none()); assert!(args.relay_ca_cert.is_none()); assert!(args.room.is_none()); } #[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_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] fn formats_relay_lifecycle_events() { let gateway = ControlMessage::PeerJoined(PeerInfo::new(1, Role::Gateway, None).unwrap()); let client = ControlMessage::PeerJoined(PeerInfo::new(2, Role::Client, Some(mac(2))).unwrap()); let unknown_left = ControlMessage::PeerLeft { peer_id: 3, reason: DisconnectReason::Normal, }; let client_left = ControlMessage::PeerLeft { peer_id: 2, reason: DisconnectReason::Normal, }; let gateway_left = ControlMessage::PeerLeft { peer_id: 1, reason: DisconnectReason::Normal, }; let mut formatter = ControlEventFormatter::default(); let relay_status = ClientRelayStatus::new(false); assert_eq!( formatter.format(&gateway, &relay_status), "relay event: LAN gateway connected as peer 1" ); assert!(relay_status.gateway_connected()); assert_eq!( formatter.format(&client, &relay_status), "relay event: client peer 2 joined with MAC 02:00:00:00:00:02" ); assert_eq!( format_control_event(&unknown_left), "relay event: peer 3 left (Normal)" ); assert_eq!( formatter.format(&client_left, &relay_status), "relay event: client peer 2 with MAC 02:00:00:00:00:02 left (Normal)" ); assert_eq!( formatter.format(&gateway_left, &relay_status), "relay event: LAN gateway disconnected (peer 1, Normal)" ); assert!(!relay_status.gateway_connected()); } 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() } fn ethernet_frame(destination: MacAddr, source: MacAddr) -> Vec { let mut frame = Vec::new(); frame.extend_from_slice(&destination.octets()); frame.extend_from_slice(&source.octets()); frame.extend_from_slice(&0x0800_u16.to_be_bytes()); frame.extend_from_slice(b"payload"); frame } 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())) } }