feat(gateway): add relay Ethernet datagram helpers

GatewayConnection can now send and receive Ethernet frames over the admitted
relay QUIC connection. Outgoing frames are wrapped in the shared overlay format
with the gateway's assigned room id and peer id; incoming datagrams are ignored
unless they are Ethernet frames for the assigned room from another peer.

The receive helper also parses the payload as an Ethernet frame before exposing
it, which keeps the future AF_PACKET bridge from injecting malformed runt
payloads if the relay path ever misbehaves.

The loopback connector test now verifies the full post-handshake datagram path:
the gateway sends a frame to the test relay, the relay validates the overlay
metadata, and the gateway receives a relay-sent Ethernet frame back.

Test Plan:
- cargo fmt --check
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings

Refs: PLAN.md gateway relay datagram send/receive
This commit is contained in:
2026-05-21 18:12:07 +02:00
parent 1b00deb419
commit 128903c312
4 changed files with 103 additions and 1 deletions
Generated
+2
View File
@@ -453,8 +453,10 @@ name = "lanparty-gateway"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytes",
"clap", "clap",
"lanparty-ctrl", "lanparty-ctrl",
"lanparty-proto",
"libc", "libc",
"quinn", "quinn",
"rcgen", "rcgen",
+2 -1
View File
@@ -82,4 +82,5 @@ cargo run -p lanparty-gateway -- \
The gateway currently connects to the relay as `role = gateway`, completes the The gateway currently connects to the relay as `role = gateway`, completes the
control-stream hello/welcome handshake, opens an AF_PACKET socket on the LAN 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. interface, and has relay Ethernet datagram send/receive helpers. The frame
bridge loop is not wired yet.
+2
View File
@@ -5,8 +5,10 @@ edition.workspace = true
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
bytes.workspace = true
clap.workspace = true clap.workspace = true
lanparty-ctrl = { path = "../lanparty-ctrl" } lanparty-ctrl = { path = "../lanparty-ctrl" }
lanparty-proto = { path = "../lanparty-proto" }
libc.workspace = true libc.workspace = true
quinn.workspace = true quinn.workspace = true
rustls.workspace = true rustls.workspace = true
+97
View File
@@ -15,11 +15,13 @@ use std::{
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use bytes::Bytes;
use clap::Parser; use clap::Parser;
use lanparty_ctrl::{ use lanparty_ctrl::{
CONTROL_LENGTH_PREFIX_LEN, ControlMessage, EndpointHello, MAX_CONTROL_MESSAGE_LEN, RELAY_ALPN, CONTROL_LENGTH_PREFIX_LEN, ControlMessage, EndpointHello, MAX_CONTROL_MESSAGE_LEN, RELAY_ALPN,
RoomCode, ServerWelcome, decode_control_frame, encode_control_message, RoomCode, ServerWelcome, decode_control_frame, encode_control_message,
}; };
use lanparty_proto::{EthernetFrame, FrameType, decode_datagram, encode_datagram};
use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig}; use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer; use rustls::pki_types::CertificateDer;
@@ -164,6 +166,24 @@ pub struct GatewayConnection {
welcome: ServerWelcome, welcome: ServerWelcome,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReceivedEthernetFrame {
source_peer_id: u32,
payload: Bytes,
}
impl ReceivedEthernetFrame {
#[must_use]
pub const fn source_peer_id(&self) -> u32 {
self.source_peer_id
}
#[must_use]
pub fn payload(&self) -> &[u8] {
&self.payload
}
}
impl GatewayConnection { impl GatewayConnection {
#[must_use] #[must_use]
pub const fn config(&self) -> &GatewayConfig { pub const fn config(&self) -> &GatewayConfig {
@@ -175,6 +195,47 @@ impl GatewayConnection {
&self.welcome &self.welcome
} }
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
let datagram = encode_datagram(
FrameType::Ethernet,
self.welcome.room_id(),
self.welcome.peer_id(),
0,
frame,
)
.context("failed to encode gateway Ethernet datagram")?;
self.connection
.send_datagram(Bytes::from(datagram))
.context("failed to send gateway Ethernet datagram")?;
Ok(())
}
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
loop {
let datagram = self.connection.read_datagram().await?;
let Ok(packet) = decode_datagram(&datagram) else {
continue;
};
let header = packet.header();
if header.frame_type() != FrameType::Ethernet
|| header.room_id() != self.welcome.room_id()
|| header.peer_id() == self.welcome.peer_id()
{
continue;
}
if EthernetFrame::parse(packet.payload()).is_err() {
continue;
}
return Ok(ReceivedEthernetFrame {
source_peer_id: header.peer_id(),
payload: Bytes::copy_from_slice(packet.payload()),
});
}
}
pub async fn shutdown(self, reason: &str) { pub async fn shutdown(self, reason: &str) {
self.connection.close(0_u32.into(), reason.as_bytes()); self.connection.close(0_u32.into(), reason.as_bytes());
self.endpoint.wait_idle().await; self.endpoint.wait_idle().await;
@@ -258,6 +319,7 @@ async fn request_control_message(
mod tests { mod tests {
use std::time::Duration; use std::time::Duration;
use bytes::Bytes;
use lanparty_ctrl::Role; use lanparty_ctrl::Role;
use quinn::{ServerConfig, TransportConfig, crypto::rustls::QuicServerConfig}; use quinn::{ServerConfig, TransportConfig, crypto::rustls::QuicServerConfig};
use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer}; use rustls::pki_types::{PrivateKeyDer, PrivatePkcs8KeyDer};
@@ -340,6 +402,24 @@ mod tests {
send.write_all(&response).await.unwrap(); send.write_all(&response).await.unwrap();
send.finish().unwrap(); send.finish().unwrap();
let datagram = connection.read_datagram().await.unwrap();
let packet = decode_datagram(&datagram).unwrap();
let header = packet.header();
assert_eq!(header.frame_type(), FrameType::Ethernet);
assert_eq!(header.room_id(), 7);
assert_eq!(header.peer_id(), 1);
assert_eq!(packet.payload(), ethernet_frame(b"to relay").as_slice());
let response = encode_datagram(
FrameType::Ethernet,
7,
99,
0,
&ethernet_frame(b"from relay"),
)
.unwrap();
connection.send_datagram(Bytes::from(response)).unwrap();
connection.closed().await; connection.closed().await;
endpoint.close(0_u32.into(), b"test complete"); endpoint.close(0_u32.into(), b"test complete");
endpoint.wait_idle().await; endpoint.wait_idle().await;
@@ -360,6 +440,14 @@ mod tests {
assert_eq!(gateway.welcome().room_id(), 7); assert_eq!(gateway.welcome().room_id(), 7);
assert_eq!(gateway.welcome().peer_id(), 1); assert_eq!(gateway.welcome().peer_id(), 1);
gateway.send_ethernet(&ethernet_frame(b"to relay")).unwrap();
let received = tokio::time::timeout(Duration::from_secs(5), gateway.recv_ethernet())
.await
.unwrap()
.unwrap();
assert_eq!(received.source_peer_id(), 99);
assert_eq!(received.payload(), ethernet_frame(b"from relay").as_slice());
gateway.shutdown("test complete").await; gateway.shutdown("test complete").await;
tokio::time::timeout(Duration::from_secs(5), server_task) tokio::time::timeout(Duration::from_secs(5), server_task)
.await .await
@@ -390,4 +478,13 @@ mod tests {
(server_config, certificate) (server_config, certificate)
} }
fn ethernet_frame(payload: &[u8]) -> Vec<u8> {
let mut frame = Vec::new();
frame.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]);
frame.extend_from_slice(&[0x02, 0, 0, 0, 0, 1]);
frame.extend_from_slice(&0x0800_u16.to_be_bytes());
frame.extend_from_slice(payload);
frame
}
} }