Skip to main content

fips_core/node/
endpoint_event.rs

1use super::*;
2use crate::transport::PacketBuffer;
3use std::sync::Arc;
4
5/// Maximum endpoint packets in one authenticated direct packet run.
6pub const FIPS_ENDPOINT_DIRECT_PACKET_RUN_MAX_PACKETS: usize = 128;
7
8/// Maximum endpoint packets pending in an installed direct packet sink.
9pub const FIPS_ENDPOINT_DIRECT_PACKET_QUEUE_MAX_PACKETS: usize = 4096;
10
11/// Authenticated source/session facts for a direct endpoint packet run.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub(crate) struct FipsEndpointDirectPacketRunMeta {
14    source_peer: PeerIdentity,
15    previous_hop_addr: NodeAddr,
16    received_k_bit: bool,
17    direct_path: bool,
18    enqueued_at_ms: u64,
19}
20
21impl FipsEndpointDirectPacketRunMeta {
22    pub(crate) fn new(
23        source_peer: PeerIdentity,
24        previous_hop_addr: NodeAddr,
25        received_k_bit: bool,
26        direct_path: bool,
27        enqueued_at_ms: u64,
28    ) -> Self {
29        Self {
30            source_peer,
31            previous_hop_addr,
32            received_k_bit,
33            direct_path,
34            enqueued_at_ms,
35        }
36    }
37}
38
39/// Consecutive direct endpoint packets from one authenticated FIPS source.
40///
41/// FIPS owns authentication and ordering. The embedder can still apply live
42/// routing policy before borrowing packet bytes for TUN writes.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct FipsEndpointDirectPacketRun {
45    meta: FipsEndpointDirectPacketRunMeta,
46    packets: Vec<PacketBuffer>,
47    packet_bytes: usize,
48}
49
50/// Borrowed packet slices from a direct endpoint packet run.
51pub struct FipsEndpointDirectPacketSlices<'a> {
52    packets: std::slice::Iter<'a, PacketBuffer>,
53}
54
55impl FipsEndpointDirectPacketRun {
56    pub(crate) fn from_packet(meta: FipsEndpointDirectPacketRunMeta, packet: PacketBuffer) -> Self {
57        let packet_bytes = packet.len();
58        let mut packets = Vec::with_capacity(FIPS_ENDPOINT_DIRECT_PACKET_RUN_MAX_PACKETS);
59        packets.push(packet);
60        Self {
61            meta,
62            packets,
63            packet_bytes,
64        }
65    }
66
67    /// Authenticated FIPS peer that originated every packet in this run.
68    pub fn source_peer(&self) -> &PeerIdentity {
69        &self.meta.source_peer
70    }
71
72    /// Unix-millisecond time when FIPS handed this run to the direct sink.
73    pub fn enqueued_at_ms(&self) -> u64 {
74        self.meta.enqueued_at_ms
75    }
76
77    /// Number of endpoint packets in the run.
78    pub fn len(&self) -> usize {
79        self.packets.len()
80    }
81
82    /// Whether the run contains no packets.
83    pub fn is_empty(&self) -> bool {
84        self.len() == 0
85    }
86
87    /// Sum of endpoint packet bytes, excluding bulk length metadata.
88    pub fn packet_bytes(&self) -> usize {
89        self.packet_bytes
90    }
91
92    /// Borrow one packet by index.
93    pub fn packet_slice(&self, index: usize) -> Option<&[u8]> {
94        self.packets.get(index).map(PacketBuffer::as_slice)
95    }
96
97    pub(crate) fn push_packet(&mut self, packet: PacketBuffer) {
98        self.packet_bytes = self.packet_bytes.saturating_add(packet.len());
99        self.packets.push(packet);
100    }
101
102    pub(crate) fn try_append(&mut self, other: &mut Self) -> bool {
103        if self.len().saturating_add(other.len()) > FIPS_ENDPOINT_DIRECT_PACKET_RUN_MAX_PACKETS
104            || self.meta.source_peer != other.meta.source_peer
105            || self.meta.previous_hop_addr != other.meta.previous_hop_addr
106            || self.meta.received_k_bit != other.meta.received_k_bit
107            || self.meta.direct_path != other.meta.direct_path
108        {
109            return false;
110        }
111        self.packet_bytes = self.packet_bytes.saturating_add(other.packet_bytes);
112        other.packet_bytes = 0;
113        self.packets.append(&mut other.packets);
114        true
115    }
116
117    /// Borrow packet bytes from the run-owned buffers.
118    pub fn packet_slices(&self) -> FipsEndpointDirectPacketSlices<'_> {
119        FipsEndpointDirectPacketSlices {
120            packets: self.packets.iter(),
121        }
122    }
123
124    /// Keep only packets accepted by the caller while preserving packet buffers.
125    ///
126    /// The predicate receives the original packet index and immutable bytes. This
127    /// keeps routing/admission policy outside FIPS while allowing embedders to
128    /// remove rejected packets before a TUN writer borrows the run.
129    pub fn retain_packets<F>(&mut self, mut keep: F)
130    where
131        F: FnMut(usize, &[u8]) -> bool,
132    {
133        let mut index = 0usize;
134        let mut packet_bytes = 0usize;
135        self.packets.retain(|packet| {
136            let retained = keep(index, packet.as_slice());
137            index = index.saturating_add(1);
138            if retained {
139                packet_bytes = packet_bytes.saturating_add(packet.len());
140            }
141            retained
142        });
143        self.packet_bytes = packet_bytes;
144    }
145
146    /// Split this run at a packet index without copying packet bytes.
147    ///
148    /// The original run keeps packets before `at`; the returned run contains
149    /// packets from `at` onward with the same authenticated source metadata.
150    pub fn split_off_packets(&mut self, at: usize) -> Option<Self> {
151        if at >= self.packets.len() {
152            return None;
153        }
154        let packets = self.packets.split_off(at);
155        let packet_bytes = packets.iter().map(PacketBuffer::len).sum();
156        self.packet_bytes = self.packet_bytes.saturating_sub(packet_bytes);
157        Some(Self {
158            meta: self.meta.clone(),
159            packets,
160            packet_bytes,
161        })
162    }
163}
164
165impl<'a> Iterator for FipsEndpointDirectPacketSlices<'a> {
166    type Item = &'a [u8];
167
168    fn next(&mut self) -> Option<Self::Item> {
169        self.packets.next().map(PacketBuffer::as_slice)
170    }
171
172    fn size_hint(&self) -> (usize, Option<usize>) {
173        self.packets.size_hint()
174    }
175}
176
177impl ExactSizeIterator for FipsEndpointDirectPacketSlices<'_> {}
178
179impl Drop for FipsEndpointDirectPacketRun {
180    fn drop(&mut self) {
181        PacketBuffer::recycle_batch(&mut self.packets);
182    }
183}
184
185/// Established endpoint packet runs delivered without the endpoint-event queue.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct FipsEndpointDirectPacketBatch {
188    packet_runs: Vec<FipsEndpointDirectPacketRun>,
189}
190
191impl FipsEndpointDirectPacketBatch {
192    pub(crate) fn from_packet_runs(packet_runs: Vec<FipsEndpointDirectPacketRun>) -> Self {
193        debug_assert!(
194            packet_runs
195                .iter()
196                .all(|run| run.len() <= FIPS_ENDPOINT_DIRECT_PACKET_RUN_MAX_PACKETS)
197        );
198        Self { packet_runs }
199    }
200
201    /// Take ownership of the delivered packet runs.
202    pub fn into_packet_runs(self) -> Vec<FipsEndpointDirectPacketRun> {
203        self.packet_runs
204    }
205}
206
207/// Error returned by an installed direct endpoint sink.
208#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
209pub enum FipsEndpointDirectDeliveryError {
210    /// The sink could not accept this batch.
211    #[error("direct endpoint sink unavailable")]
212    Unavailable,
213}
214
215/// Application-provided direct dataplane endpoint delivery sink.
216///
217/// This sink is called synchronously from the dataplane output path with owned packet
218/// buffers. It should return quickly and avoid blocking unrelated dataplane progress.
219pub trait FipsEndpointDirectSink: Send + Sync + 'static {
220    /// Deliver established endpoint data as authenticated packet runs.
221    fn deliver_endpoint_packet_batch(
222        &self,
223        batch: FipsEndpointDirectPacketBatch,
224    ) -> Result<(), FipsEndpointDirectDeliveryError>;
225}
226
227impl<F> FipsEndpointDirectSink for F
228where
229    F: Fn(FipsEndpointDirectPacketBatch) -> Result<(), FipsEndpointDirectDeliveryError>
230        + Send
231        + Sync
232        + 'static,
233{
234    fn deliver_endpoint_packet_batch(
235        &self,
236        batch: FipsEndpointDirectPacketBatch,
237    ) -> Result<(), FipsEndpointDirectDeliveryError> {
238        self(batch)
239    }
240}
241
242#[derive(Clone)]
243pub(crate) struct EndpointDirectSink {
244    sink: Arc<dyn FipsEndpointDirectSink>,
245}
246
247impl std::fmt::Debug for EndpointDirectSink {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        f.debug_struct("EndpointDirectSink").finish_non_exhaustive()
250    }
251}
252
253impl EndpointDirectSink {
254    pub(crate) fn new<S>(sink: S) -> Self
255    where
256        S: FipsEndpointDirectSink,
257    {
258        Self {
259            sink: Arc::new(sink),
260        }
261    }
262
263    pub(crate) fn deliver_direct_packet_batch(
264        &self,
265        batch: FipsEndpointDirectPacketBatch,
266    ) -> Result<(), FipsEndpointDirectDeliveryError> {
267        self.sink.deliver_endpoint_packet_batch(batch)
268    }
269}
270
271/// App-owned packet channels for embedding FIPS without a system TUN.
272#[derive(Debug)]
273pub struct ExternalPacketIo {
274    /// Send outbound IPv6 packets into the node.
275    pub outbound_tx: crate::upper::tun::TunOutboundTx,
276    /// Receive inbound IPv6 packets delivered by FIPS sessions.
277    pub inbound_rx: tokio::sync::mpsc::Receiver<NodeDeliveredPacket>,
278}
279
280/// App-owned endpoint data channels for embedding FIPS without a daemon.
281#[derive(Debug)]
282pub(crate) struct EndpointDataIo {
283    /// Send endpoint management commands into the node RX loop ahead of queued
284    /// endpoint data.
285    pub(crate) control_tx: tokio::sync::mpsc::Sender<NodeEndpointControlCommand>,
286    /// Send endpoint data batches into the node RX loop.
287    ///
288    /// Bounded by the explicit endpoint packet capacity. Bulk backpressure is
289    /// visible to the caller instead of hidden behind an environment-selected
290    /// queue size.
291    pub(crate) data_batch_tx: EndpointDataBatchTx,
292    /// Receive endpoint data delivered by FIPS sessions.
293    ///
294    /// Endpoint data uses one bounded app-data channel. Oversized batches split
295    /// at the message-credit boundary before any remaining tail drops visibly
296    /// via `endpoint_event_bulk_dropped`. Backpressure is still visible through
297    /// `endpoint_event_wait` latency and `endpoint_event_backlog_high` when the
298    /// consumer falls materially behind.
299    pub(crate) event_rx: EndpointEventReceiver,
300    /// Clone of the event_tx exposed for in-process loopback. Lets the endpoint
301    /// inject an event into the same queue without going through the encrypt /
302    /// decrypt path, while keeping every consumer reading from a single channel.
303    pub(crate) event_tx: EndpointEventSender,
304    /// Receive registered FSP service datagrams.
305    pub(crate) service_event_rx: EndpointServiceEventReceiver,
306    /// Clone used for registered in-process loopback service sends.
307    pub(crate) service_event_tx: EndpointServiceEventSender,
308}
309
310/// Observable owner for endpoint events delivered to embedded applications.
311#[derive(Debug, Clone)]
312pub(crate) struct EndpointEventSender {
313    tx: tokio::sync::mpsc::Sender<NodeEndpointEvent>,
314    direct_sink: Option<EndpointDirectSink>,
315    queued_messages: Arc<AtomicUsize>,
316    ready: Arc<EndpointEventReady>,
317    message_cap: usize,
318}
319
320#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
321pub(crate) enum EndpointEventSendError {
322    #[error("endpoint event channel closed")]
323    Closed,
324}
325
326#[derive(Debug)]
327pub(crate) struct EndpointEventReceiver {
328    rx: tokio::sync::mpsc::Receiver<NodeEndpointEvent>,
329    queued_messages: Arc<AtomicUsize>,
330    ready: Arc<EndpointEventReady>,
331    closed: bool,
332}
333
334#[derive(Debug, Default)]
335struct EndpointEventReady {
336    sequence: StdMutex<u64>,
337    changed: Condvar,
338}
339
340impl EndpointEventReady {
341    fn notify(&self) {
342        if let Ok(mut sequence) = self.sequence.lock() {
343            *sequence = sequence.wrapping_add(1);
344            self.changed.notify_one();
345        }
346    }
347
348    fn snapshot(&self) -> u64 {
349        self.sequence.lock().map(|sequence| *sequence).unwrap_or(0)
350    }
351
352    fn wait_for_change(&self, observed: &mut u64) {
353        let Ok(mut sequence) = self.sequence.lock() else {
354            return;
355        };
356        while *sequence == *observed {
357            match self.changed.wait(sequence) {
358                Ok(next) => sequence = next,
359                Err(_) => return,
360            }
361        }
362        *observed = *sequence;
363    }
364}
365
366fn endpoint_event_capacity(requested: usize) -> usize {
367    requested.max(1)
368}
369
370fn try_reserve_endpoint_event_messages(
371    counter: &AtomicUsize,
372    capacity: usize,
373    count: usize,
374) -> Option<usize> {
375    if count == 0 {
376        return Some(counter.load(Relaxed));
377    }
378
379    counter
380        .fetch_update(Relaxed, Relaxed, |current| {
381            current.checked_add(count).filter(|next| *next <= capacity)
382        })
383        .ok()
384}
385
386/// Delivery-side owner for endpoint data emitted by session receive handling.
387///
388/// The rx loop currently owns this runtime, but keeping sender, batching, and
389/// backlog accounting behind one value makes the future peer/shard receive
390/// runtime move explicit instead of threading endpoint-event fields through
391/// `Node` packet handlers.
392#[derive(Debug, Default)]
393pub(in crate::node) struct EndpointEventRuntime {
394    sender: Option<EndpointEventSender>,
395}
396
397impl EndpointEventSender {
398    pub(in crate::node) fn channel(capacity: usize) -> (Self, EndpointEventReceiver) {
399        Self::channel_with_direct_sink(capacity, None)
400    }
401
402    pub(in crate::node) fn channel_with_direct_sink(
403        capacity: usize,
404        direct_sink: Option<EndpointDirectSink>,
405    ) -> (Self, EndpointEventReceiver) {
406        let message_cap = endpoint_event_capacity(capacity);
407        let (tx, rx) = tokio::sync::mpsc::channel(message_cap);
408        let queued_messages = Arc::new(AtomicUsize::new(0));
409        let ready = Arc::new(EndpointEventReady::default());
410        (
411            Self {
412                tx,
413                direct_sink,
414                queued_messages: Arc::clone(&queued_messages),
415                ready: Arc::clone(&ready),
416                message_cap,
417            },
418            EndpointEventReceiver {
419                rx,
420                queued_messages,
421                ready,
422                closed: false,
423            },
424        )
425    }
426
427    pub(crate) fn direct_sink(&self) -> Option<&EndpointDirectSink> {
428        self.direct_sink.as_ref()
429    }
430
431    pub(crate) fn send(&self, event: NodeEndpointEvent) -> Result<(), EndpointEventSendError> {
432        if event.messages.is_empty() {
433            return Ok(());
434        }
435
436        self.send_event(event, true)
437    }
438
439    fn send_event(
440        &self,
441        event: NodeEndpointEvent,
442        split_on_pressure: bool,
443    ) -> Result<(), EndpointEventSendError> {
444        let count = event.message_count();
445        let Some(previous) =
446            try_reserve_endpoint_event_messages(&self.queued_messages, self.message_cap, count)
447        else {
448            if split_on_pressure && count > 1 {
449                return self.split_and_send_event(event);
450            }
451            crate::perf_profile::record_event_count(
452                crate::perf_profile::Event::EndpointEventBulkDropped,
453                count as u64,
454            );
455            return Ok(());
456        };
457
458        let queued = previous.saturating_add(count);
459        match self.tx.try_send(event) {
460            Ok(()) => {
461                self.note_send_success(previous, queued);
462                Ok(())
463            }
464            Err(tokio::sync::mpsc::error::TrySendError::Full(_event)) => {
465                self.note_send_rejected(count);
466                crate::perf_profile::record_event_count(
467                    crate::perf_profile::Event::EndpointEventBulkDropped,
468                    count as u64,
469                );
470                Ok(())
471            }
472            Err(tokio::sync::mpsc::error::TrySendError::Closed(event)) => {
473                self.note_send_rejected(count);
474                drop(event);
475                Err(EndpointEventSendError::Closed)
476            }
477        }
478    }
479
480    fn split_and_send_event(&self, event: NodeEndpointEvent) -> Result<(), EndpointEventSendError> {
481        let mut messages = event.messages;
482        let queued_at = event.queued_at;
483        if messages.len() <= 1 {
484            return self.send_event(
485                NodeEndpointEvent {
486                    messages,
487                    queued_at,
488                },
489                false,
490            );
491        }
492
493        let right = messages.split_off(messages.len() / 2);
494        if !messages.is_empty() {
495            self.send_event(
496                NodeEndpointEvent {
497                    messages,
498                    queued_at,
499                },
500                true,
501            )?;
502        }
503        if !right.is_empty() {
504            self.send_event(
505                NodeEndpointEvent {
506                    messages: right,
507                    queued_at,
508                },
509                true,
510            )?;
511        }
512        Ok(())
513    }
514
515    fn note_send_success(&self, previous: usize, queued: usize) {
516        if previous < ENDPOINT_EVENT_BACKLOG_HIGH_WATER
517            && queued >= ENDPOINT_EVENT_BACKLOG_HIGH_WATER
518        {
519            crate::perf_profile::record_event(crate::perf_profile::Event::EndpointEventBacklogHigh);
520        }
521        self.ready.notify();
522    }
523
524    fn note_send_rejected(&self, count: usize) {
525        release_endpoint_event_messages(&self.queued_messages, count);
526        self.ready.notify();
527    }
528
529    #[cfg(test)]
530    pub(crate) fn queued_messages(&self) -> usize {
531        self.queued_messages.load(Relaxed)
532    }
533}
534
535impl Drop for EndpointEventSender {
536    fn drop(&mut self) {
537        self.ready.notify();
538    }
539}
540
541impl Drop for EndpointEventReceiver {
542    fn drop(&mut self) {
543        self.queued_messages.store(0, Relaxed);
544        self.ready.notify();
545    }
546}
547
548impl EndpointEventRuntime {
549    pub(in crate::node) fn attach(&mut self, sender: EndpointEventSender) {
550        self.sender = Some(sender);
551    }
552
553    pub(in crate::node) fn is_attached(&self) -> bool {
554        self.sender.is_some()
555    }
556
557    pub(in crate::node) fn sender(&self) -> Option<EndpointEventSender> {
558        self.sender.clone()
559    }
560
561    pub(in crate::node) fn deliver_endpoint_data_batch(
562        &mut self,
563        messages: Vec<EndpointDataDelivery>,
564    ) -> Result<(), EndpointEventSendError> {
565        if messages.is_empty() {
566            return Ok(());
567        }
568
569        let Some(sender) = &self.sender else {
570            return Ok(());
571        };
572        let _t_deliver =
573            crate::perf_profile::Timer::start(crate::perf_profile::Stage::EndpointDeliver);
574        sender.send(NodeEndpointEvent {
575            messages,
576            queued_at: crate::perf_profile::stamp(),
577        })
578    }
579}
580
581impl EndpointEventReceiver {
582    pub(crate) async fn recv(&mut self) -> Option<NodeEndpointEvent> {
583        let event = self.rx.recv().await?;
584        self.note_observed(&event);
585        Some(event)
586    }
587
588    pub(crate) fn blocking_recv(&mut self) -> Option<NodeEndpointEvent> {
589        let mut observed = self.ready.snapshot();
590        loop {
591            match self.try_recv() {
592                Ok(event) => return Some(event),
593                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => return None,
594                Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
595                    self.ready.wait_for_change(&mut observed);
596                }
597            }
598        }
599    }
600
601    pub(crate) fn try_recv(
602        &mut self,
603    ) -> Result<NodeEndpointEvent, tokio::sync::mpsc::error::TryRecvError> {
604        match self.rx.try_recv() {
605            Ok(event) => {
606                self.note_observed(&event);
607                Ok(event)
608            }
609            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {
610                if self.closed {
611                    Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
612                } else {
613                    Err(tokio::sync::mpsc::error::TryRecvError::Empty)
614                }
615            }
616            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
617                self.closed = true;
618                Err(tokio::sync::mpsc::error::TryRecvError::Disconnected)
619            }
620        }
621    }
622
623    pub(crate) fn release_messages(&self, count: usize) {
624        release_endpoint_event_messages(&self.queued_messages, count);
625    }
626
627    fn note_observed(&self, event: &NodeEndpointEvent) {
628        event.record_dequeue_wait();
629    }
630}
631
632pub(in crate::node) fn release_endpoint_event_messages(counter: &AtomicUsize, count: usize) {
633    if count == 0 {
634        return;
635    }
636
637    let previous = counter.fetch_sub(count, Relaxed);
638    debug_assert!(
639        previous >= count,
640        "endpoint event queued message accounting underflow"
641    );
642}
643
644/// Reports what changed in response to `UpdatePeers`.
645#[derive(Debug, Clone, Default, PartialEq, Eq)]
646pub(crate) struct UpdatePeersOutcome {
647    pub(crate) added: usize,
648    pub(crate) removed: usize,
649    pub(crate) updated: usize,
650    pub(crate) unchanged: usize,
651}
652
653/// Authenticated endpoint data emitted by the session receive path.
654///
655/// Keeping source identity and payload together makes the delivery-side
656/// ownership boundary explicit for the current rx loop and for a future
657/// peer/session runtime that can move endpoint-data delivery off the bounce path.
658#[derive(Debug, Clone)]
659pub(crate) struct EndpointDataDelivery {
660    pub(crate) source_peer: PeerIdentity,
661    pub(crate) payload: PacketBuffer,
662    pub(crate) enqueued_at_ms: u64,
663}
664
665impl EndpointDataDelivery {
666    pub(crate) fn new(source_peer: PeerIdentity, payload: PacketBuffer) -> Self {
667        Self {
668            source_peer,
669            payload,
670            enqueued_at_ms: crate::time::now_ms(),
671        }
672    }
673}
674
675/// Endpoint data events emitted by the node session receive path.
676#[derive(Debug)]
677pub(crate) struct NodeEndpointEvent {
678    pub(crate) messages: Vec<EndpointDataDelivery>,
679    pub(crate) queued_at: Option<crate::perf_profile::TraceStamp>,
680}
681
682impl NodeEndpointEvent {
683    pub(in crate::node) fn message_count(&self) -> usize {
684        self.messages.len()
685    }
686
687    fn record_dequeue_wait(&self) {
688        let queued_at = self.queued_at;
689        if queued_at.is_none() {
690            return;
691        }
692        crate::perf_profile::record_since_count(
693            crate::perf_profile::Stage::EndpointEventWait,
694            queued_at,
695            self.message_count() as u64,
696        );
697    }
698}
699
700/// Authenticated peer state exposed to embedded endpoint callers.
701#[derive(Debug, Clone, PartialEq, Eq)]
702pub(crate) struct NodeEndpointPeer {
703    pub(crate) npub: String,
704    pub(crate) node_addr: NodeAddr,
705    pub(crate) connected: bool,
706    pub(crate) transport_addr: Option<String>,
707    pub(crate) transport_type: Option<String>,
708    pub(crate) link_id: u64,
709    pub(crate) srtt_ms: Option<u64>,
710    pub(crate) srtt_age_ms: Option<u64>,
711    pub(crate) packets_sent: u64,
712    pub(crate) packets_recv: u64,
713    pub(crate) bytes_sent: u64,
714    pub(crate) bytes_recv: u64,
715    pub(crate) rekey_in_progress: bool,
716    pub(crate) rekey_draining: bool,
717    pub(crate) current_k_bit: Option<bool>,
718    pub(crate) last_outbound_route: Option<String>,
719    pub(crate) direct_probe_pending: bool,
720    pub(crate) direct_probe_after_ms: Option<u64>,
721    pub(crate) direct_probe_retry_count: u32,
722    pub(crate) direct_probe_auto_reconnect: bool,
723    pub(crate) direct_probe_expires_at_ms: Option<u64>,
724    pub(crate) nostr_traversal_consecutive_failures: u32,
725    pub(crate) nostr_traversal_in_cooldown: bool,
726    pub(crate) nostr_traversal_cooldown_until_ms: Option<u64>,
727    pub(crate) nostr_traversal_last_observed_skew_ms: Option<i64>,
728}
729
730/// Live Nostr relay state exposed to embedded endpoint callers.
731#[derive(Debug, Clone, PartialEq, Eq)]
732pub(crate) struct NodeEndpointRelayStatus {
733    pub(crate) url: String,
734    pub(crate) status: String,
735}