diff --git a/Cargo.lock b/Cargo.lock index a1c454f..106ba77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -446,6 +446,15 @@ dependencies = [ "tokio", ] +[[package]] +name = "lanparty-client-tap" +version = "0.1.0" +dependencies = [ + "anyhow", + "lanparty-proto", + "windows-sys 0.61.2", +] + [[package]] name = "lanparty-client-win" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index f1796ff..3613762 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "3" members = [ "crates/lanparty-client-core", + "crates/lanparty-client-tap", "crates/lanparty-client-win", "crates/lanparty-ctrl", "crates/lanparty-gateway", @@ -28,3 +29,4 @@ serde_json = "1" thiserror = "2" tokio = { version = "1.52.3", features = ["macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tracing = "0.1" +windows-sys = "0.61.2" diff --git a/README.md b/README.md index 9575270..b0c9f24 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Monorepo for a Layer 2 over QUIC LAN party bridge. - `lanparty-ctrl`: control-plane messages (join/hello/role/version). - `lanparty-obs`: shared diagnostics/logging event models. - `lanparty-client-core`: platform-agnostic client session state. +- `lanparty-client-tap`: TAP-Windows6 adapter discovery and frame I/O. - `lanparty-client-win`: Windows TAP + route/metric handling binary. - `lanparty-gateway`: Linux AF_PACKET gateway binary. - `lanparty-relay`: public QUIC relay binary. @@ -47,6 +48,15 @@ Platform-neutral remote client relay session: - welcome/reject handling with assigned peer id and effective TAP MTU - Ethernet frame send/receive helpers over QUIC DATAGRAM +### `lanparty-client-tap` + +Windows TAP adapter boundary: + +- TAP-Windows6 adapter discovery from the Windows network adapter registry +- `\\.\Global\{NetCfgInstanceId}.tap` device path construction +- blocking Ethernet frame reads/writes through the TAP device handle +- TAP driver IOCTL helpers for media status, adapter MAC, and MTU + ### `lanparty-relay` Public relay binary and relay-owned room state: diff --git a/crates/lanparty-client-tap/Cargo.toml b/crates/lanparty-client-tap/Cargo.toml new file mode 100644 index 0000000..1791f73 --- /dev/null +++ b/crates/lanparty-client-tap/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "lanparty-client-tap" +version.workspace = true +edition.workspace = true + +[dependencies] +anyhow.workspace = true +lanparty-proto = { path = "../lanparty-proto" } + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_IO", + "Win32_System_Registry", +] } diff --git a/crates/lanparty-client-tap/src/lib.rs b/crates/lanparty-client-tap/src/lib.rs new file mode 100644 index 0000000..7317e6f --- /dev/null +++ b/crates/lanparty-client-tap/src/lib.rs @@ -0,0 +1,145 @@ +//! TAP-Windows6 adapter discovery and raw Ethernet frame I/O. +//! +//! This crate deliberately stays below the relay session layer. It only knows +//! how to find and open an installed TAP-Windows6 Ethernet adapter; the Windows +//! client binary owns when to connect it to QUIC and how to protect routes. + +use anyhow::{Result, bail}; + +pub const TAP_COMPONENT_ID: &str = "tap0901"; +pub const TAP_ADAPTER_KEY: &str = + r"SYSTEM\CurrentControlSet\Control\Class\{4D36E972-E325-11CE-BFC1-08002BE10318}"; +pub const TAP_DEVICE_PREFIX: &str = r"\\.\Global\"; +pub const TAP_DEVICE_SUFFIX: &str = ".tap"; +const FILE_DEVICE_UNKNOWN: u32 = 0x0000_0022; +const METHOD_BUFFERED: u32 = 0; +const FILE_ANY_ACCESS: u32 = 0; +const TAP_IOCTL_GET_MAC_REQUEST: u32 = 1; +const TAP_IOCTL_GET_MTU_REQUEST: u32 = 3; +const TAP_IOCTL_SET_MEDIA_STATUS_REQUEST: u32 = 6; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TapAdapterInfo { + instance_id: String, + component_id: String, +} + +impl TapAdapterInfo { + pub fn new(instance_id: impl Into, component_id: impl Into) -> Result { + let instance_id = instance_id.into(); + if instance_id.trim().is_empty() { + bail!("TAP adapter instance id cannot be empty"); + } + + let component_id = component_id.into(); + if !is_tap_component_id(&component_id) { + bail!("unsupported TAP adapter component id {component_id:?}"); + } + + Ok(Self { + instance_id, + component_id, + }) + } + + #[must_use] + pub fn instance_id(&self) -> &str { + &self.instance_id + } + + #[must_use] + pub fn component_id(&self) -> &str { + &self.component_id + } + + #[must_use] + pub fn device_path(&self) -> String { + tap_device_path(&self.instance_id) + } +} + +#[must_use] +pub fn is_tap_component_id(component_id: &str) -> bool { + let id = component_id.trim(); + id.eq_ignore_ascii_case(TAP_COMPONENT_ID) + || id.eq_ignore_ascii_case(concat!("root\\", "tap0901")) +} + +#[must_use] +pub fn tap_device_path(instance_id: &str) -> String { + format!("{TAP_DEVICE_PREFIX}{instance_id}{TAP_DEVICE_SUFFIX}") +} + +#[must_use] +pub const fn tap_control_code(request: u32) -> u32 { + (FILE_DEVICE_UNKNOWN << 16) | (FILE_ANY_ACCESS << 14) | (request << 2) | METHOD_BUFFERED +} + +#[must_use] +pub const fn tap_ioctl_get_mac() -> u32 { + tap_control_code(TAP_IOCTL_GET_MAC_REQUEST) +} + +#[must_use] +pub const fn tap_ioctl_get_mtu() -> u32 { + tap_control_code(TAP_IOCTL_GET_MTU_REQUEST) +} + +#[must_use] +pub const fn tap_ioctl_set_media_status() -> u32 { + tap_control_code(TAP_IOCTL_SET_MEDIA_STATUS_REQUEST) +} + +#[cfg(windows)] +mod windows; + +#[cfg(windows)] +pub use windows::{TapAdapter, available_adapters, open_first_adapter}; + +#[cfg(not(windows))] +pub fn available_adapters() -> Result> { + bail!("TAP-Windows6 adapter discovery is only available on Windows"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identifies_tap_windows_component_ids() { + assert!(is_tap_component_id("tap0901")); + assert!(is_tap_component_id("TAP0901")); + assert!(is_tap_component_id("root\\tap0901")); + assert!(!is_tap_component_id("wintun")); + assert!(!is_tap_component_id("tap0801")); + } + + #[test] + fn builds_tap_device_path() { + assert_eq!( + tap_device_path("{01234567-89AB-CDEF-0123-456789ABCDEF}"), + r"\\.\Global\{01234567-89AB-CDEF-0123-456789ABCDEF}.tap" + ); + } + + #[test] + fn computes_tap_ioctl_codes() { + assert_eq!(tap_ioctl_get_mac(), 0x0022_0004); + assert_eq!(tap_ioctl_get_mtu(), 0x0022_000c); + assert_eq!(tap_ioctl_set_media_status(), 0x0022_0018); + } + + #[test] + fn validates_adapter_info() { + let info = + TapAdapterInfo::new("{01234567-89AB-CDEF-0123-456789ABCDEF}", "tap0901").unwrap(); + + assert_eq!(info.component_id(), "tap0901"); + assert_eq!( + info.device_path(), + r"\\.\Global\{01234567-89AB-CDEF-0123-456789ABCDEF}.tap" + ); + assert!(TapAdapterInfo::new("", "tap0901").is_err()); + assert!(TapAdapterInfo::new("{01234567-89AB-CDEF-0123-456789ABCDEF}", "wintun").is_err()); + } +} diff --git a/crates/lanparty-client-tap/src/windows.rs b/crates/lanparty-client-tap/src/windows.rs new file mode 100644 index 0000000..345da43 --- /dev/null +++ b/crates/lanparty-client-tap/src/windows.rs @@ -0,0 +1,376 @@ +use std::{ + ffi::c_void, + io::{self, ErrorKind}, + ptr::{null, null_mut}, +}; + +use anyhow::{Context, Result}; +use lanparty_proto::MacAddr; +use windows_sys::Win32::{ + Foundation::{ + CloseHandle, ERROR_FILE_NOT_FOUND, ERROR_MORE_DATA, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, + GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, + }, + Storage::FileSystem::{ + CreateFileW, FILE_ATTRIBUTE_SYSTEM, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + ReadFile, WriteFile, + }, + System::{ + IO::DeviceIoControl, + Registry::{ + HKEY, HKEY_LOCAL_MACHINE, KEY_READ, REG_SZ, RegCloseKey, RegEnumKeyExW, RegOpenKeyExW, + RegQueryValueExW, + }, + }, +}; + +use crate::{ + TAP_ADAPTER_KEY, TapAdapterInfo, is_tap_component_id, tap_ioctl_get_mac, tap_ioctl_get_mtu, + tap_ioctl_set_media_status, +}; + +#[derive(Debug)] +pub struct TapAdapter { + info: TapAdapterInfo, + handle: OwnedHandle, +} + +impl TapAdapter { + pub fn open(info: TapAdapterInfo) -> Result { + let path = info.device_path(); + let wide_path = wide_null(&path); + let handle = unsafe { + // SAFETY: wide_path is NUL-terminated and lives for the duration of the call. + // The security attributes and template handle are intentionally null. + CreateFileW( + wide_path.as_ptr(), + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, + null(), + OPEN_EXISTING, + FILE_ATTRIBUTE_SYSTEM, + null_mut(), + ) + }; + let handle = OwnedHandle::new(handle) + .with_context(|| format!("failed to open TAP adapter device {path}"))?; + + Ok(Self { info, handle }) + } + + #[must_use] + pub fn info(&self) -> &TapAdapterInfo { + &self.info + } + + pub fn set_media_connected(&self, connected: bool) -> Result<()> { + let mut status = u32::from(connected); + self.device_io_control( + tap_ioctl_set_media_status(), + (&mut status as *mut u32).cast::(), + std::mem::size_of::() as u32, + null_mut(), + 0, + ) + .context("failed to set TAP media status")?; + + Ok(()) + } + + pub fn driver_mac(&self) -> Result { + let mut bytes = [0_u8; 6]; + self.device_io_control( + tap_ioctl_get_mac(), + null_mut(), + 0, + bytes.as_mut_ptr().cast::(), + bytes.len() as u32, + ) + .context("failed to read TAP driver MAC address")?; + + Ok(MacAddr::new(bytes)) + } + + pub fn driver_mtu(&self) -> Result { + let mut mtu = 0_u32; + self.device_io_control( + tap_ioctl_get_mtu(), + null_mut(), + 0, + (&mut mtu as *mut u32).cast::(), + std::mem::size_of::() as u32, + ) + .context("failed to read TAP driver MTU")?; + + Ok(mtu) + } + + pub fn read_frame(&self, buffer: &mut [u8]) -> Result { + let mut bytes_read = 0_u32; + let ok = unsafe { + // SAFETY: buffer is valid for writes of buffer.len() bytes and the handle is owned by + // this adapter. The synchronous handle uses a null OVERLAPPED pointer. + ReadFile( + self.handle.raw(), + buffer.as_mut_ptr(), + buffer + .len() + .try_into() + .context("TAP read buffer is too large")?, + &mut bytes_read, + null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()).context("failed to read TAP frame"); + } + + Ok(bytes_read as usize) + } + + pub fn write_frame(&self, frame: &[u8]) -> Result { + let mut bytes_written = 0_u32; + let ok = unsafe { + // SAFETY: frame is valid for reads of frame.len() bytes and the handle is owned by + // this adapter. The synchronous handle uses a null OVERLAPPED pointer. + WriteFile( + self.handle.raw(), + frame.as_ptr(), + frame.len().try_into().context("TAP frame is too large")?, + &mut bytes_written, + null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()).context("failed to write TAP frame"); + } + + Ok(bytes_written as usize) + } + + fn device_io_control( + &self, + code: u32, + input: *mut c_void, + input_len: u32, + output: *mut c_void, + output_len: u32, + ) -> io::Result { + let mut bytes_returned = 0_u32; + let ok = unsafe { + // SAFETY: input/output pointers and lengths are supplied by the typed public methods + // above. The synchronous handle uses a null OVERLAPPED pointer. + DeviceIoControl( + self.handle.raw(), + code, + input.cast_const(), + input_len, + output, + output_len, + &mut bytes_returned, + null_mut(), + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(bytes_returned) + } +} + +pub fn open_first_adapter() -> Result { + let mut adapters = available_adapters()?; + let info = adapters + .drain(..) + .next() + .context("no TAP-Windows6 adapters found")?; + + TapAdapter::open(info) +} + +pub fn available_adapters() -> Result> { + let adapters_key = RegKey::open(HKEY_LOCAL_MACHINE, TAP_ADAPTER_KEY) + .context("failed to open TAP adapter registry key")?; + let mut adapters = Vec::new(); + + for subkey in adapters_key.subkey_names()? { + let subkey = adapters_key + .open_subkey(&subkey) + .with_context(|| format!("failed to open TAP adapter registry subkey {subkey}"))?; + let Some(component_id) = subkey.query_string("ComponentId")? else { + continue; + }; + if !is_tap_component_id(&component_id) { + continue; + } + let Some(instance_id) = subkey.query_string("NetCfgInstanceId")? else { + continue; + }; + + adapters.push(TapAdapterInfo::new(instance_id, component_id)?); + } + + Ok(adapters) +} + +#[derive(Debug)] +struct OwnedHandle(HANDLE); + +impl OwnedHandle { + fn new(handle: HANDLE) -> io::Result { + if handle == INVALID_HANDLE_VALUE { + Err(io::Error::last_os_error()) + } else { + Ok(Self(handle)) + } + } + + const fn raw(&self) -> HANDLE { + self.0 + } +} + +impl Drop for OwnedHandle { + fn drop(&mut self) { + unsafe { + // SAFETY: self.0 is a valid owned HANDLE created by CreateFileW. + CloseHandle(self.0); + } + } +} + +#[derive(Debug)] +struct RegKey(HKEY); + +impl RegKey { + fn open(root: HKEY, path: &str) -> io::Result { + let path = wide_null(path); + let mut key = null_mut(); + let status = unsafe { + // SAFETY: path is NUL-terminated and phkresult points to valid storage. + RegOpenKeyExW(root, path.as_ptr(), 0, KEY_READ, &mut key) + }; + windows_status(status)?; + + Ok(Self(key)) + } + + fn open_subkey(&self, path: &str) -> io::Result { + Self::open(self.0, path) + } + + fn subkey_names(&self) -> io::Result> { + let mut names = Vec::new(); + let mut index = 0_u32; + + loop { + let mut buffer = vec![0_u16; 256]; + let mut len = buffer.len() as u32; + let status = unsafe { + // SAFETY: buffer is valid for len UTF-16 code units and len points to storage + // that RegEnumKeyExW updates with the returned name length. + RegEnumKeyExW( + self.0, + index, + buffer.as_mut_ptr(), + &mut len, + null(), + null_mut(), + null_mut(), + null_mut(), + ) + }; + match status { + ERROR_SUCCESS => { + buffer.truncate(len as usize); + names.push(String::from_utf16_lossy(&buffer)); + index += 1; + } + ERROR_NO_MORE_ITEMS => return Ok(names), + ERROR_MORE_DATA => { + return Err(io::Error::new( + ErrorKind::InvalidData, + "registry subkey name exceeded internal buffer", + )); + } + status => return Err(windows_error(status)), + } + } + } + + fn query_string(&self, name: &str) -> io::Result> { + let name = wide_null(name); + let mut value_type = 0_u32; + let mut byte_len = 0_u32; + let status = unsafe { + // SAFETY: name is NUL-terminated. A null data buffer asks Windows for the byte size. + RegQueryValueExW( + self.0, + name.as_ptr(), + null(), + &mut value_type, + null_mut(), + &mut byte_len, + ) + }; + match status { + ERROR_SUCCESS => {} + ERROR_FILE_NOT_FOUND => return Ok(None), + status => return Err(windows_error(status)), + } + + if value_type != REG_SZ { + return Ok(None); + } + if byte_len == 0 { + return Ok(Some(String::new())); + } + + let mut buffer = vec![0_u16; byte_len.div_ceil(2) as usize]; + let status = unsafe { + // SAFETY: buffer is valid for byte_len bytes and name remains NUL-terminated. + RegQueryValueExW( + self.0, + name.as_ptr(), + null(), + &mut value_type, + buffer.as_mut_ptr().cast::(), + &mut byte_len, + ) + }; + windows_status(status)?; + let nul = buffer + .iter() + .position(|value| *value == 0) + .unwrap_or(buffer.len()); + buffer.truncate(nul); + + Ok(Some(String::from_utf16_lossy(&buffer))) + } +} + +impl Drop for RegKey { + fn drop(&mut self) { + unsafe { + // SAFETY: self.0 is a valid open registry key owned by this value. + RegCloseKey(self.0); + } + } +} + +fn windows_status(status: u32) -> io::Result<()> { + if status == ERROR_SUCCESS { + Ok(()) + } else { + Err(windows_error(status)) + } +} + +fn windows_error(status: u32) -> io::Error { + io::Error::from_raw_os_error(status as i32) +} + +fn wide_null(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() +}