feat(client): add TAP-Windows adapter crate

The Windows client still needs a real Ethernet TAP boundary before it can pump
frames between the adapter and the relay session. Keep that OS-specific surface
separate from the QUIC client state by adding a `lanparty-client-tap` crate.

The new crate discovers TAP-Windows6 adapters through the Windows network
adapter registry class, validates `tap0901` component ids, constructs the
`\\.\Global\{NetCfgInstanceId}.tap` device path, opens the TAP device handle,
and exposes blocking Ethernet frame read/write helpers. It also wraps the
TAP-Windows IOCTLs for media status, driver MAC, and driver MTU.

This does not wire the TAP crate into `lanparty-client-win` yet and does not
attempt route protection. The value of this slice is the target-checkable OS
boundary that the next client pump can depend on.

Test Plan:
- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- Windows-target cargo check for lanparty-client-tap with clang-cl/lld-link
- Windows-target cargo clippy for lanparty-client-tap with -D warnings
- git diff --check

Refs: PLAN.md Windows TAP client
This commit is contained in:
2026-05-21 18:48:14 +02:00
parent c07e49581c
commit a09852dada
6 changed files with 559 additions and 0 deletions
+376
View File
@@ -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<Self> {
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::<c_void>(),
std::mem::size_of::<u32>() as u32,
null_mut(),
0,
)
.context("failed to set TAP media status")?;
Ok(())
}
pub fn driver_mac(&self) -> Result<MacAddr> {
let mut bytes = [0_u8; 6];
self.device_io_control(
tap_ioctl_get_mac(),
null_mut(),
0,
bytes.as_mut_ptr().cast::<c_void>(),
bytes.len() as u32,
)
.context("failed to read TAP driver MAC address")?;
Ok(MacAddr::new(bytes))
}
pub fn driver_mtu(&self) -> Result<u32> {
let mut mtu = 0_u32;
self.device_io_control(
tap_ioctl_get_mtu(),
null_mut(),
0,
(&mut mtu as *mut u32).cast::<c_void>(),
std::mem::size_of::<u32>() as u32,
)
.context("failed to read TAP driver MTU")?;
Ok(mtu)
}
pub fn read_frame(&self, buffer: &mut [u8]) -> Result<usize> {
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<usize> {
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<u32> {
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<TapAdapter> {
let mut adapters = available_adapters()?;
let info = adapters
.drain(..)
.next()
.context("no TAP-Windows6 adapters found")?;
TapAdapter::open(info)
}
pub fn available_adapters() -> Result<Vec<TapAdapterInfo>> {
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<Self> {
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<Self> {
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> {
Self::open(self.0, path)
}
fn subkey_names(&self) -> io::Result<Vec<String>> {
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<Option<String>> {
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::<u8>(),
&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<u16> {
value.encode_utf16().chain(std::iter::once(0)).collect()
}