ca57b90228
The relay and gateway already emit structured frame logs, but the Windows client only exposed aggregate counters. During the MVP end-to-end test that left a blind spot between TAP reads/writes and the relay datagram path. Add client-side frame log lines for accepted TAP-to-relay sends, relay-to-TAP writes, and local TAP-frame drops before relay send. The logs use the shared FrameLog vocabulary with TapToRelay and RelayToTap directions so the client, relay, and gateway logs can be correlated during DHCP, ARP, ping, and LAN-game discovery checks. Test Plan: - cargo test -p lanparty-client-win formats_client_frame_log_lines - cargo test -p lanparty-client-win - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check - git diff --cached --check Refs: MVP Windows client diagnostics
1550 lines
49 KiB
Rust
1550 lines
49 KiB
Rust
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<RelayEndpoint>,
|
|
|
|
/// 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<PathBuf>,
|
|
|
|
/// Room code to join as a remote client.
|
|
#[arg(long, required_unless_present = "list_tap_adapters")]
|
|
room: Option<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>,
|
|
|
|
/// 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 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,
|
|
tap_instance_id: Option<String>,
|
|
}
|
|
|
|
impl ClientArgs {
|
|
fn into_startup_config(self) -> Result<ClientStartupConfig> {
|
|
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<ClientRuntimeConfig> {
|
|
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<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(),
|
|
if route.created_by_client() {
|
|
"created"
|
|
} else {
|
|
"already existed"
|
|
}
|
|
);
|
|
}
|
|
|
|
#[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: Arc<TapAdapter>,
|
|
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<ScopedInterfaceMetric>,
|
|
_ipv6_default_routes: Option<ScopedDefaultRoutes>,
|
|
_ipv6_mtu: Option<ScopedInterfaceMtu>,
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
struct TapMediaStatusGuard {
|
|
tap: Arc<TapAdapter>,
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
impl TapMediaStatusGuard {
|
|
fn connected(tap: Arc<TapAdapter>) -> Result<Self> {
|
|
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<OpenedTapAdapter> {
|
|
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<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 {}",
|
|
info.instance_id()
|
|
)
|
|
})?;
|
|
|
|
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 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(
|
|
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<IpAddr>) -> 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<u32>,
|
|
frame_bytes: &[u8],
|
|
action: FrameAction,
|
|
drop_reason: Option<DropReason>,
|
|
) -> 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<T: std::fmt::Display>(value: Option<T>) -> String {
|
|
value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
|
|
}
|
|
|
|
fn optional_millis_label(value: Option<u64>) -> 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<AtomicBool>,
|
|
}
|
|
|
|
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<u32, PeerInfo>,
|
|
}
|
|
|
|
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<IpAddr> {
|
|
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<IpAddr> {
|
|
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<Item = IpAddr>) -> Option<IpAddr> {
|
|
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<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();
|
|
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<TapAdapter>) -> 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<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_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::<IpAddr>().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<u8> {
|
|
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()))
|
|
}
|
|
}
|