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:
@@ -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",
|
||||
] }
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user