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:
@@ -20,6 +20,7 @@ Monorepo for a Layer 2 over QUIC LAN party bridge.
|
|||||||
Transport-agnostic tunnel contract shared by all binaries:
|
Transport-agnostic tunnel contract shared by all binaries:
|
||||||
|
|
||||||
- overlay datagram header encoding and decoding
|
- overlay datagram header encoding and decoding
|
||||||
|
- negotiated QUIC datagram budget validation before send
|
||||||
- Ethernet frame header parsing
|
- Ethernet frame header parsing
|
||||||
- MAC address parsing and identity validation
|
- MAC address parsing and identity validation
|
||||||
- QUIC datagram to TAP MTU budget helpers
|
- QUIC datagram to TAP MTU budget helpers
|
||||||
@@ -59,7 +60,7 @@ Platform-neutral remote client relay session:
|
|||||||
- welcome/reject handling with assigned peer id and effective TAP MTU
|
- welcome/reject handling with assigned peer id and effective TAP MTU
|
||||||
- QUIC DATAGRAM support and negotiated datagram budget diagnostics
|
- QUIC DATAGRAM support and negotiated datagram budget diagnostics
|
||||||
- reliable relay control-event reads for peer lifecycle messages
|
- reliable relay control-event reads for peer lifecycle messages
|
||||||
- Ethernet frame send/receive helpers over QUIC DATAGRAM
|
- Ethernet frame send/receive helpers over QUIC DATAGRAM with budget checks
|
||||||
- client tunnel statistics for frame/datagram rx/tx and drops
|
- client tunnel statistics for frame/datagram rx/tx and drops
|
||||||
- reliable client stats snapshot sends for relay diagnostics
|
- reliable client stats snapshot sends for relay diagnostics
|
||||||
- best-effort graceful disconnect messages before QUIC close
|
- best-effort graceful disconnect messages before QUIC close
|
||||||
@@ -158,10 +159,12 @@ cargo run -p lanparty-gateway -- \
|
|||||||
The gateway connects to the relay as `role = gateway`, completes the
|
The gateway 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 with promiscuous packet membership, and bridges Ethernet frames
|
interface with promiscuous packet membership, and bridges Ethernet frames
|
||||||
between the relay and wired LAN until shutdown. `--relay` accepts a DNS name or
|
between the relay and wired LAN until shutdown. Sends fail before fragmentation
|
||||||
socket address; bare hosts default to UDP/443. It tracks remote-client source
|
when an encoded Ethernet datagram exceeds the negotiated QUIC datagram budget.
|
||||||
MACs seen from relay traffic and periodically emits small CAM refresh frames so
|
`--relay` accepts a DNS name or socket address; bare hosts default to UDP/443.
|
||||||
the physical switch keeps those MACs associated with the gateway port. Gateway
|
It tracks remote-client source MACs seen from relay traffic and periodically
|
||||||
|
emits small CAM refresh frames so the physical switch keeps those MACs
|
||||||
|
associated with the gateway port. Gateway
|
||||||
frame logs include direction, peer id when present, MACs, ethertype/length,
|
frame logs include direction, peer id when present, MACs, ethertype/length,
|
||||||
frame length, action, and drop reason. The gateway also tracks frame/datagram
|
frame length, action, and drop reason. The gateway also tracks frame/datagram
|
||||||
counters and periodically sends stats snapshots to the relay. Relay lifecycle
|
counters and periodically sends stats snapshots to the relay. Relay lifecycle
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ use lanparty_ctrl::{
|
|||||||
encode_control_message,
|
encode_control_message,
|
||||||
};
|
};
|
||||||
use lanparty_obs::{QuicDiagnostics, TunnelStats};
|
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 quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
|
||||||
use rustls::pki_types::CertificateDer;
|
use rustls::pki_types::CertificateDer;
|
||||||
|
|
||||||
@@ -266,6 +268,7 @@ impl ClientSession {
|
|||||||
ClientRelayIo::new(
|
ClientRelayIo::new(
|
||||||
self.connection.clone(),
|
self.connection.clone(),
|
||||||
self.welcome.clone(),
|
self.welcome.clone(),
|
||||||
|
self.quic_max_datagram_size,
|
||||||
Arc::clone(&self.stats),
|
Arc::clone(&self.stats),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -305,6 +308,7 @@ impl ClientSession {
|
|||||||
pub struct ClientRelayIo {
|
pub struct ClientRelayIo {
|
||||||
connection: quinn::Connection,
|
connection: quinn::Connection,
|
||||||
welcome: ServerWelcome,
|
welcome: ServerWelcome,
|
||||||
|
quic_max_datagram_size: u16,
|
||||||
stats: Arc<ClientTunnelStats>,
|
stats: Arc<ClientTunnelStats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,11 +317,13 @@ impl ClientRelayIo {
|
|||||||
fn new(
|
fn new(
|
||||||
connection: quinn::Connection,
|
connection: quinn::Connection,
|
||||||
welcome: ServerWelcome,
|
welcome: ServerWelcome,
|
||||||
|
quic_max_datagram_size: u16,
|
||||||
stats: Arc<ClientTunnelStats>,
|
stats: Arc<ClientTunnelStats>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
connection,
|
connection,
|
||||||
welcome,
|
welcome,
|
||||||
|
quic_max_datagram_size,
|
||||||
stats,
|
stats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,6 +333,11 @@ impl ClientRelayIo {
|
|||||||
&self.welcome
|
&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<()> {
|
pub fn send_ethernet(&self, frame: &[u8]) -> Result<()> {
|
||||||
let ethernet_frame = match EthernetFrame::parse(frame) {
|
let ethernet_frame = match EthernetFrame::parse(frame) {
|
||||||
Ok(frame) => frame,
|
Ok(frame) => frame,
|
||||||
@@ -343,6 +354,12 @@ impl ClientRelayIo {
|
|||||||
frame,
|
frame,
|
||||||
)
|
)
|
||||||
.context("failed to encode client Ethernet datagram")?;
|
.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
|
self.connection
|
||||||
.send_datagram(Bytes::from(datagram))
|
.send_datagram(Bytes::from(datagram))
|
||||||
@@ -799,6 +816,10 @@ mod tests {
|
|||||||
);
|
);
|
||||||
let relay_io = client.relay_io();
|
let relay_io = client.relay_io();
|
||||||
assert_eq!(relay_io.welcome().peer_id(), 2);
|
assert_eq!(relay_io.welcome().peer_id(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
relay_io.quic_max_datagram_size(),
|
||||||
|
client.quic_max_datagram_size()
|
||||||
|
);
|
||||||
|
|
||||||
relay_io
|
relay_io
|
||||||
.send_ethernet(ðernet_frame(b"to relay"))
|
.send_ethernet(ðernet_frame(b"to relay"))
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ use lanparty_obs::TunnelStats;
|
|||||||
use lanparty_obs::{FrameAction, FrameDirection, FrameLog};
|
use lanparty_obs::{FrameAction, FrameDirection, FrameLog};
|
||||||
use lanparty_proto::{
|
use lanparty_proto::{
|
||||||
EthernetFrame, FrameType, MAX_STANDARD_ETHERNET_FRAME_LEN, MacAddr, decode_datagram,
|
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 quinn::{ClientConfig, Endpoint, crypto::rustls::QuicClientConfig};
|
||||||
use rustls::pki_types::CertificateDer;
|
use rustls::pki_types::CertificateDer;
|
||||||
@@ -196,6 +196,7 @@ pub struct GatewayConnection {
|
|||||||
connection: quinn::Connection,
|
connection: quinn::Connection,
|
||||||
config: GatewayConfig,
|
config: GatewayConfig,
|
||||||
welcome: ServerWelcome,
|
welcome: ServerWelcome,
|
||||||
|
quic_max_datagram_size: u16,
|
||||||
stats: Arc<GatewayTunnelStats>,
|
stats: Arc<GatewayTunnelStats>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,8 +229,19 @@ impl GatewayConnection {
|
|||||||
&self.welcome
|
&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<()> {
|
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> {
|
pub async fn recv_ethernet(&self) -> Result<ReceivedEthernetFrame> {
|
||||||
@@ -263,6 +275,7 @@ impl GatewayConnection {
|
|||||||
endpoint,
|
endpoint,
|
||||||
connection,
|
connection,
|
||||||
welcome,
|
welcome,
|
||||||
|
quic_max_datagram_size,
|
||||||
stats,
|
stats,
|
||||||
..
|
..
|
||||||
} = self;
|
} = self;
|
||||||
@@ -292,7 +305,13 @@ impl GatewayConnection {
|
|||||||
}
|
}
|
||||||
lan_frame = read_lan_ethernet(&packet_socket) => {
|
lan_frame = read_lan_ethernet(&packet_socket) => {
|
||||||
let lan_frame = lan_frame?;
|
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!(
|
println!(
|
||||||
"{}",
|
"{}",
|
||||||
gateway_frame_log_line(
|
gateway_frame_log_line(
|
||||||
@@ -363,6 +382,7 @@ impl GatewayConnection {
|
|||||||
fn send_gateway_ethernet(
|
fn send_gateway_ethernet(
|
||||||
connection: &quinn::Connection,
|
connection: &quinn::Connection,
|
||||||
welcome: &ServerWelcome,
|
welcome: &ServerWelcome,
|
||||||
|
quic_max_datagram_size: u16,
|
||||||
stats: &GatewayTunnelStats,
|
stats: &GatewayTunnelStats,
|
||||||
frame: &[u8],
|
frame: &[u8],
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
@@ -381,6 +401,12 @@ fn send_gateway_ethernet(
|
|||||||
frame,
|
frame,
|
||||||
)
|
)
|
||||||
.context("failed to encode gateway Ethernet datagram")?;
|
.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
|
connection
|
||||||
.send_datagram(Bytes::from(datagram))
|
.send_datagram(Bytes::from(datagram))
|
||||||
@@ -747,6 +773,7 @@ pub async fn connect_gateway(config: GatewayConfig) -> Result<GatewayConnection>
|
|||||||
connection,
|
connection,
|
||||||
config,
|
config,
|
||||||
welcome,
|
welcome,
|
||||||
|
quic_max_datagram_size: hello_datagram_size,
|
||||||
stats: Arc::default(),
|
stats: Arc::default(),
|
||||||
}),
|
}),
|
||||||
ControlMessage::Reject(reject) => bail!(
|
ControlMessage::Reject(reject) => bail!(
|
||||||
@@ -968,6 +995,7 @@ mod tests {
|
|||||||
assert_eq!(gateway.config().interface(), "eth0");
|
assert_eq!(gateway.config().interface(), "eth0");
|
||||||
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);
|
||||||
|
assert!(gateway.quic_max_datagram_size() <= 1400);
|
||||||
|
|
||||||
let event = tokio::time::timeout(Duration::from_secs(5), gateway.recv_control_event())
|
let event = tokio::time::timeout(Duration::from_secs(5), gateway.recv_control_event())
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -20,5 +20,5 @@ pub use mtu::{
|
|||||||
};
|
};
|
||||||
pub use overlay::{
|
pub use overlay::{
|
||||||
FrameType, OVERLAY_HEADER_LEN, OVERLAY_MAGIC, OVERLAY_VERSION, OverlayHeader, OverlayPacket,
|
FrameType, OVERLAY_HEADER_LEN, OVERLAY_MAGIC, OVERLAY_VERSION, OverlayHeader, OverlayPacket,
|
||||||
ProtoError, decode_datagram, encode_datagram,
|
ProtoError, decode_datagram, encode_datagram, validate_datagram_budget,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -170,6 +170,8 @@ pub enum ProtoError {
|
|||||||
UnknownFrameType(u8),
|
UnknownFrameType(u8),
|
||||||
#[error("payload length {len} exceeds wire maximum {max}")]
|
#[error("payload length {len} exceeds wire maximum {max}")]
|
||||||
PayloadTooLarge { len: usize, max: usize },
|
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}")]
|
#[error("declared payload length {declared} does not match actual length {actual}")]
|
||||||
PayloadLengthMismatch { declared: usize, actual: usize },
|
PayloadLengthMismatch { declared: usize, actual: usize },
|
||||||
#[error("Ethernet frame is too short: got {actual} bytes, need at least {minimum}")]
|
#[error("Ethernet frame is too short: got {actual} bytes, need at least {minimum}")]
|
||||||
@@ -190,6 +192,20 @@ pub fn encode_datagram(
|
|||||||
Ok(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> {
|
pub fn decode_datagram(bytes: &[u8]) -> Result<OverlayPacket<'_>, ProtoError> {
|
||||||
let header = OverlayHeader::decode(bytes)?;
|
let header = OverlayHeader::decode(bytes)?;
|
||||||
let payload = &bytes[OVERLAY_HEADER_LEN..];
|
let payload = &bytes[OVERLAY_HEADER_LEN..];
|
||||||
@@ -281,4 +297,18 @@ mod tests {
|
|||||||
ProtoError::PayloadTooLarge { .. }
|
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
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user