Files
softlan-vpn/crates/lanparty-obs/src/lib.rs
T
ddidderr bd22a68a6f fix(tunnel): enforce negotiated TAP MTU
The MVP tunnel negotiates an effective TAP MTU and configures the Windows TAP IP
interface to that value, but the forwarding path only rejected frames that were
standard-Ethernet jumbo frames or exceeded the QUIC datagram budget. A frame
could therefore be larger than the negotiated TAP MTU while still fitting inside
the QUIC datagram budget.

Make the TAP-MTU frame limit an explicit shared protocol helper and enforce it
at every data-path boundary: Windows client send/receive, Linux gateway
send/receive, and relay forwarding. Such frames now produce TapMtuExceeded in
logs and counters instead of being forwarded until a later layer drops or
accepts them implicitly.

This keeps the no-fragmentation contract honest: one Ethernet frame still maps
to one QUIC datagram, but only if that frame also fits the room's negotiated TAP
MTU.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-proto tap_mtu
- cargo test -p lanparty-client-core connects_to_relay_control_stream_as_client
- cargo test -p lanparty-gateway connects_to_relay_control_stream_as_gateway
- cargo test -p lanparty-relay drops_frames_above_effective_tap_mtu
- cargo test -p lanparty-relay rate_limits_client_total_bandwidth_after_burst
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- cargo build --release -p lanparty-relay -p lanparty-gateway
- git diff --check
- git diff --cached --check

Refs: MVP no-fragmentation tunnel MTU contract
2026-05-22 07:15:11 +02:00

802 lines
22 KiB
Rust

//! Shared diagnostics and structured logging models.
//!
//! Runtime crates can convert these values into `tracing` fields, JSON logs, or
//! user-facing status lines without each component inventing its own vocabulary.
use std::net::IpAddr;
use lanparty_proto::{EthernetFrame, EthernetSafetyDrop, MacAddr};
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameDirection {
TapToRelay,
RelayToTap,
RemoteToLan,
LanToRemote,
RelayIngress,
RelayEgress,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum FrameAction {
Forwarded,
Dropped,
Filtered,
RateLimited,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DropReason {
Malformed,
JumboFrame,
InvalidSourceMac,
UnauthorizedSourceMac,
DuplicateMac,
ControlPlaneEtherType,
DhcpServerReply,
Ipv6RouterAdvertisement,
Ipv6Fragment,
VlanTaggedFrame,
TapMtuExceeded,
DatagramBudget,
UnknownDestination,
RateLimit,
}
impl From<EthernetSafetyDrop> for DropReason {
fn from(value: EthernetSafetyDrop) -> Self {
match value {
EthernetSafetyDrop::InvalidSourceMac => Self::InvalidSourceMac,
EthernetSafetyDrop::JumboFrame => Self::JumboFrame,
EthernetSafetyDrop::ControlPlaneEtherType => Self::ControlPlaneEtherType,
EthernetSafetyDrop::VlanTaggedFrame => Self::VlanTaggedFrame,
EthernetSafetyDrop::DhcpServerReply => Self::DhcpServerReply,
EthernetSafetyDrop::Ipv6RouterAdvertisement => Self::Ipv6RouterAdvertisement,
EthernetSafetyDrop::Ipv6Fragment => Self::Ipv6Fragment,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct FrameLog {
direction: FrameDirection,
source_mac: Option<MacAddr>,
destination_mac: Option<MacAddr>,
ethertype_or_len: Option<u16>,
frame_len: usize,
peer_id: Option<u32>,
action: FrameAction,
drop_reason: Option<DropReason>,
}
impl FrameLog {
#[must_use]
pub fn from_ethernet(
direction: FrameDirection,
peer_id: Option<u32>,
action: FrameAction,
drop_reason: Option<DropReason>,
frame: EthernetFrame<'_>,
) -> Self {
Self {
direction,
source_mac: Some(frame.source()),
destination_mac: Some(frame.destination()),
ethertype_or_len: Some(frame.ethertype_or_len()),
frame_len: frame.len(),
peer_id,
action,
drop_reason,
}
}
#[must_use]
pub const fn malformed(
direction: FrameDirection,
peer_id: Option<u32>,
frame_len: usize,
) -> Self {
Self {
direction,
source_mac: None,
destination_mac: None,
ethertype_or_len: None,
frame_len,
peer_id,
action: FrameAction::Dropped,
drop_reason: Some(DropReason::Malformed),
}
}
#[must_use]
pub const fn direction(&self) -> FrameDirection {
self.direction
}
#[must_use]
pub const fn source_mac(&self) -> Option<MacAddr> {
self.source_mac
}
#[must_use]
pub const fn destination_mac(&self) -> Option<MacAddr> {
self.destination_mac
}
#[must_use]
pub const fn ethertype_or_len(&self) -> Option<u16> {
self.ethertype_or_len
}
#[must_use]
pub const fn frame_len(&self) -> usize {
self.frame_len
}
#[must_use]
pub const fn peer_id(&self) -> Option<u32> {
self.peer_id
}
#[must_use]
pub const fn action(&self) -> FrameAction {
self.action
}
#[must_use]
pub const fn drop_reason(&self) -> Option<DropReason> {
self.drop_reason
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)]
pub struct TunnelStats {
ethernet_frames_tx: u64,
ethernet_frames_rx: u64,
#[serde(default)]
broadcast_frames_tx: u64,
#[serde(default)]
broadcast_frames_rx: u64,
datagrams_tx: u64,
datagrams_rx: u64,
dropped_frames: u64,
malformed_frames: u64,
}
impl TunnelStats {
#[must_use]
pub const fn new(
ethernet_frames_tx: u64,
ethernet_frames_rx: u64,
datagrams_tx: u64,
datagrams_rx: u64,
dropped_frames: u64,
malformed_frames: u64,
) -> Self {
Self {
ethernet_frames_tx,
ethernet_frames_rx,
broadcast_frames_tx: 0,
broadcast_frames_rx: 0,
datagrams_tx,
datagrams_rx,
dropped_frames,
malformed_frames,
}
}
#[must_use]
pub const fn with_broadcast_frames(
mut self,
broadcast_frames_tx: u64,
broadcast_frames_rx: u64,
) -> Self {
self.broadcast_frames_tx = broadcast_frames_tx;
self.broadcast_frames_rx = broadcast_frames_rx;
self
}
#[must_use]
pub const fn ethernet_frames_tx(&self) -> u64 {
self.ethernet_frames_tx
}
#[must_use]
pub const fn ethernet_frames_rx(&self) -> u64 {
self.ethernet_frames_rx
}
#[must_use]
pub const fn broadcast_frames_tx(&self) -> u64 {
self.broadcast_frames_tx
}
#[must_use]
pub const fn broadcast_frames_rx(&self) -> u64 {
self.broadcast_frames_rx
}
#[must_use]
pub const fn datagrams_tx(&self) -> u64 {
self.datagrams_tx
}
#[must_use]
pub const fn datagrams_rx(&self) -> u64 {
self.datagrams_rx
}
#[must_use]
pub const fn dropped_frames(&self) -> u64 {
self.dropped_frames
}
#[must_use]
pub const fn malformed_frames(&self) -> u64 {
self.malformed_frames
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct RelayDiagnostics {
reachable: bool,
route_pinned: bool,
gateway_connected: bool,
}
impl RelayDiagnostics {
#[must_use]
pub const fn new(reachable: bool, route_pinned: bool, gateway_connected: bool) -> Self {
Self {
reachable,
route_pinned,
gateway_connected,
}
}
#[must_use]
pub const fn reachable(&self) -> bool {
self.reachable
}
#[must_use]
pub const fn route_pinned(&self) -> bool {
self.route_pinned
}
#[must_use]
pub const fn gateway_connected(&self) -> bool {
self.gateway_connected
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct QuicDiagnostics {
datagram_supported: bool,
max_datagram_size: Option<u16>,
#[serde(default)]
relay_rtt_ms: Option<u64>,
}
impl QuicDiagnostics {
#[must_use]
pub const fn new(datagram_supported: bool, max_datagram_size: Option<u16>) -> Self {
Self {
datagram_supported,
max_datagram_size,
relay_rtt_ms: None,
}
}
#[must_use]
pub const fn with_relay_rtt_ms(mut self, relay_rtt_ms: Option<u64>) -> Self {
self.relay_rtt_ms = relay_rtt_ms;
self
}
#[must_use]
pub const fn datagram_supported(&self) -> bool {
self.datagram_supported
}
#[must_use]
pub const fn max_datagram_size(&self) -> Option<u16> {
self.max_datagram_size
}
#[must_use]
pub const fn relay_rtt_ms(&self) -> Option<u64> {
self.relay_rtt_ms
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct TapDiagnostics {
adapter_found: bool,
mac: Option<MacAddr>,
mtu: Option<u16>,
ip: Option<IpAddr>,
}
impl TapDiagnostics {
#[must_use]
pub const fn new(
adapter_found: bool,
mac: Option<MacAddr>,
mtu: Option<u16>,
ip: Option<IpAddr>,
) -> Self {
Self {
adapter_found,
mac,
mtu,
ip,
}
}
#[must_use]
pub const fn adapter_found(&self) -> bool {
self.adapter_found
}
#[must_use]
pub const fn mac(&self) -> Option<MacAddr> {
self.mac
}
#[must_use]
pub const fn mtu(&self) -> Option<u16> {
self.mtu
}
#[must_use]
pub const fn ip(&self) -> Option<IpAddr> {
self.ip
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct ClientDiagnostics {
relay: RelayDiagnostics,
quic: QuicDiagnostics,
tap: TapDiagnostics,
stats: TunnelStats,
}
impl ClientDiagnostics {
#[must_use]
pub const fn new(
relay: RelayDiagnostics,
quic: QuicDiagnostics,
tap: TapDiagnostics,
stats: TunnelStats,
) -> Self {
Self {
relay,
quic,
tap,
stats,
}
}
#[must_use]
pub const fn relay(&self) -> &RelayDiagnostics {
&self.relay
}
#[must_use]
pub const fn quic(&self) -> &QuicDiagnostics {
&self.quic
}
#[must_use]
pub const fn tap(&self) -> &TapDiagnostics {
&self.tap
}
#[must_use]
pub const fn stats(&self) -> &TunnelStats {
&self.stats
}
#[must_use]
pub fn user_diagnostics(&self) -> Vec<UserDiagnostic> {
let mut diagnostics = Vec::new();
diagnostics.push(if self.relay.reachable() {
UserDiagnostic::new(UserDiagnosticLevel::Info, "Connected to relay")
} else {
UserDiagnostic::new(UserDiagnosticLevel::Error, "Relay not reachable")
});
diagnostics.push(if self.relay.gateway_connected() {
UserDiagnostic::new(UserDiagnosticLevel::Info, "Connected to LAN gateway")
} else {
UserDiagnostic::new(UserDiagnosticLevel::Warning, "Waiting for LAN gateway")
});
if !self.relay.route_pinned() {
diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Warning,
"Relay route not pinned",
));
}
if !self.tap.adapter_found() {
diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Error,
"TAP adapter not found",
));
} else if self.tap.ip().is_none() {
diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Warning,
"Waiting for TAP IP",
));
}
if let Some(diagnostic) = tap_ip_user_diagnostic(self.tap.ip()) {
diagnostics.push(diagnostic);
}
if let Some(rtt_ms) = self.quic.relay_rtt_ms() {
diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Info,
format!("Relay RTT: {rtt_ms} ms"),
));
}
match (
self.stats.broadcast_frames_tx() > 0,
self.stats.broadcast_frames_rx() > 0,
) {
(true, true) => diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Info,
"Broadcast traffic flowing",
)),
(true, false) => diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Warning,
"Broadcast sent toward LAN; waiting for LAN broadcast reply",
)),
(false, true) => diagnostics.push(UserDiagnostic::new(
UserDiagnosticLevel::Info,
"LAN broadcast received",
)),
(false, false) => {}
}
diagnostics
}
}
fn tap_ip_user_diagnostic(ip: Option<IpAddr>) -> Option<UserDiagnostic> {
match ip {
Some(IpAddr::V4(ip)) if ip.is_link_local() => Some(UserDiagnostic::new(
UserDiagnosticLevel::Warning,
format!("TAP has link-local IP {ip}; waiting for LAN DHCP"),
)),
Some(IpAddr::V4(ip)) => Some(UserDiagnostic::new(
UserDiagnosticLevel::Info,
format!("DHCP received: {ip}"),
)),
Some(ip) => Some(UserDiagnostic::new(
UserDiagnosticLevel::Info,
format!("TAP IP detected: {ip}"),
)),
None => None,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum UserDiagnosticLevel {
Info,
Warning,
Error,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
pub struct UserDiagnostic {
level: UserDiagnosticLevel,
message: String,
}
impl UserDiagnostic {
#[must_use]
pub fn new(level: UserDiagnosticLevel, message: impl Into<String>) -> Self {
Self {
level,
message: message.into(),
}
}
#[must_use]
pub const fn level(&self) -> UserDiagnosticLevel {
self.level
}
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
#[serde(tag = "type", content = "payload", rename_all = "snake_case")]
pub enum DiagnosticEvent {
Frame(FrameLog),
Client(ClientDiagnostics),
Stats(TunnelStats),
User(UserDiagnostic),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn builds_frame_log_from_ethernet_header() {
let bytes = [
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0x08, 0x06,
];
let frame = EthernetFrame::parse(&bytes).unwrap();
let log = FrameLog::from_ethernet(
FrameDirection::RemoteToLan,
Some(7),
FrameAction::Forwarded,
None,
frame,
);
assert_eq!(log.direction(), FrameDirection::RemoteToLan);
assert_eq!(
log.source_mac(),
Some(MacAddr::new([0x02, 0xaa, 0xbb, 0xcc, 0xdd, 0xee]))
);
assert_eq!(log.destination_mac(), Some(MacAddr::BROADCAST));
assert_eq!(log.ethertype_or_len(), Some(0x0806));
assert_eq!(log.frame_len(), bytes.len());
assert_eq!(log.peer_id(), Some(7));
assert_eq!(log.action(), FrameAction::Forwarded);
assert_eq!(log.drop_reason(), None);
}
#[test]
fn builds_malformed_frame_log_without_header_fields() {
let log = FrameLog::malformed(FrameDirection::RelayIngress, Some(3), 8);
assert_eq!(log.direction(), FrameDirection::RelayIngress);
assert_eq!(log.source_mac(), None);
assert_eq!(log.destination_mac(), None);
assert_eq!(log.ethertype_or_len(), None);
assert_eq!(log.frame_len(), 8);
assert_eq!(log.peer_id(), Some(3));
assert_eq!(log.action(), FrameAction::Dropped);
assert_eq!(log.drop_reason(), Some(DropReason::Malformed));
}
#[test]
fn exposes_client_diagnostics() {
let mac = MacAddr::new([0x02, 1, 2, 3, 4, 5]);
let stats = TunnelStats::new(1, 2, 3, 4, 5, 6).with_broadcast_frames(7, 8);
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)),
TapDiagnostics::new(
true,
Some(mac),
Some(1200),
Some("10.73.42.51".parse().unwrap()),
),
stats,
);
assert!(diagnostics.relay().reachable());
assert!(diagnostics.relay().route_pinned());
assert!(diagnostics.relay().gateway_connected());
assert!(diagnostics.quic().datagram_supported());
assert_eq!(diagnostics.quic().max_datagram_size(), Some(1400));
assert_eq!(diagnostics.quic().relay_rtt_ms(), None);
assert!(diagnostics.tap().adapter_found());
assert_eq!(diagnostics.tap().mac(), Some(mac));
assert_eq!(diagnostics.tap().mtu(), Some(1200));
assert_eq!(diagnostics.tap().ip().unwrap().to_string(), "10.73.42.51");
assert_eq!(diagnostics.stats().broadcast_frames_tx(), 7);
assert_eq!(diagnostics.stats().broadcast_frames_rx(), 8);
assert_eq!(diagnostics.stats().dropped_frames(), 5);
}
#[test]
fn defaults_missing_broadcast_stats_to_zero() {
let stats: TunnelStats = serde_json::from_str(
r#"{
"ethernet_frames_tx": 1,
"ethernet_frames_rx": 2,
"datagrams_tx": 3,
"datagrams_rx": 4,
"dropped_frames": 5,
"malformed_frames": 6
}"#,
)
.unwrap();
assert_eq!(stats.broadcast_frames_tx(), 0);
assert_eq!(stats.broadcast_frames_rx(), 0);
}
#[test]
fn defaults_missing_relay_rtt_to_unknown() {
let diagnostics: QuicDiagnostics = serde_json::from_str(
r#"{
"datagram_supported": true,
"max_datagram_size": 1400
}"#,
)
.unwrap();
assert_eq!(diagnostics.relay_rtt_ms(), None);
}
#[test]
fn derives_user_diagnostics_from_client_snapshot() {
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)).with_relay_rtt_ms(Some(23)),
TapDiagnostics::new(
true,
Some(MacAddr::new([0x02, 1, 2, 3, 4, 5])),
Some(1200),
Some("10.73.42.51".parse().unwrap()),
),
TunnelStats::new(1, 2, 3, 4, 5, 6).with_broadcast_frames(7, 8),
);
let user_diagnostics = diagnostics.user_diagnostics();
let messages: Vec<_> = user_diagnostics
.iter()
.map(UserDiagnostic::message)
.collect();
assert_eq!(
messages,
[
"Connected to relay",
"Connected to LAN gateway",
"DHCP received: 10.73.42.51",
"Relay RTT: 23 ms",
"Broadcast traffic flowing",
]
);
assert!(
user_diagnostics
.iter()
.all(|diagnostic| diagnostic.level() == UserDiagnosticLevel::Info)
);
}
#[test]
fn reports_user_diagnostic_warnings_for_missing_connections() {
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(false, false, false),
QuicDiagnostics::new(false, None),
TapDiagnostics::new(false, None, None, None),
TunnelStats::default(),
);
let user_diagnostics = diagnostics.user_diagnostics();
assert_eq!(
user_diagnostics
.iter()
.map(UserDiagnostic::message)
.collect::<Vec<_>>(),
[
"Relay not reachable",
"Waiting for LAN gateway",
"Relay route not pinned",
"TAP adapter not found",
]
);
assert_eq!(user_diagnostics[0].level(), UserDiagnosticLevel::Error);
assert_eq!(user_diagnostics[1].level(), UserDiagnosticLevel::Warning);
assert_eq!(user_diagnostics[2].level(), UserDiagnosticLevel::Warning);
assert_eq!(user_diagnostics[3].level(), UserDiagnosticLevel::Error);
}
#[test]
fn reports_waiting_for_tap_ip_when_adapter_has_no_address() {
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)),
TapDiagnostics::new(
true,
Some(MacAddr::new([0x02, 1, 2, 3, 4, 5])),
Some(1200),
None,
),
TunnelStats::default(),
);
let user_diagnostics = diagnostics.user_diagnostics();
assert_eq!(
user_diagnostics
.iter()
.map(UserDiagnostic::message)
.collect::<Vec<_>>(),
[
"Connected to relay",
"Connected to LAN gateway",
"Waiting for TAP IP",
]
);
assert_eq!(user_diagnostics[2].level(), UserDiagnosticLevel::Warning);
}
#[test]
fn distinguishes_one_way_broadcast_diagnostics() {
let mut diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)),
TapDiagnostics::new(
true,
Some(MacAddr::new([0x02, 1, 2, 3, 4, 5])),
Some(1200),
None,
),
TunnelStats::default().with_broadcast_frames(1, 0),
);
let broadcast = diagnostics
.user_diagnostics()
.into_iter()
.find(|diagnostic| diagnostic.message().contains("Broadcast"))
.expect("outbound broadcast should produce a diagnostic");
assert_eq!(broadcast.level(), UserDiagnosticLevel::Warning);
assert_eq!(
broadcast.message(),
"Broadcast sent toward LAN; waiting for LAN broadcast reply"
);
diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)),
TapDiagnostics::new(
true,
Some(MacAddr::new([0x02, 1, 2, 3, 4, 5])),
Some(1200),
None,
),
TunnelStats::default().with_broadcast_frames(0, 1),
);
let broadcast = diagnostics
.user_diagnostics()
.into_iter()
.find(|diagnostic| diagnostic.message().contains("broadcast"))
.expect("inbound broadcast should produce a diagnostic");
assert_eq!(broadcast.level(), UserDiagnosticLevel::Info);
assert_eq!(broadcast.message(), "LAN broadcast received");
}
#[test]
fn reports_link_local_tap_ipv4_as_waiting_for_lan_dhcp() {
let diagnostic = tap_ip_user_diagnostic(Some("169.254.10.20".parse().unwrap())).unwrap();
assert_eq!(diagnostic.level(), UserDiagnosticLevel::Warning);
assert_eq!(
diagnostic.message(),
"TAP has link-local IP 169.254.10.20; waiting for LAN DHCP"
);
}
#[test]
fn reports_ipv6_link_local_tap_ip_without_calling_it_dhcp() {
let diagnostic = tap_ip_user_diagnostic(Some("fe80::1".parse().unwrap())).unwrap();
assert_eq!(diagnostic.level(), UserDiagnosticLevel::Info);
assert_eq!(diagnostic.message(), "TAP IP detected: fe80::1");
}
}