//! 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 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, destination_mac: Option, ethertype_or_len: Option, frame_len: usize, peer_id: Option, action: FrameAction, drop_reason: Option, } impl FrameLog { #[must_use] pub fn from_ethernet( direction: FrameDirection, peer_id: Option, action: FrameAction, drop_reason: Option, 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, 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 { self.source_mac } #[must_use] pub const fn destination_mac(&self) -> Option { self.destination_mac } #[must_use] pub const fn ethertype_or_len(&self) -> Option { 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 { self.peer_id } #[must_use] pub const fn action(&self) -> FrameAction { self.action } #[must_use] pub const fn drop_reason(&self) -> Option { 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, #[serde(default)] relay_rtt_ms: Option, } impl QuicDiagnostics { #[must_use] pub const fn new(datagram_supported: bool, max_datagram_size: Option) -> 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) -> 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 { self.max_datagram_size } #[must_use] pub const fn relay_rtt_ms(&self) -> Option { self.relay_rtt_ms } } #[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] pub struct TapDiagnostics { adapter_found: bool, mac: Option, mtu: Option, ip: Option, } impl TapDiagnostics { #[must_use] pub const fn new( adapter_found: bool, mac: Option, mtu: Option, ip: Option, ) -> 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 { self.mac } #[must_use] pub const fn mtu(&self) -> Option { self.mtu } #[must_use] pub const fn ip(&self) -> Option { 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 { 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) -> Option { 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) -> 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::>(), [ "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::>(), [ "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"); } }