0fb4689cb2
The manual lifecycle check depends on the Windows client diagnostics moving from connected, to disconnected, and back to connected when the LAN gateway is restarted. Existing formatter tests covered a gateway leave, but not the replacement join updating the tracked gateway peer id afterward. Add a client lifecycle unit test that formats the first gateway join, its leave, and a replacement gateway join. The assertions prove the client-side relay status clears the old peer id and then reports the new gateway as connected. Test Plan: - cargo fmt --check - cargo test -p lanparty-client-win \ tracks_gateway_replacement_after_disconnect -- --nocapture - cargo test -p lanparty-client-win - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings - git diff --check Refs: MVP lifecycle sanity check
1749 lines
57 KiB
Rust
1749 lines
57 KiB
Rust
#[cfg(any(windows, test))]
|
|
use std::collections::BTreeMap;
|
|
#[cfg(any(windows, test))]
|
|
use std::sync::{
|
|
Arc,
|
|
atomic::{AtomicBool, AtomicU32, Ordering},
|
|
};
|
|
use std::{fs, net::IpAddr, path::PathBuf};
|
|
#[cfg(windows)]
|
|
use std::{sync::mpsc, thread, time::Duration};
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use clap::Parser;
|
|
use lanparty_client_core::{
|
|
ClientIdentity, ClientIdentityStore, ClientSession, ClientSessionConfig, connect_client,
|
|
};
|
|
#[cfg(windows)]
|
|
use lanparty_client_core::{ClientReceiveOutcome, ClientRelayIo};
|
|
#[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::RoomCode;
|
|
#[cfg(any(windows, test))]
|
|
use lanparty_ctrl::{ControlMessage, PeerInfo, Role};
|
|
use lanparty_net::RelayEndpoint;
|
|
#[cfg(any(windows, test))]
|
|
use lanparty_obs::{ClientDiagnostics, RelayDiagnostics, UserDiagnostic, UserDiagnosticLevel};
|
|
use lanparty_obs::{DropReason, FrameAction, FrameDirection, FrameLog, TapDiagnostics};
|
|
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);
|
|
const MAX_CLIENT_SHUTDOWN_MESSAGE_BYTES: usize = 1024;
|
|
const CLIENT_SHUTDOWN_TRUNCATION_SUFFIX: &str = "...";
|
|
|
|
#[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(());
|
|
}
|
|
|
|
ensure_supported_platform()?;
|
|
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()?;
|
|
#[cfg(windows)]
|
|
let relay_route_pin = pin_relay_route_before_tap(config.session.relay_addr().ip())?;
|
|
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(),
|
|
gateway_status_label(
|
|
session.welcome().gateway_connected(),
|
|
session.welcome().gateway_peer_id()
|
|
)
|
|
);
|
|
#[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;
|
|
let shutdown_message = client_shutdown_message(&run_result);
|
|
session.shutdown(&shutdown_message).await;
|
|
#[cfg(windows)]
|
|
drop(relay_route_pin);
|
|
|
|
run_result
|
|
}
|
|
|
|
fn client_shutdown_message(run_result: &Result<()>) -> String {
|
|
let message = match run_result {
|
|
Ok(()) => "client shutting down".to_owned(),
|
|
Err(error) => format!("client shutting down after error: {error:#}"),
|
|
};
|
|
|
|
truncate_client_shutdown_message(&message)
|
|
}
|
|
|
|
fn truncate_client_shutdown_message(message: &str) -> String {
|
|
if message.len() <= MAX_CLIENT_SHUTDOWN_MESSAGE_BYTES {
|
|
return message.to_owned();
|
|
}
|
|
|
|
let suffix_len = CLIENT_SHUTDOWN_TRUNCATION_SUFFIX.len();
|
|
let mut end = MAX_CLIENT_SHUTDOWN_MESSAGE_BYTES - suffix_len;
|
|
while !message.is_char_boundary(end) {
|
|
end -= 1;
|
|
}
|
|
|
|
let mut truncated = message[..end].to_owned();
|
|
truncated.push_str(CLIENT_SHUTDOWN_TRUNCATION_SUFFIX);
|
|
truncated
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn ensure_supported_platform() -> Result<()> {
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(not(windows))]
|
|
fn ensure_supported_platform() -> Result<()> {
|
|
bail!(
|
|
"lanparty-client-win can only run on Windows; TAP-Windows6 and relay route protection are Windows-only"
|
|
)
|
|
}
|
|
|
|
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("after TAP activation", &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<()> {
|
|
unreachable!("ensure_supported_platform rejects non-Windows before tunnel setup")
|
|
}
|
|
|
|
#[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);
|
|
let route = verify_relay_route_is_pinned(destination, &pin)
|
|
.context("relay route did not use pinned host route before TAP activation")?;
|
|
print_verified_relay_route("before TAP activation", &route);
|
|
|
|
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.matches_pinned_host_route(
|
|
pin.destination(),
|
|
pin.next_hop(),
|
|
pin.interface_index(),
|
|
pin.interface_luid(),
|
|
)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn print_verified_relay_route(phase: &str, route: &RouteSnapshot) {
|
|
println!(
|
|
"relay route verified {phase}: 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
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
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)
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn print_client_diagnostics(diagnostics: &ClientDiagnostics) {
|
|
println!("{}", format_client_diagnostics(diagnostics));
|
|
for diagnostic in diagnostics.user_diagnostics() {
|
|
println!("{}", format_user_diagnostic(&diagnostic));
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
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:#}");
|
|
}
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
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,
|
|
)
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
const fn yes_no(value: bool) -> &'static str {
|
|
if value { "yes" } else { "no" }
|
|
}
|
|
|
|
fn gateway_status_label(gateway_connected: bool, gateway_peer_id: Option<u32>) -> String {
|
|
match (gateway_connected, gateway_peer_id) {
|
|
(true, Some(peer_id)) => format!("yes (peer {peer_id})"),
|
|
(true, None) => "yes".to_owned(),
|
|
(false, _) => "no".to_owned(),
|
|
}
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
fn optional_label<T: std::fmt::Display>(value: Option<T>) -> String {
|
|
value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
fn optional_millis_label(value: Option<u64>) -> String {
|
|
value.map_or_else(|| "unknown".to_string(), |value| format!("{value} ms"))
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
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()),
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
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)
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
#[derive(Debug, Clone)]
|
|
struct ClientRelayStatus {
|
|
gateway_connected: Arc<AtomicBool>,
|
|
gateway_peer_id: Arc<AtomicU32>,
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
impl ClientRelayStatus {
|
|
const UNKNOWN_GATEWAY_PEER_ID: u32 = 0;
|
|
|
|
fn new(gateway_connected: bool) -> Self {
|
|
Self {
|
|
gateway_connected: Arc::new(AtomicBool::new(gateway_connected)),
|
|
gateway_peer_id: Arc::new(AtomicU32::new(Self::UNKNOWN_GATEWAY_PEER_ID)),
|
|
}
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
fn from_welcome(session: &ClientSession) -> Self {
|
|
let status = Self::new(session.welcome().gateway_connected());
|
|
status.set_gateway_peer_id(session.welcome().gateway_peer_id());
|
|
status
|
|
}
|
|
|
|
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);
|
|
if !gateway_connected {
|
|
self.set_gateway_peer_id(None);
|
|
}
|
|
}
|
|
|
|
fn gateway_peer_id(&self) -> Option<u32> {
|
|
match self.gateway_peer_id.load(Ordering::Relaxed) {
|
|
Self::UNKNOWN_GATEWAY_PEER_ID => None,
|
|
peer_id => Some(peer_id),
|
|
}
|
|
}
|
|
|
|
fn set_gateway_peer_id(&self, gateway_peer_id: Option<u32>) {
|
|
self.gateway_peer_id.store(
|
|
gateway_peer_id.unwrap_or(Self::UNKNOWN_GATEWAY_PEER_ID),
|
|
Ordering::Relaxed,
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
#[derive(Debug, Default)]
|
|
struct ControlEventFormatter {
|
|
peers: BTreeMap<u32, PeerInfo>,
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
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_peer_id(Some(peer.peer_id()));
|
|
relay_status.set_gateway_connected(true);
|
|
}
|
|
format_peer_joined(peer)
|
|
}
|
|
ControlMessage::PeerLeft { peer_id, reason } => {
|
|
let Some(peer) = self.peers.remove(peer_id) else {
|
|
if relay_status.gateway_peer_id() == Some(*peer_id) {
|
|
relay_status.set_gateway_connected(false);
|
|
return format!(
|
|
"relay event: LAN gateway disconnected (peer {peer_id}, {reason:?})"
|
|
);
|
|
}
|
|
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:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(any(windows, test))]
|
|
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_outcome() => {
|
|
match relay_frame.context("failed to receive relay Ethernet frame")? {
|
|
ClientReceiveOutcome::Accepted(relay_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,
|
|
)
|
|
);
|
|
}
|
|
ClientReceiveOutcome::Filtered(relay_frame) => {
|
|
eprintln!(
|
|
"{}",
|
|
client_frame_log_line(
|
|
FrameDirection::RelayToTap,
|
|
Some(relay_frame.source_peer_id()),
|
|
relay_frame.payload(),
|
|
FrameAction::Filtered,
|
|
Some(relay_frame.drop_reason()),
|
|
)
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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(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};
|
|
|
|
#[cfg(not(windows))]
|
|
#[test]
|
|
fn rejects_runtime_on_non_windows() {
|
|
let error = ensure_supported_platform().unwrap_err();
|
|
|
|
assert!(error.to_string().contains("can only run on Windows"));
|
|
}
|
|
|
|
#[cfg(windows)]
|
|
#[test]
|
|
fn accepts_runtime_on_windows() {
|
|
assert!(ensure_supported_platform().is_ok());
|
|
}
|
|
|
|
#[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 reports_runtime_errors_in_shutdown_message() {
|
|
assert_eq!(
|
|
client_shutdown_message(&Ok(())),
|
|
"client shutting down".to_owned()
|
|
);
|
|
|
|
let error = anyhow::anyhow!("route pin changed").context("bridge stopped");
|
|
assert_eq!(
|
|
client_shutdown_message(&Err(error)),
|
|
"client shutting down after error: bridge stopped: route pin changed".to_owned()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn bounds_runtime_errors_in_shutdown_message() {
|
|
let detail = "x".repeat(MAX_CLIENT_SHUTDOWN_MESSAGE_BYTES * 2);
|
|
let error = anyhow::anyhow!("{detail}").context("bridge stopped");
|
|
|
|
let message = client_shutdown_message(&Err(error));
|
|
|
|
assert_eq!(message.len(), MAX_CLIENT_SHUTDOWN_MESSAGE_BYTES);
|
|
assert!(message.starts_with("client shutting down after error: bridge stopped: "));
|
|
assert!(message.ends_with(CLIENT_SHUTDOWN_TRUNCATION_SUFFIX));
|
|
}
|
|
|
|
#[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_gateway_status_with_welcome_peer_id() {
|
|
assert_eq!(gateway_status_label(true, Some(7)), "yes (peer 7)");
|
|
assert_eq!(gateway_status_label(true, None), "yes");
|
|
assert_eq!(gateway_status_label(false, Some(7)), "no");
|
|
}
|
|
|
|
#[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"
|
|
);
|
|
assert_eq!(
|
|
client_frame_log_line(
|
|
FrameDirection::RelayToTap,
|
|
Some(1),
|
|
&frame,
|
|
FrameAction::Filtered,
|
|
Some(DropReason::UnknownDestination),
|
|
),
|
|
"client frame direction=RelayToTap peer_id=1 src=02:00:00:00:00:01 dst=02:00:00:00:00:02 ethertype_or_len=0x0800 len=21 action=Filtered drop_reason=UnknownDestination"
|
|
);
|
|
}
|
|
|
|
#[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!(relay_status.gateway_peer_id(), Some(1));
|
|
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());
|
|
assert_eq!(relay_status.gateway_peer_id(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn clears_gateway_status_when_welcome_gateway_leaves_before_join_event() {
|
|
let gateway_left = ControlMessage::PeerLeft {
|
|
peer_id: 7,
|
|
reason: DisconnectReason::Normal,
|
|
};
|
|
let mut formatter = ControlEventFormatter::default();
|
|
let relay_status = ClientRelayStatus::new(true);
|
|
relay_status.set_gateway_peer_id(Some(7));
|
|
|
|
assert_eq!(
|
|
formatter.format(&gateway_left, &relay_status),
|
|
"relay event: LAN gateway disconnected (peer 7, Normal)"
|
|
);
|
|
assert!(!relay_status.gateway_connected());
|
|
assert_eq!(relay_status.gateway_peer_id(), None);
|
|
}
|
|
|
|
#[test]
|
|
fn tracks_gateway_replacement_after_disconnect() {
|
|
let first_gateway =
|
|
ControlMessage::PeerJoined(PeerInfo::new(1, Role::Gateway, None).unwrap());
|
|
let first_gateway_left = ControlMessage::PeerLeft {
|
|
peer_id: 1,
|
|
reason: DisconnectReason::Normal,
|
|
};
|
|
let replacement_gateway =
|
|
ControlMessage::PeerJoined(PeerInfo::new(3, Role::Gateway, None).unwrap());
|
|
let mut formatter = ControlEventFormatter::default();
|
|
let relay_status = ClientRelayStatus::new(false);
|
|
|
|
assert_eq!(
|
|
formatter.format(&first_gateway, &relay_status),
|
|
"relay event: LAN gateway connected as peer 1"
|
|
);
|
|
assert!(relay_status.gateway_connected());
|
|
assert_eq!(relay_status.gateway_peer_id(), Some(1));
|
|
|
|
assert_eq!(
|
|
formatter.format(&first_gateway_left, &relay_status),
|
|
"relay event: LAN gateway disconnected (peer 1, Normal)"
|
|
);
|
|
assert!(!relay_status.gateway_connected());
|
|
assert_eq!(relay_status.gateway_peer_id(), None);
|
|
|
|
assert_eq!(
|
|
formatter.format(&replacement_gateway, &relay_status),
|
|
"relay event: LAN gateway connected as peer 3"
|
|
);
|
|
assert!(relay_status.gateway_connected());
|
|
assert_eq!(relay_status.gateway_peer_id(), Some(3));
|
|
}
|
|
|
|
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()))
|
|
}
|
|
}
|