From 7aeaa0aeb989952acecca92e542c1d826f206691 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Thu, 21 May 2026 17:17:44 +0200 Subject: [PATCH] feat(obs): add shared diagnostics models Add the observability vocabulary needed for phase-1 frame logging and client status reporting. Runtime crates can now emit structured events without each binary inventing separate field names for the same tunnel state. The new models cover frame direction, action, drop reason, parsed Ethernet frame logs, malformed frame logs, tunnel counters, relay/QUIC/TAP client diagnostics, and user-facing diagnostic messages. TunnelStats now lives in lanparty-obs and is re-exported by lanparty-ctrl so stats remain one shared type whether they are logged locally or carried over the control stream. This still does not add logging sinks or tracing integration; those should be wired in when the relay, gateway, and client loops exist. Test Plan: - cargo fmt --check - cargo test --workspace - cargo clippy --workspace --all-targets -- -D warnings Refs: PLAN.md Logging / diagnostics --- Cargo.lock | 5 + README.md | 8 + crates/lanparty-ctrl/Cargo.toml | 1 + crates/lanparty-ctrl/src/lib.rs | 62 +---- crates/lanparty-obs/Cargo.toml | 2 + crates/lanparty-obs/src/lib.rs | 448 +++++++++++++++++++++++++++++++- 6 files changed, 460 insertions(+), 66 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5f37d1f..399edc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,7 @@ version = "0.1.0" name = "lanparty-ctrl" version = "0.1.0" dependencies = [ + "lanparty-obs", "lanparty-proto", "serde", "thiserror", @@ -26,6 +27,10 @@ version = "0.1.0" [[package]] name = "lanparty-obs" version = "0.1.0" +dependencies = [ + "lanparty-proto", + "serde", +] [[package]] name = "lanparty-proto" diff --git a/README.md b/README.md index 7a1ecec..04e404e 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,14 @@ Reliable control-plane schema shared by the QUIC stream handlers: - server welcome, reject, peer lifecycle, stats, and disconnect messages - room-code, role/MAC, peer-id, and effective-MTU validation +### `lanparty-obs` + +Shared diagnostics and structured logging vocabulary: + +- gateway/relay frame logs with MACs, ethertype, length, peer, and action +- tunnel counters shared by control messages and runtime diagnostics +- client connectivity/TAP diagnostics and user-facing status messages + ## Build ```bash diff --git a/crates/lanparty-ctrl/Cargo.toml b/crates/lanparty-ctrl/Cargo.toml index 9ddbae6..0ef8b01 100644 --- a/crates/lanparty-ctrl/Cargo.toml +++ b/crates/lanparty-ctrl/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] +lanparty-obs = { path = "../lanparty-obs" } lanparty-proto = { path = "../lanparty-proto" } serde.workspace = true thiserror.workspace = true diff --git a/crates/lanparty-ctrl/src/lib.rs b/crates/lanparty-ctrl/src/lib.rs index 14684a0..32b0a02 100644 --- a/crates/lanparty-ctrl/src/lib.rs +++ b/crates/lanparty-ctrl/src/lib.rs @@ -6,6 +6,7 @@ use std::{fmt, str::FromStr}; +pub use lanparty_obs::TunnelStats; use lanparty_proto::{MIN_USEFUL_TAP_MTU, MacAddr, MtuError, recommended_tap_mtu}; use thiserror::Error; @@ -357,67 +358,6 @@ impl Reject { } } -#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Deserialize, serde::Serialize)] -pub struct TunnelStats { - ethernet_frames_tx: u64, - ethernet_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, - datagrams_tx, - datagrams_rx, - dropped_frames, - malformed_frames, - } - } - - #[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 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)] #[serde(tag = "type", content = "payload", rename_all = "snake_case")] pub enum ControlMessage { diff --git a/crates/lanparty-obs/Cargo.toml b/crates/lanparty-obs/Cargo.toml index d47bd59..c7ca86d 100644 --- a/crates/lanparty-obs/Cargo.toml +++ b/crates/lanparty-obs/Cargo.toml @@ -4,3 +4,5 @@ version.workspace = true edition.workspace = true [dependencies] +lanparty-proto = { path = "../lanparty-proto" } +serde.workspace = true diff --git a/crates/lanparty-obs/src/lib.rs b/crates/lanparty-obs/src/lib.rs index b93cf3f..b3740a0 100644 --- a/crates/lanparty-obs/src/lib.rs +++ b/crates/lanparty-obs/src/lib.rs @@ -1,5 +1,380 @@ -pub fn add(left: u64, right: u64) -> u64 { - left + right +//! 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, MacAddr}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FrameDirection { + 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, + UnauthorizedSourceMac, + DuplicateMac, + ControlPlaneEtherType, + DhcpServerReply, + Ipv6RouterAdvertisement, + UnknownDestination, + RateLimit, +} + +#[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, + 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, + datagrams_tx, + datagrams_rx, + dropped_frames, + malformed_frames, + } + } + + #[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 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, +} + +impl RelayDiagnostics { + #[must_use] + pub const fn new(reachable: bool, route_pinned: bool) -> Self { + Self { + reachable, + route_pinned, + } + } + + #[must_use] + pub const fn reachable(&self) -> bool { + self.reachable + } + + #[must_use] + pub const fn route_pinned(&self) -> bool { + self.route_pinned + } +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, serde::Serialize)] +pub struct QuicDiagnostics { + datagram_supported: bool, + max_datagram_size: Option, +} + +impl QuicDiagnostics { + #[must_use] + pub const fn new(datagram_supported: bool, max_datagram_size: Option) -> Self { + Self { + datagram_supported, + max_datagram_size, + } + } + + #[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 + } +} + +#[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 + } +} + +#[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)] @@ -7,8 +382,71 @@ mod tests { use super::*; #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); + 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); + let diagnostics = ClientDiagnostics::new( + RelayDiagnostics::new(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.quic().datagram_supported()); + assert_eq!(diagnostics.quic().max_datagram_size(), Some(1400)); + 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().dropped_frames(), 5); } }