From 1b00deb419fb41b54835cc4b5c52ab8fd80f83a3 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 18:09:03 +0200 Subject: [PATCH] feat(gateway): open AF_PACKET sockets The gateway now has a small Linux PacketSocket wrapper for raw Ethernet frame I/O. It resolves the configured interface with if_nametoindex, opens an AF_PACKET/SOCK_RAW socket for ETH_P_ALL, binds it to the interface, and exposes thin send_frame and recv_frame helpers around the owned file descriptor. The gateway binary opens this socket after completing the relay control handshake. The frame bridge loop is still intentionally left for a later slice, but the process now proves the two required resources are available: relay admission and raw L2 access on the LAN interface. Tests cover interface-name validation and missing-interface lookup without requiring root or CAP_NET_RAW. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings Refs: PLAN.md Linux AF_PACKET gateway socket --- Cargo.lock | 1 + Cargo.toml | 1 + README.md | 4 +- crates/lanparty-gateway/Cargo.toml | 1 + crates/lanparty-gateway/src/lib.rs | 6 + crates/lanparty-gateway/src/main.rs | 14 +++ crates/lanparty-gateway/src/packet.rs | 161 ++++++++++++++++++++++++++ 7 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 crates/lanparty-gateway/src/packet.rs diff --git a/Cargo.lock b/Cargo.lock index 6a5dd2d..78a7f32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,6 +455,7 @@ dependencies = [ "anyhow", "clap", "lanparty-ctrl", + "libc", "quinn", "rcgen", "rustls", diff --git a/Cargo.toml b/Cargo.toml index 2757b3a..8015eb8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ edition = "2024" anyhow = "1" bytes = "1" clap = { version = "4.6.1", features = ["derive"] } +libc = "0.2" quinn = "0.11.9" rcgen = "0.14.8" rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } diff --git a/README.md b/README.md index c2307bc..f562c1b 100644 --- a/README.md +++ b/README.md @@ -81,5 +81,5 @@ cargo run -p lanparty-gateway -- \ ``` The gateway currently connects to the relay as `role = gateway`, completes the -control-stream hello/welcome handshake, and then waits for shutdown. AF_PACKET -capture and injection are not wired yet. +control-stream hello/welcome handshake, opens an AF_PACKET socket on the LAN +interface, and then waits for shutdown. The frame bridge loop is not wired yet. diff --git a/crates/lanparty-gateway/Cargo.toml b/crates/lanparty-gateway/Cargo.toml index 520a166..96aa363 100644 --- a/crates/lanparty-gateway/Cargo.toml +++ b/crates/lanparty-gateway/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true anyhow.workspace = true clap.workspace = true lanparty-ctrl = { path = "../lanparty-ctrl" } +libc.workspace = true quinn.workspace = true rustls.workspace = true tokio.workspace = true diff --git a/crates/lanparty-gateway/src/lib.rs b/crates/lanparty-gateway/src/lib.rs index 1948f0e..3ab8e8b 100644 --- a/crates/lanparty-gateway/src/lib.rs +++ b/crates/lanparty-gateway/src/lib.rs @@ -4,6 +4,9 @@ //! will add the AF_PACKET capture/injection loop that feeds Ethernet frames into //! this established QUIC session. +#[cfg(target_os = "linux")] +mod packet; + use std::{ fs, net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, @@ -20,6 +23,9 @@ use lanparty_ctrl::{ use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use rustls::pki_types::CertificateDer; +#[cfg(target_os = "linux")] +pub use packet::PacketSocket; + const MAX_CONTROL_FRAME_LEN: usize = CONTROL_LENGTH_PREFIX_LEN + MAX_CONTROL_MESSAGE_LEN; #[derive(Debug, Parser)] diff --git a/crates/lanparty-gateway/src/main.rs b/crates/lanparty-gateway/src/main.rs index 4a1932d..eb9c319 100644 --- a/crates/lanparty-gateway/src/main.rs +++ b/crates/lanparty-gateway/src/main.rs @@ -1,4 +1,6 @@ use clap::Parser; +#[cfg(target_os = "linux")] +use lanparty_gateway::PacketSocket; use lanparty_gateway::{GatewayArgs, connect_gateway}; #[tokio::main] @@ -19,6 +21,18 @@ async fn main() -> anyhow::Result<()> { gateway.welcome().effective_tap_mtu() ); println!("AF_PACKET bridging is not wired yet; press Ctrl-C to stop"); + #[cfg(target_os = "linux")] + let _packet_socket = { + let socket = PacketSocket::open(gateway.config().interface())?; + println!( + "lanparty-gateway opened AF_PACKET socket on {} (ifindex {})", + socket.interface(), + socket.interface_index() + ); + socket + }; + #[cfg(not(target_os = "linux"))] + anyhow::bail!("lanparty-gateway requires Linux AF_PACKET support"); tokio::signal::ctrl_c().await?; gateway.shutdown("gateway shutting down").await; diff --git a/crates/lanparty-gateway/src/packet.rs b/crates/lanparty-gateway/src/packet.rs new file mode 100644 index 0000000..c710b3a --- /dev/null +++ b/crates/lanparty-gateway/src/packet.rs @@ -0,0 +1,161 @@ +use std::{ + ffi::CString, + io, + os::fd::{AsRawFd, FromRawFd, OwnedFd}, +}; + +const ETH_P_ALL: u16 = libc::ETH_P_ALL as u16; + +#[derive(Debug)] +pub struct PacketSocket { + fd: OwnedFd, + interface: String, + interface_index: u32, +} + +impl PacketSocket { + pub fn open(interface: &str) -> io::Result { + let interface_index = interface_index(interface)?; + let protocol = i32::from(ETH_P_ALL.to_be()); + let raw_fd = unsafe { + // SAFETY: socket is called with constant domain/type/protocol values and returns + // a new file descriptor or -1 without aliasing Rust-owned memory. + libc::socket( + libc::AF_PACKET, + libc::SOCK_RAW | libc::SOCK_CLOEXEC, + protocol, + ) + }; + if raw_fd < 0 { + return Err(io::Error::last_os_error()); + } + let fd = unsafe { + // SAFETY: raw_fd was just returned by socket and is owned by this function. + OwnedFd::from_raw_fd(raw_fd) + }; + + let mut address = unsafe { + // SAFETY: sockaddr_ll is a plain old data kernel ABI struct; zero is a valid base + // before filling the fields required by bind. + std::mem::zeroed::() + }; + address.sll_family = libc::AF_PACKET as u16; + address.sll_protocol = ETH_P_ALL.to_be(); + address.sll_ifindex = interface_index as i32; + + let result = unsafe { + // SAFETY: address points to a properly initialized sockaddr_ll and the length + // matches that struct. fd remains owned by this function across the call. + libc::bind( + fd.as_raw_fd(), + (&address as *const libc::sockaddr_ll).cast::(), + std::mem::size_of::() as libc::socklen_t, + ) + }; + if result < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(Self { + fd, + interface: interface.to_owned(), + interface_index, + }) + } + + #[must_use] + pub fn interface(&self) -> &str { + &self.interface + } + + #[must_use] + pub const fn interface_index(&self) -> u32 { + self.interface_index + } + + pub fn send_frame(&self, frame: &[u8]) -> io::Result { + let sent = unsafe { + // SAFETY: frame.as_ptr() is valid for frame.len() bytes for the duration of send, + // and send does not retain the pointer after returning. + libc::send( + self.fd.as_raw_fd(), + frame.as_ptr().cast::(), + frame.len(), + 0, + ) + }; + if sent < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(sent as usize) + } + + pub fn recv_frame(&self, buffer: &mut [u8]) -> io::Result { + let received = unsafe { + // SAFETY: buffer.as_mut_ptr() is valid for buffer.len() bytes for the duration of + // recv, and recv initializes at most that many bytes. + libc::recv( + self.fd.as_raw_fd(), + buffer.as_mut_ptr().cast::(), + buffer.len(), + 0, + ) + }; + if received < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(received as usize) + } +} + +pub fn interface_index(interface: &str) -> io::Result { + if interface.trim().is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "interface name cannot be empty", + )); + } + + let name = CString::new(interface).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "interface name cannot contain NUL bytes", + ) + })?; + let index = unsafe { + // SAFETY: name is a valid NUL-terminated C string and if_nametoindex does not retain it. + libc::if_nametoindex(name.as_ptr()) + }; + + if index == 0 { + return Err(io::Error::last_os_error()); + } + + Ok(index) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_invalid_interface_names() { + assert_eq!( + interface_index("").unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + assert_eq!( + interface_index("eth0\0bad").unwrap_err().kind(), + io::ErrorKind::InvalidInput + ); + } + + #[test] + fn reports_missing_interface() { + let error = interface_index("lanparty-definitely-missing0").unwrap_err(); + + assert_ne!(error.kind(), io::ErrorKind::InvalidInput); + } +}