mcpmesh_node/diag.rs
1//! Node-level conditions that are observable but have no API to observe them through (#134).
2//!
3//! **The duplicate-identity condition.** Two nodes booted from COPIES of one mesh root present the
4//! same endpoint id. The relay notices — it can only serve one — and tells the displaced client
5//! `Status::SameEndpointIdConnected`. From the node's own point of view its peers simply stop being
6//! reachable, with nothing saying why; diagnosing it cost a downstream real time.
7//!
8//! The local guards cannot see it: a second node on the SAME root is stopped by the redb lock, but
9//! a node on a COPY is a different file, at a different path, possibly on another machine. The only
10//! party that observes the collision is the network layer.
11//!
12//! **iroh 1.0.3 offers no API for it.** The signal arrives, and
13//! `socket/transports/relay/actor.rs` handles it with `warn!("Relay server reports problem: {status}")`
14//! — no event, no `Endpoint` state, no watcher. A `tracing` event is the only channel there is.
15//!
16//! **So this is a `Layer`, not a subscriber.** A `tracing` subscriber is global and an embedded
17//! `mcpmesh-node` does not own it — the host application does. Installing our own would either fail
18//! or fight theirs. [`IdentityConflictLayer`] is composed into whatever subscriber the host already
19//! runs; the standalone daemon installs it itself at boot.
20//!
21//! **On matching a log message.** It is brittle by nature, so the needle is not written down here:
22//! it is built from [`Status::SameEndpointIdConnected`]'s own `Display`, the same value iroh
23//! formats into that `warn!`. An upstream rewording changes both sides together, and the test that
24//! pins the two against each other fails if the shape of the coupling changes rather than the
25//! wording.
26use std::sync::Arc;
27use std::sync::atomic::{AtomicI64, Ordering};
28
29use iroh_relay::protos::relay::Status;
30use tracing_subscriber::Layer;
31use tracing_subscriber::layer::Context;
32
33/// The observed duplicate-identity condition (#134): when the relay last told us another endpoint
34/// is presenting our identity.
35///
36/// Sticky by design. The condition is not a state the relay keeps reporting — it is announced once,
37/// as the displaced connection is dropped — so a flag that cleared itself would be blank by the
38/// time anyone read `status`. It records the LAST observation and lets the reader judge staleness
39/// from the epoch, exactly as `last_change_epoch` does.
40///
41/// **ADVISORY, and relay-attested rather than authenticated.** The claim originates at a relay, and
42/// iroh's older `Health { problem }` frame carries arbitrary text through the same log line, so any
43/// relay in this node's set can synthesize this condition. It is a DIAGNOSTIC — it must never gate
44/// authorization, refuse a peer, or stop a node. The worst a hostile relay achieves is a misleading
45/// status field, which is the same class of trust already extended to a relay for reachability.
46#[derive(Debug, Default)]
47pub struct IdentityConflict {
48 /// Epoch seconds of the last observation, or 0 for "never seen".
49 last_seen: AtomicI64,
50}
51
52impl IdentityConflict {
53 /// When the condition was last observed, or `None` if never.
54 pub fn last_seen_epoch(&self) -> Option<i64> {
55 match self.last_seen.load(Ordering::Relaxed) {
56 0 => None,
57 t => Some(t),
58 }
59 }
60
61 /// Record an observation at `epoch`. Idempotent-ish: a later observation replaces an earlier
62 /// one, so the reported time is the most recent, not the first.
63 pub fn observe(&self, epoch: i64) {
64 self.last_seen.store(epoch, Ordering::Relaxed);
65 }
66}
67
68/// The message iroh's relay actor logs for the duplicate-identity condition, as a needle.
69///
70/// Built from iroh's own type rather than transcribed: `Status` derives `Display`, and the relay
71/// actor formats exactly that value into its `warn!`. So this needle and iroh's message cannot
72/// drift apart through a rewording — only through a change in HOW iroh logs it, which is what the
73/// test pins.
74fn needle() -> String {
75 Status::SameEndpointIdConnected.to_string()
76}
77
78/// A [`tracing`] layer that watches for the relay's duplicate-identity report and records it.
79///
80/// Install it into the subscriber your application already owns:
81///
82/// ```ignore
83/// use tracing_subscriber::prelude::*;
84/// let conflict = std::sync::Arc::new(mcpmesh_node::diag::IdentityConflict::default());
85/// tracing_subscriber::registry()
86/// .with(your_fmt_layer)
87/// .with(mcpmesh_node::diag::IdentityConflictLayer::new(conflict.clone()))
88/// .init();
89/// ```
90///
91/// It never filters another layer's events and never suppresses anyone's output. It does re-emit
92/// one `error!` under mcpmesh's own target when it fires, because iroh's line says only that "a
93/// relay reported a problem" — it does not say that THIS node's identity is in use elsewhere,
94/// which is the fact an operator needs. (Under a scoped subscriber that nested event is dropped by
95/// tracing's re-entrancy guard; under a global one it is emitted. The recording happens either
96/// way, and the recording is what `status` reads.)
97pub struct IdentityConflictLayer {
98 state: Arc<IdentityConflict>,
99 needle: String,
100}
101
102impl IdentityConflictLayer {
103 pub fn new(state: Arc<IdentityConflict>) -> Self {
104 Self {
105 state,
106 needle: needle(),
107 }
108 }
109}
110
111/// Pulls the `message` field out of an event without allocating for the other fields.
112struct MessageVisitor<'a> {
113 needle: &'a str,
114 hit: bool,
115}
116
117impl tracing::field::Visit for MessageVisitor<'_> {
118 fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
119 if field.name() == "message" && format!("{value:?}").contains(self.needle) {
120 self.hit = true;
121 }
122 }
123
124 fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
125 if field.name() == "message" && value.contains(self.needle) {
126 self.hit = true;
127 }
128 }
129}
130
131impl<S: tracing::Subscriber> Layer<S> for IdentityConflictLayer {
132 fn on_event(&self, event: &tracing::Event<'_>, _ctx: Context<'_, S>) {
133 // Cheap gate first: the condition is reported at WARN by iroh, and the overwhelming
134 // majority of events in a busy host are not. Only then pay for the field visit.
135 if *event.metadata().level() > tracing::Level::WARN {
136 return;
137 }
138 let mut v = MessageVisitor {
139 needle: &self.needle,
140 hit: false,
141 };
142 event.record(&mut v);
143 if v.hit {
144 self.state.observe(crate::util::epoch_now_i64());
145 // Re-emit at ERROR under OUR target, naming the condition and the remedy. iroh's own
146 // line says a relay reported a problem; it does not say that this node's identity is
147 // in use elsewhere, which is the fact an operator needs.
148 tracing::error!(
149 "another endpoint is presenting THIS node's identity — two nodes booted from \
150 copies of one mesh root cannot both use the relay, and peers will appear \
151 unreachable. Stop the duplicate, or give it its own identity."
152 );
153 }
154 }
155}
156
157/// Install [`IdentityConflictLayer`] as the process-global subscriber. **Standalone daemon only.**
158///
159/// A `tracing` subscriber is process-global and can be set once. `serve_forever` owns its process
160/// and installs none of its own, so taking it there is safe. It must NOT be called from any path an
161/// embedded node shares: seizing the global would panic a host that calls `fmt::init()` afterwards,
162/// and silently swallow its logs for the process lifetime if it uses `try_init()`.
163///
164/// The layer is filtered to `WARN`, which matters for more than tidiness: `tracing`'s max-level
165/// hint is derived from the installed subscriber, and a bare registry sets it to `TRACE` — turning
166/// every `trace!` and `trace_span!` in iroh, quinn and tokio from a compiled-out no-op into an
167/// allocation on the datagram hot path, for a daemon that prints nothing anyway.
168///
169/// Best effort: if a subscriber already exists we leave it alone and say so at `debug!`.
170pub fn install_for_daemon(state: Arc<IdentityConflict>) {
171 use tracing_subscriber::filter::LevelFilter;
172 use tracing_subscriber::layer::SubscriberExt;
173
174 let layer = IdentityConflictLayer::new(state).with_filter(LevelFilter::WARN);
175 if tracing::subscriber::set_global_default(tracing_subscriber::registry().with(layer)).is_err()
176 {
177 tracing::debug!(
178 "a tracing subscriber is already installed, so the duplicate-identity detector was \
179 NOT registered (#134)"
180 );
181 }
182}
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187 use tracing_subscriber::prelude::*;
188
189 /// #134: the needle really is what iroh logs.
190 ///
191 /// The coupling, not the wording, is the thing under test — the needle is DERIVED from
192 /// `Status`'s `Display`, so a rewording moves both together. What this pins is that the
193 /// derivation still produces a non-trivial, identity-specific string: if iroh ever made
194 /// `Status` display as something generic (or empty), the layer would start matching unrelated
195 /// warnings, or nothing at all, and silently stop being a diagnostic.
196 #[test]
197 fn the_needle_is_derived_from_iroh_and_is_specific() {
198 let n = needle();
199 assert!(
200 n.len() > 20,
201 "too short to be a safe substring match — it would match unrelated warnings: {n:?}"
202 );
203 assert!(
204 n.to_lowercase().contains("endpoint id"),
205 "the needle must still be about endpoint identity; if iroh made this generic the \
206 layer is no longer a diagnostic: {n:?}"
207 );
208 assert_ne!(
209 n,
210 Status::Healthy.to_string(),
211 "the needle must not collapse onto the healthy status"
212 );
213 }
214
215 /// #134: the layer records the condition, and does NOT record on unrelated warnings.
216 ///
217 /// The second half is the one that matters — a detector that fires on any warning would
218 /// report a duplicate identity on a node that merely lost a relay, which is worse than no
219 /// detection at all.
220 #[test]
221 fn the_layer_records_only_the_duplicate_identity_warning() {
222 let state = Arc::new(IdentityConflict::default());
223 assert_eq!(state.last_seen_epoch(), None, "nothing observed yet");
224
225 let sub = tracing_subscriber::registry().with(IdentityConflictLayer::new(state.clone()));
226 tracing::subscriber::with_default(sub, || {
227 // Unrelated noise at the same level, including a DIFFERENT relay status.
228 tracing::warn!("Relay server reports problem: {}", Status::Healthy);
229 tracing::warn!("peer unreachable");
230 tracing::error!("something else broke entirely");
231 assert_eq!(
232 state.last_seen_epoch(),
233 None,
234 "an unrelated warning must never read as a duplicate identity"
235 );
236
237 // The real thing, formatted exactly as iroh's relay actor formats it.
238 tracing::warn!(
239 "Relay server reports problem: {}",
240 Status::SameEndpointIdConnected
241 );
242 assert!(
243 state.last_seen_epoch().is_some(),
244 "the duplicate-identity report must be recorded"
245 );
246 });
247 }
248
249 /// The stamp is the LAST observation, so a reader can judge staleness — the condition is
250 /// announced once as the connection is dropped, never re-reported, so a self-clearing flag
251 /// would be blank by the time anyone read `status`.
252 #[test]
253 fn the_stamp_is_the_most_recent_observation() {
254 let s = IdentityConflict::default();
255 s.observe(100);
256 assert_eq!(s.last_seen_epoch(), Some(100));
257 s.observe(200);
258 assert_eq!(s.last_seen_epoch(), Some(200), "later replaces earlier");
259 }
260}