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
This commit is contained in:
2026-05-21 18:09:03 +02:00
parent 763a55bfba
commit 1b00deb419
7 changed files with 186 additions and 2 deletions
Generated
+1
View File
@@ -455,6 +455,7 @@ dependencies = [
"anyhow",
"clap",
"lanparty-ctrl",
"libc",
"quinn",
"rcgen",
"rustls",
+1
View File
@@ -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"] }
+2 -2
View File
@@ -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.
+1
View File
@@ -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
+6
View File
@@ -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)]
+14
View File
@@ -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;
+161
View File
@@ -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<Self> {
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::<libc::sockaddr_ll>()
};
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::<libc::sockaddr>(),
std::mem::size_of::<libc::sockaddr_ll>() 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<usize> {
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::<libc::c_void>(),
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<usize> {
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::<libc::c_void>(),
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<u32> {
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);
}
}