feat(proto): validate negotiated datagram budgets

PLAN.md keeps MVP traffic to one Ethernet frame per QUIC datagram with no
fragmentation. The relay already negotiates a datagram budget, but the client
and gateway send paths still relied on Quinn to reject oversized encoded
Ethernet datagrams.

Add a shared protocol validation helper for encoded datagram length versus the
negotiated QUIC budget. Thread the negotiated budget into client and gateway
send boundaries, reject oversized datagrams before send, and count those valid
but unsent frames as local drops.

Document the budget check in the workspace decomposition and gateway behavior.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-proto -p lanparty-client-core -p lanparty-gateway
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- git diff --check

Refs: PLAN.md
This commit is contained in:
2026-05-21 22:03:15 +02:00
parent e533131c74
commit 325e5651a2
5 changed files with 92 additions and 10 deletions
+22 -1
View File
@@ -26,7 +26,9 @@ use lanparty_ctrl::{
encode_control_message,
};
use lanparty_obs::{QuicDiagnostics, TunnelStats};
use lanparty_proto::{EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram};
use lanparty_proto::{
EthernetFrame, FrameType, MacAddr, decode_datagram, encode_datagram, validate_datagram_budget,
};
use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer;
@@ -266,6 +268,7 @@ impl ClientSession {
ClientRelayIo::new(
self.connection.clone(),
self.welcome.clone(),
self.quic_max_datagram_size,
Arc::clone(&self.stats),
)
}
@@ -305,6 +308,7 @@ impl ClientSession {
pub struct ClientRelayIo {
connection: quinn::Connection,
welcome: ServerWelcome,
quic_max_datagram_size: u16,
stats: Arc<ClientTunnelStats>,
}
@@ -313,11 +317,13 @@ impl ClientRelayIo {
fn new(
connection: quinn::Connection,
welcome: ServerWelcome,
quic_max_datagram_size: u16,
stats: Arc<ClientTunnelStats>,
) -> Self {
Self {
connection,
welcome,
quic_max_datagram_size,
stats,
}
}
@@ -327,6 +333,11 @@ impl ClientRelayIo {
&self.welcome
}
#[must_use]
pub const fn quic_max_datagram_size(&self) -> u16 {
self.quic_max_datagram_size
}
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
let ethernet_frame = match EthernetFrame::parse(frame) {
Ok(frame) => frame,
@@ -343,6 +354,12 @@ impl ClientRelayIo {
frame,
)
.context("failed to encode client Ethernet datagram")?;
if let Err(error) =
validate_datagram_budget(datagram.len(), usize::from(self.quic_max_datagram_size))
{
self.stats.record_dropped_frame();
return Err(error).context("client Ethernet datagram exceeds negotiated QUIC budget");
}
self.connection
.send_datagram(Bytes::from(datagram))
@@ -799,6 +816,10 @@ mod tests {
);
let relay_io = client.relay_io();
assert_eq!(relay_io.welcome().peer_id(), 2);
assert_eq!(
relay_io.quic_max_datagram_size(),
client.quic_max_datagram_size()
);
relay_io
.send_ethernet(&ethernet_frame(b"to relay"))
+31 -3
View File
@@ -33,7 +33,7 @@ use lanparty_obs::TunnelStats;
use lanparty_obs::{FrameAction, FrameDirection, FrameLog};
use lanparty_proto::{
EthernetFrame, FrameType, MAX_STANDARD_ETHERNET_FRAME_LEN, MacAddr, decode_datagram,
encode_datagram,
encode_datagram, validate_datagram_budget,
};
use quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
use rustls::pki_types::CertificateDer;
@@ -196,6 +196,7 @@ pub struct GatewayConnection {
connection: quinn::Connection,
config: GatewayConfig,
welcome: ServerWelcome,
quic_max_datagram_size: u16,
stats: Arc<GatewayTunnelStats>,
}
@@ -228,8 +229,19 @@ impl GatewayConnection {
&self.welcome
}
#[must_use]
pub const fn quic_max_datagram_size(&self) -> u16 {
self.quic_max_datagram_size
}
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
send_gateway_ethernet(&self.connection, &self.welcome, &self.stats, frame)
send_gateway_ethernet(
&self.connection,
&self.welcome,
self.quic_max_datagram_size,
&self.stats,
frame,
)
}
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
@@ -263,6 +275,7 @@ impl GatewayConnection {
endpoint,
connection,
welcome,
quic_max_datagram_size,
stats,
..
} = self;
@@ -292,7 +305,13 @@ impl GatewayConnection {
}
lan_frame = read_lan_ethernet(&packet_socket) => {
let lan_frame = lan_frame?;
send_gateway_ethernet(&connection, &welcome, &stats, &lan_frame)?;
send_gateway_ethernet(
&connection,
&welcome,
quic_max_datagram_size,
&stats,
&lan_frame,
)?;
println!(
"{}",
gateway_frame_log_line(
@@ -363,6 +382,7 @@ impl GatewayConnection {
fn send_gateway_ethernet(
connection: &quinn::Connection,
welcome: &ServerWelcome,
quic_max_datagram_size: u16,
stats: &GatewayTunnelStats,
frame: &[u8],
) -> Result<()> {
@@ -381,6 +401,12 @@ fn send_gateway_ethernet(
frame,
)
.context("failed to encode gateway Ethernet datagram")?;
if let Err(error) =
validate_datagram_budget(datagram.len(), usize::from(quic_max_datagram_size))
{
stats.record_dropped_frame();
return Err(error).context("gateway Ethernet datagram exceeds negotiated QUIC budget");
}
connection
.send_datagram(Bytes::from(datagram))
@@ -747,6 +773,7 @@ pub async fn connect_gateway(config: GatewayConfig) -> Result<GatewayConnection>
connection,
config,
welcome,
quic_max_datagram_size: hello_datagram_size,
stats: Arc::default(),
}),
ControlMessage::Reject(reject) => bail!(
@@ -968,6 +995,7 @@ mod tests {
assert_eq!(gateway.config().interface(), "eth0");
assert_eq!(gateway.welcome().room_id(), 7);
assert_eq!(gateway.welcome().peer_id(), 1);
assert!(gateway.quic_max_datagram_size() <= 1400);
let event = tokio::time::timeout(Duration::from_secs(5), gateway.recv_control_event())
.await
+1 -1
View File
@@ -20,5 +20,5 @@ pub use mtu::{
};
pub use overlay::{
FrameType, OVERLAY_HEADER_LEN, OVERLAY_MAGIC, OVERLAY_VERSION, OverlayHeader, OverlayPacket,
ProtoError, decode_datagram, encode_datagram,
ProtoError, decode_datagram, encode_datagram, validate_datagram_budget,
};
+30
View File
@@ -170,6 +170,8 @@ pub enum ProtoError {
UnknownFrameType(u8),
#[error("payload length {len} exceeds wire maximum {max}")]
PayloadTooLarge { len: usize, max: usize },
#[error("encoded datagram length {len} exceeds negotiated QUIC datagram budget {max}")]
DatagramExceedsBudget { len: usize, max: usize },
#[error("declared payload length {declared} does not match actual length {actual}")]
PayloadLengthMismatch { declared: usize, actual: usize },
#[error("Ethernet frame is too short: got {actual} bytes, need at least {minimum}")]
@@ -190,6 +192,20 @@ pub fn encode_datagram(
Ok(datagram)
}
pub fn validate_datagram_budget(
datagram_len: usize,
max_datagram_size: usize,
) -> Result<(), ProtoError> {
if datagram_len > max_datagram_size {
return Err(ProtoError::DatagramExceedsBudget {
len: datagram_len,
max: max_datagram_size,
});
}
Ok(())
}
pub fn decode_datagram(bytes: &[u8]) -> Result<OverlayPacket<'_>, ProtoError> {
let header = OverlayHeader::decode(bytes)?;
let payload = &bytes[OVERLAY_HEADER_LEN..];
@@ -281,4 +297,18 @@ mod tests {
ProtoError::PayloadTooLarge { .. }
));
}
#[test]
fn rejects_datagrams_over_negotiated_budget() {
let datagram = encode_datagram(FrameType::Ethernet, 1, 2, 0, &[1, 2, 3]).unwrap();
assert!(validate_datagram_budget(datagram.len(), datagram.len()).is_ok());
assert_eq!(
validate_datagram_budget(datagram.len(), datagram.len() - 1).unwrap_err(),
ProtoError::DatagramExceedsBudget {
len: datagram.len(),
max: datagram.len() - 1
}
);
}
}