feat(client): include gateway presence in diagnostics

PLAN.md calls for user-facing diagnostics that can say whether the remote
client is connected to the LAN gateway. Startup already reported the initial
welcome bit, but periodic diagnostics only carried relay reachability and route
pinning.

Add gateway_connected to RelayDiagnostics and seed the client status from the
welcome. The client control-event logger now updates that status when gateway
join and leave events arrive, so later diagnostics reflect relay lifecycle
changes while the tunnel is running.

Test Plan:
- cargo fmt --check
- cargo test -p lanparty-obs -p lanparty-client-win
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
- git diff --check

Refs: PLAN.md
This commit is contained in:
2026-05-21 21:47:53 +02:00
parent 6a18daac3a
commit 9722adbd70
3 changed files with 87 additions and 31 deletions
+7 -7
View File
@@ -202,10 +202,10 @@ does not match the tunnel identity, because an already-initialized Windows TAP
adapter may need to be disabled/enabled or reinstalled before it reloads the
configured `NetworkAddress`.
It prints and reports client diagnostics snapshots with relay reachability,
route-pinning, QUIC datagram budget, TAP status/IP, frame/datagram counters,
and drops. The periodic diagnostics refresh the TAP unicast IP so DHCP results
that arrive after bridging starts become visible in later status lines.
Relay lifecycle events are logged as they arrive, including gateway joins and
peer leaves. The client remembers peer identities from join and catch-up events
so later leave logs can identify a disconnected LAN gateway or client MAC when
that peer was known.
LAN-gateway presence, route-pinning, QUIC datagram budget, TAP status/IP,
frame/datagram counters, and drops. The periodic diagnostics refresh the TAP
unicast IP so DHCP results that arrive after bridging starts become visible in
later status lines. Relay lifecycle events are logged as they arrive, including
gateway joins and peer leaves. The client remembers peer identities from join
and catch-up events so later leave logs can identify a disconnected LAN gateway
or client MAC when that peer was known.
+70 -22
View File
@@ -1,10 +1,10 @@
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::{collections::BTreeMap, fs, net::IpAddr, path::PathBuf};
#[cfg(windows)]
use std::{
sync::{Arc, mpsc},
thread,
time::Duration,
};
use std::{sync::mpsc, thread, time::Duration};
use anyhow::{Context, Result, bail};
use clap::Parser;
@@ -141,6 +141,7 @@ async fn main() -> Result<()> {
#[cfg(windows)]
async fn run_client(session: &ClientSession, relay_route_pin: &PinnedRelayRoute) -> Result<()> {
let relay_status = ClientRelayStatus::from_welcome(session);
let OpenedTapAdapter {
tap,
tap_diagnostics,
@@ -156,6 +157,7 @@ async fn run_client(session: &ClientSession, relay_route_pin: &PinnedRelayRoute)
&client_diagnostics_snapshot(
session,
true,
&relay_status,
current_tap_diagnostics(&tap_diagnostics, tap_interface),
),
)
@@ -166,7 +168,7 @@ async fn run_client(session: &ClientSession, relay_route_pin: &PinnedRelayRoute)
let frame_pump = run_tap_frame_pump(session.relay_io(), tap);
tokio::pin!(frame_pump);
let control_events = run_control_event_log(session);
let control_events = run_control_event_log(session, relay_status.clone());
tokio::pin!(control_events);
let shutdown = tokio::signal::ctrl_c();
tokio::pin!(shutdown);
@@ -199,6 +201,7 @@ async fn run_client(session: &ClientSession, relay_route_pin: &PinnedRelayRoute)
&client_diagnostics_snapshot(
session,
true,
&relay_status,
current_tap_diagnostics(&tap_diagnostics, tap_interface),
),
).await;
@@ -209,15 +212,21 @@ async fn run_client(session: &ClientSession, relay_route_pin: &PinnedRelayRoute)
#[cfg(not(windows))]
async fn run_client(session: &ClientSession) -> Result<()> {
let relay_status = ClientRelayStatus::from_welcome(session);
open_tap_adapter(session);
print_and_report_client_diagnostics(
session,
&client_diagnostics_snapshot(session, false, TapDiagnostics::new(false, None, None, None)),
&client_diagnostics_snapshot(
session,
false,
&relay_status,
TapDiagnostics::new(false, None, None, None),
),
)
.await;
println!("TAP frame pump and route pinning are not wired yet; press Ctrl-C to stop");
let control_events = run_control_event_log(session);
let control_events = run_control_event_log(session, relay_status);
tokio::pin!(control_events);
let shutdown = tokio::signal::ctrl_c();
tokio::pin!(shutdown);
@@ -403,10 +412,11 @@ fn open_configured_tap_adapter(virtual_mac: MacAddr) -> Result<TapAdapter> {
fn client_diagnostics_snapshot(
session: &ClientSession,
route_pinned: bool,
relay_status: &ClientRelayStatus,
tap: TapDiagnostics,
) -> ClientDiagnostics {
ClientDiagnostics::new(
RelayDiagnostics::new(true, route_pinned),
RelayDiagnostics::new(true, route_pinned, relay_status.gateway_connected()),
session.quic_diagnostics(),
tap,
session.stats_snapshot(),
@@ -443,8 +453,9 @@ async fn print_and_report_client_diagnostics(
fn format_client_diagnostics(diagnostics: &ClientDiagnostics) -> String {
let stats = diagnostics.stats();
format!(
"client diagnostics: relay reachable {} route pinned {}; QUIC datagrams {} max {}; TAP found {} MAC {} MTU {} IP {}; frames tx {} rx {} datagrams tx {} rx {} drops {} malformed {}",
"client diagnostics: relay reachable {} gateway connected {} route pinned {}; QUIC datagrams {} max {}; TAP found {} MAC {} MTU {} IP {}; frames tx {} rx {} datagrams tx {} rx {} drops {} malformed {}",
yes_no(diagnostics.relay().reachable()),
yes_no(diagnostics.relay().gateway_connected()),
yes_no(diagnostics.relay().route_pinned()),
yes_no(diagnostics.quic().datagram_supported()),
optional_label(diagnostics.quic().max_datagram_size()),
@@ -469,7 +480,10 @@ fn optional_label<T: std::fmt::Display>(value: Option<T>) -> String {
value.map_or_else(|| "unknown".to_string(), |value| value.to_string())
}
async fn run_control_event_log(session: &ClientSession) -> Result<()> {
async fn run_control_event_log(
session: &ClientSession,
relay_status: ClientRelayStatus,
) -> Result<()> {
let mut formatter = ControlEventFormatter::default();
loop {
@@ -477,13 +491,40 @@ async fn run_control_event_log(session: &ClientSession) -> Result<()> {
.recv_control_event()
.await
.context("failed to receive relay control event")?;
println!("{}", formatter.format(&event));
println!("{}", formatter.format(&event, &relay_status));
}
}
#[cfg(test)]
fn format_control_event(event: &ControlMessage) -> String {
ControlEventFormatter::default().format(event)
let relay_status = ClientRelayStatus::new(false);
ControlEventFormatter::default().format(event, &relay_status)
}
#[derive(Debug, Clone)]
struct ClientRelayStatus {
gateway_connected: Arc<AtomicBool>,
}
impl ClientRelayStatus {
fn new(gateway_connected: bool) -> Self {
Self {
gateway_connected: Arc::new(AtomicBool::new(gateway_connected)),
}
}
fn from_welcome(session: &ClientSession) -> Self {
Self::new(session.welcome().gateway_connected())
}
fn gateway_connected(&self) -> bool {
self.gateway_connected.load(Ordering::Relaxed)
}
fn set_gateway_connected(&self, gateway_connected: bool) {
self.gateway_connected
.store(gateway_connected, Ordering::Relaxed);
}
}
#[derive(Debug, Default)]
@@ -492,10 +533,13 @@ struct ControlEventFormatter {
}
impl ControlEventFormatter {
fn format(&mut self, event: &ControlMessage) -> String {
fn format(&mut self, event: &ControlMessage, relay_status: &ClientRelayStatus) -> String {
match event {
ControlMessage::PeerJoined(peer) => {
self.peers.insert(peer.peer_id(), peer.clone());
if peer.role() == Role::Gateway {
relay_status.set_gateway_connected(true);
}
format_peer_joined(peer)
}
ControlMessage::PeerLeft { peer_id, reason } => {
@@ -505,6 +549,7 @@ impl ControlEventFormatter {
match peer.role() {
Role::Gateway => {
relay_status.set_gateway_connected(false);
format!(
"relay event: LAN gateway disconnected (peer {}, {reason:?})",
peer.peer_id()
@@ -798,7 +843,7 @@ mod tests {
#[test]
fn formats_client_diagnostics_status_line() {
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, true),
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)),
TapDiagnostics::new(
true,
@@ -811,14 +856,14 @@ mod tests {
assert_eq!(
format_client_diagnostics(&diagnostics),
"client diagnostics: relay reachable yes route pinned yes; QUIC datagrams yes max 1400; TAP found yes MAC 02:00:00:00:00:01 MTU 1200 IP 10.73.42.51; frames tx 1 rx 2 datagrams tx 3 rx 4 drops 5 malformed 6"
"client diagnostics: relay reachable yes gateway connected yes route pinned yes; QUIC datagrams yes max 1400; TAP found yes MAC 02:00:00:00:00:01 MTU 1200 IP 10.73.42.51; frames tx 1 rx 2 datagrams tx 3 rx 4 drops 5 malformed 6"
);
}
#[test]
fn formats_missing_client_diagnostics_as_unknown() {
let diagnostics = ClientDiagnostics::new(
RelayDiagnostics::new(true, false),
RelayDiagnostics::new(true, false, false),
QuicDiagnostics::new(false, None),
TapDiagnostics::new(false, None, None, None),
TunnelStats::default(),
@@ -826,7 +871,7 @@ mod tests {
assert_eq!(
format_client_diagnostics(&diagnostics),
"client diagnostics: relay reachable yes route pinned no; QUIC datagrams no max unknown; TAP found no MAC unknown MTU unknown IP unknown; frames tx 0 rx 0 datagrams tx 0 rx 0 drops 0 malformed 0"
"client diagnostics: relay reachable yes gateway connected no route pinned no; QUIC datagrams no max unknown; TAP found no MAC unknown MTU unknown IP unknown; frames tx 0 rx 0 datagrams tx 0 rx 0 drops 0 malformed 0"
);
}
@@ -882,13 +927,15 @@ mod tests {
reason: DisconnectReason::Normal,
};
let mut formatter = ControlEventFormatter::default();
let relay_status = ClientRelayStatus::new(false);
assert_eq!(
formatter.format(&gateway),
formatter.format(&gateway, &relay_status),
"relay event: LAN gateway connected as peer 1"
);
assert!(relay_status.gateway_connected());
assert_eq!(
formatter.format(&client),
formatter.format(&client, &relay_status),
"relay event: client peer 2 joined with MAC 02:00:00:00:00:02"
);
assert_eq!(
@@ -896,13 +943,14 @@ mod tests {
"relay event: peer 3 left (Normal)"
);
assert_eq!(
formatter.format(&client_left),
formatter.format(&client_left, &relay_status),
"relay event: client peer 2 with MAC 02:00:00:00:00:02 left (Normal)"
);
assert_eq!(
formatter.format(&gateway_left),
formatter.format(&gateway_left, &relay_status),
"relay event: LAN gateway disconnected (peer 1, Normal)"
);
assert!(!relay_status.gateway_connected());
}
const fn mac(last_octet: u8) -> MacAddr {
+10 -2
View File
@@ -196,14 +196,16 @@ impl TunnelStats {
pub struct RelayDiagnostics {
reachable: bool,
route_pinned: bool,
gateway_connected: bool,
}
impl RelayDiagnostics {
#[must_use]
pub const fn new(reachable: bool, route_pinned: bool) -> Self {
pub const fn new(reachable: bool, route_pinned: bool, gateway_connected: bool) -> Self {
Self {
reachable,
route_pinned,
gateway_connected,
}
}
@@ -216,6 +218,11 @@ impl RelayDiagnostics {
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)]
@@ -428,7 +435,7 @@ mod tests {
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),
RelayDiagnostics::new(true, true, true),
QuicDiagnostics::new(true, Some(1400)),
TapDiagnostics::new(
true,
@@ -441,6 +448,7 @@ mod tests {
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!(diagnostics.tap().adapter_found());