Files
softlan-vpn/crates/lanparty-gateway/src/packet.rs
T
ddidderr 217469edf0 fix(gateway): reject LAN interfaces without carrier
A gateway with the selected Ethernet cable unplugged can still open an
AF_PACKET socket and join the relay room. That makes clients see a connected LAN
gateway even though DHCP and LAN discovery cannot make the physical round trip.

Check the selected Linux interface's sysfs carrier file before creating the raw
socket. If sysfs reports carrier 0, fail before the gateway joins the relay.
Missing or unrecognized carrier files remain allowed so this does not reject
interfaces where the kernel cannot expose link state in that form. README and
TESTING now document the preflight and the operator fix.

Test Plan:
All cargo commands used these environment variables:
RUSTUP_HOME=/tmp/softlan-vpn-rustup
CARGO_HOME=/tmp/softlan-vpn-cargo

- cargo test -p lanparty-gateway \
  packet::tests::detects_disconnected_interfaces_from_sysfs_carrier
- cargo test -p lanparty-gateway
- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- git diff --check

Refs: PLAN.md wired Ethernet gateway requirement
2026-05-22 04:48:12 +02:00

435 lines
14 KiB
Rust

use std::{
ffi::CString,
fs, io,
os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
path::Path,
};
use lanparty_proto::MacAddr;
const ETH_P_ALL: u16 = libc::ETH_P_ALL as u16;
const SYS_CLASS_NET: &str = "/sys/class/net";
const WIRELESS_INTERFACE_MARKERS: &[&str] = &["wireless", "phy80211"];
const CARRIER_FILE: &str = "carrier";
#[derive(Debug)]
pub struct PacketSocket {
fd: OwnedFd,
interface: String,
interface_index: u32,
interface_mac: MacAddr,
}
impl PacketSocket {
pub fn open(interface: &str) -> io::Result<Self> {
let interface_index = interface_index(interface)?;
reject_wireless_interface(interface)?;
reject_disconnected_interface(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 | libc::SOCK_NONBLOCK,
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());
}
enable_promiscuous_membership(fd.as_raw_fd(), interface_index)?;
let interface_mac = interface_hardware_addr(fd.as_raw_fd(), interface)?;
Ok(Self {
fd,
interface: interface.to_owned(),
interface_index,
interface_mac,
})
}
#[must_use]
pub fn interface(&self) -> &str {
&self.interface
}
#[must_use]
pub const fn interface_index(&self) -> u32 {
self.interface_index
}
#[must_use]
pub const fn interface_mac(&self) -> MacAddr {
self.interface_mac
}
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> {
loop {
let mut address = unsafe {
// SAFETY: sockaddr_ll is a plain old data kernel ABI struct; zero is a valid
// base before recvfrom initializes the peer address.
std::mem::zeroed::<libc::sockaddr_ll>()
};
let mut address_len = std::mem::size_of::<libc::sockaddr_ll>() as libc::socklen_t;
let received = unsafe {
// SAFETY: buffer.as_mut_ptr() is valid for buffer.len() bytes for the duration
// of recvfrom, and recvfrom initializes at most that many bytes. address points
// to a sockaddr_ll-sized output buffer and address_len carries that size.
libc::recvfrom(
self.fd.as_raw_fd(),
buffer.as_mut_ptr().cast::<libc::c_void>(),
buffer.len(),
0,
(&mut address as *mut libc::sockaddr_ll).cast::<libc::sockaddr>(),
&mut address_len,
)
};
if received < 0 {
return Err(io::Error::last_os_error());
}
if is_inbound_packet_type(address.sll_pkttype) {
return Ok(received as usize);
}
}
}
}
impl AsRawFd for PacketSocket {
fn as_raw_fd(&self) -> RawFd {
self.fd.as_raw_fd()
}
}
pub fn interface_index(interface: &str) -> io::Result<u32> {
let name = interface_name(interface)?;
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)
}
fn enable_promiscuous_membership(fd: RawFd, interface_index: u32) -> io::Result<()> {
let membership = promiscuous_membership(interface_index);
let result = unsafe {
// SAFETY: membership points to a fully initialized packet_mreq and the provided length
// matches that kernel ABI struct. The socket fd is owned by PacketSocket::open.
libc::setsockopt(
fd,
libc::SOL_PACKET,
libc::PACKET_ADD_MEMBERSHIP,
(&membership as *const libc::packet_mreq).cast::<libc::c_void>(),
std::mem::size_of::<libc::packet_mreq>() as libc::socklen_t,
)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
fn promiscuous_membership(interface_index: u32) -> libc::packet_mreq {
let mut membership = unsafe {
// SAFETY: packet_mreq is a plain kernel ABI struct; zeroing leaves unused fields empty.
std::mem::zeroed::<libc::packet_mreq>()
};
membership.mr_ifindex = interface_index as libc::c_int;
membership.mr_type = libc::PACKET_MR_PROMISC as libc::c_ushort;
membership
}
fn interface_name(interface: &str) -> io::Result<CString> {
if interface.trim().is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"interface name cannot be empty",
));
}
if interface.contains('/') {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"interface name cannot contain path separators",
));
}
let name = CString::new(interface).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"interface name cannot contain NUL bytes",
)
})?;
if name.as_bytes_with_nul().len() > libc::IFNAMSIZ {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"interface name is too long",
));
}
Ok(name)
}
fn reject_wireless_interface(interface: &str) -> io::Result<()> {
if interface_is_wireless_in_sysfs(Path::new(SYS_CLASS_NET), interface) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("wireless interface {interface} cannot be used as a LAN gateway"),
));
}
Ok(())
}
fn reject_disconnected_interface(interface: &str) -> io::Result<()> {
if interface_carrier_state(Path::new(SYS_CLASS_NET), interface)? == Some(false) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"wired interface {interface} reports no carrier; plug it into the LAN switch before starting the gateway"
),
));
}
Ok(())
}
fn interface_is_wireless_in_sysfs(sys_class_net: &Path, interface: &str) -> bool {
WIRELESS_INTERFACE_MARKERS
.iter()
.any(|marker| sys_class_net.join(interface).join(marker).exists())
}
fn interface_carrier_state(sys_class_net: &Path, interface: &str) -> io::Result<Option<bool>> {
let carrier_path = sys_class_net.join(interface).join(CARRIER_FILE);
match fs::read_to_string(&carrier_path) {
Ok(value) => match value.trim() {
"0" => Ok(Some(false)),
"1" => Ok(Some(true)),
_ => Ok(None),
},
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(error),
}
}
fn interface_hardware_addr(fd: RawFd, interface: &str) -> io::Result<MacAddr> {
let name = interface_name(interface)?;
let mut request = unsafe {
// SAFETY: ifreq is a kernel ABI struct; zeroing it gives a valid base before filling the
// interface name and asking ioctl to populate the hardware-address union field.
std::mem::zeroed::<libc::ifreq>()
};
for (destination, source) in request
.ifr_name
.iter_mut()
.zip(name.as_bytes_with_nul().iter())
{
*destination = *source as libc::c_char;
}
let result = unsafe {
// SAFETY: request points to an initialized ifreq with a NUL-terminated interface name.
// ioctl writes the hardware address into the ifreq union and does not retain pointers.
libc::ioctl(fd, libc::SIOCGIFHWADDR, &mut request)
};
if result < 0 {
return Err(io::Error::last_os_error());
}
let hardware_addr = unsafe {
// SAFETY: SIOCGIFHWADDR succeeded, so reading the hardware-address union field is valid.
request.ifr_ifru.ifru_hwaddr
};
if hardware_addr.sa_family != libc::ARPHRD_ETHER {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"interface is not an Ethernet device",
));
}
let data = hardware_addr.sa_data;
let mac = MacAddr::new([
data[0] as u8,
data[1] as u8,
data[2] as u8,
data[3] as u8,
data[4] as u8,
data[5] as u8,
]);
if !mac.is_valid_unicast() {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("interface has invalid Ethernet MAC address {mac}"),
));
}
Ok(mac)
}
fn is_inbound_packet_type(packet_type: u8) -> bool {
packet_type != libc::PACKET_OUTGOING
}
#[cfg(test)]
mod tests {
use std::{
fs,
time::{SystemTime, UNIX_EPOCH},
};
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
);
assert_eq!(
interface_index("eth0/bad").unwrap_err().kind(),
io::ErrorKind::InvalidInput
);
}
#[test]
fn reports_missing_interface() {
let error = interface_index("lp-missing0").unwrap_err();
assert_ne!(error.kind(), io::ErrorKind::InvalidInput);
}
#[test]
fn builds_promiscuous_packet_membership_request() {
let membership = promiscuous_membership(12);
assert_eq!(membership.mr_ifindex, 12);
assert_eq!(membership.mr_type, libc::PACKET_MR_PROMISC as u16);
assert_eq!(membership.mr_alen, 0);
assert_eq!(membership.mr_address, [0; 8]);
}
#[test]
fn classifies_inbound_packet_types() {
assert!(!is_inbound_packet_type(libc::PACKET_OUTGOING));
assert!(is_inbound_packet_type(libc::PACKET_HOST));
assert!(is_inbound_packet_type(libc::PACKET_BROADCAST));
assert!(is_inbound_packet_type(libc::PACKET_MULTICAST));
}
#[test]
fn detects_wireless_interfaces_from_sysfs_markers() {
let sys_class_net = unique_temp_dir("lanparty-gateway-sysfs");
let wired = sys_class_net.join("eth0");
let wireless = sys_class_net.join("wlan0");
let phy_wireless = sys_class_net.join("wlp1s0");
fs::create_dir_all(&wired).unwrap();
fs::create_dir_all(wireless.join("wireless")).unwrap();
fs::create_dir_all(phy_wireless.join("phy80211")).unwrap();
assert!(!interface_is_wireless_in_sysfs(&sys_class_net, "eth0"));
assert!(interface_is_wireless_in_sysfs(&sys_class_net, "wlan0"));
assert!(interface_is_wireless_in_sysfs(&sys_class_net, "wlp1s0"));
fs::remove_dir_all(sys_class_net).unwrap();
}
#[test]
fn detects_disconnected_interfaces_from_sysfs_carrier() {
let sys_class_net = unique_temp_dir("lanparty-gateway-carrier");
let connected = sys_class_net.join("eth0");
let disconnected = sys_class_net.join("eth1");
let unknown = sys_class_net.join("eth2");
fs::create_dir_all(&connected).unwrap();
fs::create_dir_all(&disconnected).unwrap();
fs::create_dir_all(&unknown).unwrap();
fs::write(connected.join(CARRIER_FILE), "1\n").unwrap();
fs::write(disconnected.join(CARRIER_FILE), "0\n").unwrap();
fs::write(unknown.join(CARRIER_FILE), "unknown\n").unwrap();
assert_eq!(
interface_carrier_state(&sys_class_net, "eth0").unwrap(),
Some(true)
);
assert_eq!(
interface_carrier_state(&sys_class_net, "eth1").unwrap(),
Some(false)
);
assert_eq!(
interface_carrier_state(&sys_class_net, "eth2").unwrap(),
None
);
assert_eq!(
interface_carrier_state(&sys_class_net, "eth-missing").unwrap(),
None
);
fs::remove_dir_all(sys_class_net).unwrap();
}
fn unique_temp_dir(prefix: &str) -> std::path::PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{}-{nanos}", std::process::id()))
}
}