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
Generated
+9
View File
@@ -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"
+2
View File
@@ -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"
+10
View File
@@ -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:
+17
View File
@@ -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",
] }
+145
View File
@@ -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<String>, component_id: impl Into<String>) -> Result<Self> {
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<Vec<TapAdapterInfo>> {
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());
}
}
+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()
}