Skip to main content

fips_core/
endpoint.rs

1//! Library-first endpoint API for embedding FIPS in applications.
2//!
3//! This module exposes a no-system-TUN runtime shape for apps that want to own
4//! peer admission and local routing policy while reusing FIPS connectivity.
5
6use crate::config::{EthernetConfig, NostrDiscoveryPolicy, TransportInstances, UdpConfig};
7#[cfg(test)]
8use crate::node::ENDPOINT_EVENT_TEST_PAYLOAD_LEN;
9use crate::node::{
10    EndpointDataBatchTx, EndpointDataBulkBody, EndpointDataBulkBodyBuilder, EndpointDirectSink,
11    EndpointEventSender, NodeEndpointControlCommand, NodeEndpointDataBatch, NodeEndpointEvent,
12};
13use crate::upper::tun::TunOutboundTx;
14use crate::{
15    Config, FipsAddress, IdentityConfig, Node, NodeAddr, NodeDeliveredPacket, NodeError,
16    PeerIdentity,
17};
18use std::sync::{Arc, Mutex as StdMutex};
19use thiserror::Error;
20use tokio::sync::{Mutex, mpsc, oneshot};
21use tokio::task::JoinHandle;
22
23const ENDPOINT_DATA_BATCH_MAX: usize = 128;
24const ENDPOINT_RECV_BATCH_MAX: usize = 128;
25
26mod builder;
27mod receive;
28mod status;
29
30#[cfg(test)]
31mod tests;
32
33pub use crate::node::{
34    FipsEndpointDirectDeliveryError, FipsEndpointDirectPacketBatch, FipsEndpointDirectPacketRun,
35    FipsEndpointDirectPacketRunMeta, FipsEndpointDirectSink, FipsEndpointDirectSourceRun,
36};
37pub use builder::FipsEndpointBuilder;
38use receive::EndpointReceiveState;
39pub use status::{FipsEndpointPeer, FipsEndpointRelayStatus};
40
41/// Endpoint data bytes delivered by FIPS.
42///
43/// This is the same pooled packet owner used by the transport/dataplane, so
44/// embedders can forward endpoint data without forcing another hot-path copy.
45pub type FipsEndpointData = crate::transport::PacketBuffer;
46
47/// Already-encoded `EndpointDataBulk` body owned by FIPS' endpoint send API.
48///
49/// Hot dataplane embedders can build this once from borrowed packet slices and
50/// hand it to FIPS without first materializing a `Vec<Vec<u8>>` batch for FIPS
51/// to repack into the same bulk wire body.
52#[derive(Clone, Debug, Eq, PartialEq)]
53pub struct FipsEndpointBulkData(EndpointDataBulkBody);
54
55impl FipsEndpointBulkData {
56    /// Build a validated bulk body from an already encoded `EndpointDataBulk` body.
57    pub fn from_encoded_body(body: Vec<u8>) -> Option<Self> {
58        EndpointDataBulkBody::from_encoded_body(body).map(Self)
59    }
60
61    /// Build one bulk body from borrowed packet slices.
62    pub fn from_packet_slices<'a, I>(packets: I) -> Option<Self>
63    where
64        I: IntoIterator<Item = &'a [u8]>,
65    {
66        let mut builder = FipsEndpointBulkDataBuilder::new();
67        for packet in packets {
68            if !builder.push_packet(packet) {
69                return None;
70            }
71        }
72        builder.finish()
73    }
74
75    /// Encoded body length, including bulk packet-count and per-packet length fields.
76    pub fn body_len(&self) -> usize {
77        self.0.body_len()
78    }
79
80    /// Number of endpoint packets encoded in this body.
81    pub fn packet_count(&self) -> usize {
82        self.0.packet_count()
83    }
84
85    /// Sum of packet payload bytes, excluding bulk metadata.
86    pub fn packet_bytes(&self) -> usize {
87        self.0.packet_bytes()
88    }
89
90    fn into_node_body(self) -> EndpointDataBulkBody {
91        self.0
92    }
93
94    fn into_packet_payloads(self) -> Vec<Vec<u8>> {
95        self.0.into_packet_payloads()
96    }
97}
98
99/// Builder for one `EndpointDataBulk` body from borrowed packet slices.
100#[derive(Debug, Default)]
101pub struct FipsEndpointBulkDataBuilder(EndpointDataBulkBodyBuilder);
102
103impl FipsEndpointBulkDataBuilder {
104    /// Start an empty bulk body.
105    pub fn new() -> Self {
106        Self(EndpointDataBulkBodyBuilder::new())
107    }
108
109    /// Whether no packets have been added yet.
110    pub fn is_empty(&self) -> bool {
111        self.0.is_empty()
112    }
113
114    /// Number of endpoint packets currently encoded.
115    pub fn packet_count(&self) -> usize {
116        self.0.packet_count()
117    }
118
119    /// Sum of packet payload bytes currently encoded.
120    pub fn packet_bytes(&self) -> usize {
121        self.0.packet_bytes()
122    }
123
124    /// Encoded body length so far, including bulk metadata.
125    pub fn body_len(&self) -> usize {
126        self.0.body_len()
127    }
128
129    /// Whether `packet` fits in this body without exceeding FSP bulk limits.
130    pub fn can_push_packet(&self, packet: &[u8]) -> bool {
131        self.0.can_push_packet(packet)
132    }
133
134    /// Append one packet to this body.
135    pub fn push_packet(&mut self, packet: &[u8]) -> bool {
136        self.0.push_packet(packet)
137    }
138
139    /// Finish the body, returning `None` if no packets were added.
140    pub fn finish(self) -> Option<FipsEndpointBulkData> {
141        self.0.finish().map(FipsEndpointBulkData)
142    }
143}
144
145#[cfg(debug_assertions)]
146fn endpoint_debug_log(message: impl AsRef<str>) {
147    use std::io::Write as _;
148
149    if let Ok(mut file) = std::fs::OpenOptions::new()
150        .create(true)
151        .append(true)
152        .open(std::env::temp_dir().join("nvpn-fips-endpoint-debug.log"))
153    {
154        let _ = writeln!(
155            file,
156            "{:?} {}",
157            std::time::SystemTime::now(),
158            message.as_ref()
159        );
160    }
161}
162
163#[cfg(not(debug_assertions))]
164fn endpoint_debug_log(_message: impl AsRef<str>) {}
165
166/// Errors returned by the endpoint API.
167#[derive(Debug, Error)]
168pub enum FipsEndpointError {
169    #[error("node error: {0}")]
170    Node(#[from] NodeError),
171
172    #[error("endpoint task failed: {0}")]
173    TaskJoin(#[from] tokio::task::JoinError),
174
175    #[error("endpoint is closed")]
176    Closed,
177
178    #[error("invalid remote npub '{npub}': {reason}")]
179    InvalidRemoteNpub { npub: String, reason: String },
180}
181
182/// Source-attributed endpoint data delivered to an embedded application.
183#[derive(Debug, Clone, PartialEq, Eq)]
184pub struct FipsEndpointMessage {
185    /// Authenticated FIPS peer that originated the endpoint data.
186    pub source_peer: PeerIdentity,
187    /// Application-owned payload bytes.
188    pub data: FipsEndpointData,
189    /// Unix-millisecond time when FIPS queued this message for the embedder.
190    pub enqueued_at_ms: u64,
191}
192
193impl FipsEndpointMessage {
194    /// FIPS node address that originated the endpoint data.
195    pub fn source_node_addr(&self) -> &NodeAddr {
196        self.source_peer.node_addr()
197    }
198
199    /// Source Nostr public key as human-facing bech32 text.
200    pub fn source_npub(&self) -> String {
201        self.source_peer.npub()
202    }
203}
204
205/// Reports what changed in response to [`FipsEndpoint::update_peers`].
206#[derive(Debug, Clone, Default, PartialEq, Eq)]
207pub struct UpdatePeersOutcome {
208    /// Number of npubs that were not previously in the runtime peer list
209    /// and got an `initiate_peer_connection` call.
210    pub added: usize,
211    /// Number of npubs that were dropped from the runtime peer list. Their
212    /// retry entries are gone; any active session stays up until the
213    /// regular liveness timeout reaps it.
214    pub removed: usize,
215    /// Number of npubs that were already in the list but had a different
216    /// `addresses`, `alias`, `connect_policy`, or `auto_reconnect` value.
217    /// The new values are now in effect for retries and aliasing; refreshed
218    /// direct addresses may also trigger a new direct dial for auto peers.
219    pub updated: usize,
220    /// Number of npubs that were in the list and identical to the new entry.
221    pub unchanged: usize,
222}
223
224impl From<crate::node::UpdatePeersOutcome> for UpdatePeersOutcome {
225    fn from(value: crate::node::UpdatePeersOutcome) -> Self {
226        Self {
227            added: value.added,
228            removed: value.removed,
229            updated: value.updated,
230            unchanged: value.unchanged,
231        }
232    }
233}
234
235fn apply_default_scoped_discovery(config: &mut Config, scope: &str) {
236    if config.node.discovery.nostr.enabled || !config.transports.is_empty() {
237        return;
238    }
239
240    config.node.discovery.nostr.enabled = true;
241    config.node.discovery.nostr.advertise = true;
242    config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
243    config.node.discovery.nostr.share_local_candidates = true;
244    config.node.discovery.nostr.app = scope.to_string();
245    config.node.discovery.lan.scope = Some(scope.to_string());
246    config.node.discovery.local.enabled = true;
247    config.transports.udp = TransportInstances::Single(UdpConfig {
248        bind_addr: Some("0.0.0.0:0".to_string()),
249        advertise_on_nostr: Some(true),
250        public: Some(false),
251        outbound_only: Some(false),
252        accept_connections: Some(true),
253        ..UdpConfig::default()
254    });
255}
256
257fn endpoint_ethernet_config(interface: &str, scope: Option<&str>) -> EthernetConfig {
258    EthernetConfig {
259        interface: interface.to_string(),
260        discovery: Some(true),
261        announce: Some(true),
262        auto_connect: Some(true),
263        accept_connections: Some(true),
264        discovery_scope: scope
265            .map(str::trim)
266            .filter(|s| !s.is_empty())
267            .map(str::to_string),
268        ..EthernetConfig::default()
269    }
270}
271
272fn add_endpoint_ethernet_transport(config: &mut Config, interface: &str, scope: Option<&str>) {
273    let eth = endpoint_ethernet_config(interface, scope);
274    if config.transports.ethernet.is_empty() {
275        config.transports.ethernet = TransportInstances::Single(eth);
276        return;
277    }
278
279    let existing = std::mem::take(&mut config.transports.ethernet);
280    let mut named = match existing {
281        TransportInstances::Single(config) => {
282            let mut map = std::collections::HashMap::new();
283            map.insert("default".to_string(), config);
284            map
285        }
286        TransportInstances::Named(map) => map,
287    };
288
289    let base_name = endpoint_ethernet_instance_name(interface);
290    let mut name = base_name.clone();
291    let mut suffix = 2usize;
292    while named.contains_key(&name) {
293        name = format!("{base_name}-{suffix}");
294        suffix += 1;
295    }
296    named.insert(name, eth);
297    config.transports.ethernet = TransportInstances::Named(named);
298}
299
300fn endpoint_ethernet_instance_name(interface: &str) -> String {
301    let suffix: String = interface
302        .chars()
303        .map(|c| {
304            if c.is_ascii_alphanumeric() {
305                c.to_ascii_lowercase()
306            } else {
307                '-'
308            }
309        })
310        .collect();
311    let suffix = suffix.trim_matches('-');
312    if suffix.is_empty() {
313        "local-ethernet".to_string()
314    } else {
315        format!("local-ethernet-{suffix}")
316    }
317}
318
319fn spawn_node_task(
320    mut node: Node,
321    shutdown_rx: oneshot::Receiver<()>,
322) -> JoinHandle<Result<(), NodeError>> {
323    tokio::spawn(async move {
324        tokio::pin!(shutdown_rx);
325        let loop_result = tokio::select! {
326            result = node.run_rx_loop() => result,
327            _ = &mut shutdown_rx => Ok(()),
328        };
329        let stop_result = if node.state().can_stop() {
330            node.stop().await
331        } else {
332            Ok(())
333        };
334        loop_result?;
335        stop_result
336    })
337}
338
339/// A running embedded FIPS endpoint.
340pub struct FipsEndpoint {
341    identity: PeerIdentity,
342    npub: String,
343    node_addr: NodeAddr,
344    address: FipsAddress,
345    discovery_scope: Option<String>,
346    outbound_packets: TunOutboundTx,
347    delivered_packets: Arc<Mutex<mpsc::Receiver<NodeDeliveredPacket>>>,
348    endpoint_control_tx: mpsc::Sender<NodeEndpointControlCommand>,
349    endpoint_data_batches: EndpointDataBatchTx,
350    /// In-process loopback sender — `send()` to our own npub injects an
351    /// event into the same queue without going through the wire/encrypt
352    /// path. The node's rx_loop also sends into this channel directly
353    /// (it holds a clone of this sender) so there is no per-packet relay
354    /// task between the node task and `recv()`.
355    inbound_endpoint_tx: EndpointEventSender,
356    /// Unbounded receiver plus pending tail from an internal batch. This was
357    /// previously fed by a per-packet relay task
358    /// that translated node endpoint events into `FipsEndpointMessage`
359    /// across an additional bounded mpsc; collapsed into a single channel
360    /// -- the translation happens inline in `recv()` and the second hop
361    /// (with its scheduler wake per packet) is gone.
362    inbound_endpoint_rx: Arc<Mutex<EndpointReceiveState>>,
363    /// Cache of resolved PeerIdentity by npub string. Avoids the per-packet
364    /// secp256k1 EC point parse that `PeerIdentity::from_npub` performs;
365    /// without this cache the bulk-data send hot path spends ~10–30% of CPU
366    /// re-validating identity bytes the application has already configured.
367    peer_identity_cache: std::sync::Mutex<std::collections::HashMap<String, PeerIdentity>>,
368    shutdown_tx: StdMutex<Option<oneshot::Sender<()>>>,
369    task: StdMutex<Option<JoinHandle<Result<(), NodeError>>>>,
370}
371
372impl FipsEndpoint {
373    /// Create a builder for an embedded endpoint.
374    pub fn builder() -> FipsEndpointBuilder {
375        FipsEndpointBuilder::default()
376    }
377
378    /// Local endpoint npub.
379    pub fn npub(&self) -> &str {
380        &self.npub
381    }
382
383    /// Local FIPS node address.
384    pub fn node_addr(&self) -> &NodeAddr {
385        &self.node_addr
386    }
387
388    /// Local FIPS IPv6-compatible address.
389    pub fn address(&self) -> FipsAddress {
390        self.address
391    }
392
393    /// Application-level discovery scope, if configured.
394    pub fn discovery_scope(&self) -> Option<&str> {
395        self.discovery_scope.as_deref()
396    }
397
398    /// Send application-owned endpoint data to a remote npub.
399    ///
400    /// Fire-and-forget: enqueues endpoint data on the PM2 bulk lane. TCP and
401    /// the upper protocol handle loss recovery, and the per-packet oneshot
402    /// round-trip the previous design used for error reporting added avoidable
403    /// scheduler work to the hot endpoint-data path.
404    ///
405    /// PeerIdentity for `remote_npub` is cached after first resolution to
406    /// avoid the secp256k1 EC point parse on every packet.
407    pub async fn send(
408        &self,
409        remote_npub: impl Into<String>,
410        data: impl Into<Vec<u8>>,
411    ) -> Result<(), FipsEndpointError> {
412        let remote_npub = remote_npub.into();
413        let data = data.into();
414        if remote_npub == self.npub {
415            return self.send_loopback(data);
416        }
417
418        let remote = self.resolve_peer_identity(&remote_npub)?;
419        self.send_to_peer(remote, data).await
420    }
421
422    /// Send application-owned endpoint data to a resolved remote identity.
423    ///
424    /// This is the fast path for applications that already validate and cache
425    /// peer identities in their own routing table. It avoids per-packet npub
426    /// allocation, endpoint cache lookup, and `PeerIdentity::from_npub` parsing
427    /// while preserving the same owned-payload semantics as [`Self::send`].
428    pub async fn send_to_peer(
429        &self,
430        remote: PeerIdentity,
431        data: impl Into<Vec<u8>>,
432    ) -> Result<(), FipsEndpointError> {
433        let data = data.into();
434        if *remote.node_addr() == self.node_addr {
435            return self.send_loopback(data);
436        }
437        // Fire-and-forget: caller already drops the result, so skip
438        // the per-packet `oneshot::channel()` allocation entirely.
439        // Endpoint data now enters the PM2 bulk lane directly, without a
440        // per-packet oneshot or control-command hop.
441        if let Some(batch) =
442            NodeEndpointDataBatch::batch(remote, vec![data], crate::perf_profile::stamp())
443        {
444            self.endpoint_data_batches
445                .send_or_drop(batch)
446                .map_err(|_| FipsEndpointError::Closed)?;
447        }
448        Ok(())
449    }
450
451    /// Send a burst of application-owned endpoint payloads to one resolved peer.
452    pub async fn send_batch_to_peer(
453        &self,
454        remote: PeerIdentity,
455        payloads: Vec<Vec<u8>>,
456    ) -> Result<(), FipsEndpointError> {
457        if *remote.node_addr() == self.node_addr {
458            for payload in payloads {
459                self.send_loopback(payload)?;
460            }
461            return Ok(());
462        }
463
464        if payloads.is_empty() {
465            return Ok(());
466        }
467        self.send_endpoint_data_batch(remote, payloads)?;
468        Ok(())
469    }
470
471    /// Send prebuilt endpoint bulk bodies to one resolved peer.
472    ///
473    /// This keeps FIPS as the owner of the endpoint-data queue and PM2 send
474    /// policy while letting a hot embedder build the canonical bulk body from
475    /// borrowed packet slices before crossing the FIPS boundary.
476    pub async fn send_endpoint_bulk_data_batch_to_peer(
477        &self,
478        remote: PeerIdentity,
479        bulk_bodies: Vec<FipsEndpointBulkData>,
480    ) -> Result<(), FipsEndpointError> {
481        self.send_endpoint_bulk_data_batch(remote, bulk_bodies)
482    }
483
484    fn send_endpoint_data_batch(
485        &self,
486        remote: PeerIdentity,
487        mut payloads: Vec<Vec<u8>>,
488    ) -> Result<(), FipsEndpointError> {
489        while !payloads.is_empty() {
490            let tail = if payloads.len() > ENDPOINT_DATA_BATCH_MAX {
491                payloads.split_off(ENDPOINT_DATA_BATCH_MAX)
492            } else {
493                Vec::new()
494            };
495            let payload_batch = std::mem::replace(&mut payloads, tail);
496            let queued_at = crate::perf_profile::stamp();
497            let Some(batch) = NodeEndpointDataBatch::batch(remote, payload_batch, queued_at) else {
498                continue;
499            };
500            self.endpoint_data_batches
501                .send_or_drop(batch)
502                .map_err(|_| FipsEndpointError::Closed)?;
503        }
504        Ok(())
505    }
506
507    fn send_endpoint_bulk_data_batch(
508        &self,
509        remote: PeerIdentity,
510        bulk_bodies: Vec<FipsEndpointBulkData>,
511    ) -> Result<(), FipsEndpointError> {
512        if bulk_bodies.is_empty() {
513            return Ok(());
514        }
515
516        if *remote.node_addr() == self.node_addr {
517            for body in bulk_bodies {
518                for payload in body.into_packet_payloads() {
519                    self.send_loopback(payload)?;
520                }
521            }
522            return Ok(());
523        }
524
525        let mut batch_bodies = Vec::new();
526        let mut batch_packets = 0usize;
527        for body in bulk_bodies {
528            let body = body.into_node_body();
529            let body_packets = body.packet_count();
530            if !batch_bodies.is_empty()
531                && batch_packets.saturating_add(body_packets) > ENDPOINT_DATA_BATCH_MAX
532            {
533                let queued_at = crate::perf_profile::stamp();
534                let Some(batch) =
535                    NodeEndpointDataBatch::bulk_bodies(remote, batch_bodies, queued_at)
536                else {
537                    batch_bodies = Vec::new();
538                    batch_packets = 0;
539                    continue;
540                };
541                self.endpoint_data_batches
542                    .send_or_drop(batch)
543                    .map_err(|_| FipsEndpointError::Closed)?;
544                batch_bodies = Vec::new();
545                batch_packets = 0;
546            }
547            batch_packets = batch_packets.saturating_add(body_packets);
548            batch_bodies.push(body);
549        }
550
551        if !batch_bodies.is_empty() {
552            let queued_at = crate::perf_profile::stamp();
553            let Some(batch) = NodeEndpointDataBatch::bulk_bodies(remote, batch_bodies, queued_at)
554            else {
555                return Ok(());
556            };
557            self.endpoint_data_batches
558                .send_or_drop(batch)
559                .map_err(|_| FipsEndpointError::Closed)?;
560        }
561        Ok(())
562    }
563
564    fn resolve_peer_identity(&self, remote_npub: &str) -> Result<PeerIdentity, FipsEndpointError> {
565        // Fast path: cached identity (PeerIdentity is Copy after eager
566        // pubkey_full precompute landed in b1e92af, so dereference is free).
567        if let Ok(cache) = self.peer_identity_cache.lock()
568            && let Some(remote) = cache.get(remote_npub)
569        {
570            return Ok(*remote);
571        }
572
573        let remote = PeerIdentity::from_npub(remote_npub).map_err(|error| {
574            FipsEndpointError::InvalidRemoteNpub {
575                npub: remote_npub.to_string(),
576                reason: error.to_string(),
577            }
578        })?;
579
580        if let Ok(mut cache) = self.peer_identity_cache.lock() {
581            cache.entry(remote_npub.to_string()).or_insert(remote);
582        }
583        Ok(remote)
584    }
585
586    fn send_loopback(&self, data: Vec<u8>) -> Result<(), FipsEndpointError> {
587        self.inbound_endpoint_tx
588            .send(NodeEndpointEvent {
589                messages: vec![crate::node::EndpointDataDelivery {
590                    source_peer: self.identity,
591                    payload: data.into(),
592                    enqueued_at_ms: crate::time::now_ms(),
593                }],
594                queued_at: crate::perf_profile::stamp(),
595            })
596            .map_err(|_| FipsEndpointError::Closed)
597    }
598
599    /// Receive the next source-attributed endpoint data message.
600    ///
601    /// Translation from the internal endpoint event batch to
602    /// the public `FipsEndpointMessage` shape happens inline here -- the
603    /// rx_loop pushes directly onto this channel, no relay task in
604    /// between, no extra cross-task hop per packet.
605    pub async fn recv(&self) -> Option<FipsEndpointMessage> {
606        let mut state = self.inbound_endpoint_rx.lock().await;
607        if let Some(message) = state.pop_pending() {
608            return Some(message);
609        }
610        let event = state.rx.recv().await?;
611        state.first_from_event(event)
612    }
613
614    /// Receive one endpoint message, then drain currently queued follow-ons.
615    ///
616    /// This is the receive-side counterpart to [`Self::send_batch_to_peer`]:
617    /// callers still get individual source-attributed messages, but a hot
618    /// dataplane consumer can amortize the endpoint receiver lock and task wake
619    /// across a bounded burst.
620    pub async fn recv_batch(&self, max: usize) -> Option<Vec<FipsEndpointMessage>> {
621        let max = max.clamp(1, ENDPOINT_RECV_BATCH_MAX);
622        let mut messages = Vec::with_capacity(max);
623        self.recv_batch_into(&mut messages, max).await?;
624        Some(messages)
625    }
626
627    /// Receive one endpoint message, then drain ready follow-ons into a caller-owned buffer.
628    ///
629    /// This is the allocation-conscious form of [`Self::recv_batch`] for hot
630    /// dataplane consumers. The provided buffer is cleared before use and keeps
631    /// its allocation across calls.
632    pub async fn recv_batch_into(
633        &self,
634        messages: &mut Vec<FipsEndpointMessage>,
635        max: usize,
636    ) -> Option<usize> {
637        let max = max.clamp(1, ENDPOINT_RECV_BATCH_MAX);
638        messages.clear();
639
640        let mut state = self.inbound_endpoint_rx.lock().await;
641        state.drain_pending_into(messages, max);
642
643        while messages.len() < max {
644            let event = if messages.is_empty() {
645                state.rx.recv().await?
646            } else {
647                match state.rx.try_recv() {
648                    Ok(event) => event,
649                    Err(_) => break,
650                }
651            };
652            state.push_event_into(event, messages, max);
653        }
654
655        Some(messages.len())
656    }
657
658    /// Synchronous blocking send — parks the calling **OS thread** on
659    /// the FIPS endpoint data batch channel until the runtime accepts
660    /// the send. MUST be called only from a thread spawned via
661    /// `std::thread::spawn`, not from inside a tokio runtime.
662    ///
663    /// Companion to [`Self::blocking_recv`] for control-frame replies
664    /// (e.g. responding to a Ping with a Pong) issued from the
665    /// dedicated TUN-write thread. Failures are returned via
666    /// `FipsEndpointError::Closed` if the runtime has stopped.
667    pub fn blocking_send(
668        &self,
669        remote_npub: impl Into<String>,
670        data: impl Into<Vec<u8>>,
671    ) -> Result<(), FipsEndpointError> {
672        let remote_npub = remote_npub.into();
673        let data = data.into();
674        if remote_npub == self.npub {
675            return self.send_loopback(data);
676        }
677        let remote = self.resolve_peer_identity(&remote_npub)?;
678        self.blocking_send_to_peer(remote, data)
679    }
680
681    /// Synchronous blocking send to a resolved remote identity.
682    ///
683    /// This mirrors [`Self::send_to_peer`] for callers that already own a
684    /// `PeerIdentity` but need to use the blocking endpoint data path.
685    pub fn blocking_send_to_peer(
686        &self,
687        remote: PeerIdentity,
688        data: impl Into<Vec<u8>>,
689    ) -> Result<(), FipsEndpointError> {
690        let data = data.into();
691        if *remote.node_addr() == self.node_addr {
692            return self.send_loopback(data);
693        }
694        if let Some(batch) =
695            NodeEndpointDataBatch::batch(remote, vec![data], crate::perf_profile::stamp())
696        {
697            self.endpoint_data_batches
698                .send_or_drop(batch)
699                .map_err(|_| FipsEndpointError::Closed)?;
700        }
701        Ok(())
702    }
703
704    /// Synchronous blocking batch send to one resolved remote identity.
705    ///
706    /// This is the blocking-thread counterpart to [`Self::send_batch_to_peer`].
707    /// The caller keeps routing authority: FIPS only receives already-owned
708    /// endpoint payloads for the resolved peer.
709    pub fn blocking_send_batch_to_peer(
710        &self,
711        remote: PeerIdentity,
712        payloads: Vec<Vec<u8>>,
713    ) -> Result<(), FipsEndpointError> {
714        if *remote.node_addr() == self.node_addr {
715            for payload in payloads {
716                self.send_loopback(payload)?;
717            }
718            return Ok(());
719        }
720
721        if payloads.is_empty() {
722            return Ok(());
723        }
724        self.send_endpoint_data_batch(remote, payloads)
725    }
726
727    /// Synchronous blocking send of prebuilt endpoint bulk bodies to one resolved peer.
728    pub fn blocking_send_endpoint_bulk_data_batch_to_peer(
729        &self,
730        remote: PeerIdentity,
731        bulk_bodies: Vec<FipsEndpointBulkData>,
732    ) -> Result<(), FipsEndpointError> {
733        self.send_endpoint_bulk_data_batch(remote, bulk_bodies)
734    }
735
736    /// Synchronous blocking receive — parks the calling **OS thread**
737    /// on the channel until an event arrives or the channel closes.
738    ///
739    /// MUST NOT be called from inside a tokio runtime; use this only
740    /// from a thread spawned via `std::thread::spawn` so the tokio
741    /// scheduler doesn't deadlock.
742    ///
743    /// The motivation is the bench's CLI receive task: when run as a
744    /// regular tokio task each `recv().await` is a full task-wake on
745    /// the runtime (~1–3 µs scheduler bookkeeping), and at 113 kpps
746    /// that's ~10–30% of one core spent in plumbing the wake-up
747    /// rather than writing the packet to TUN. A dedicated OS thread
748    /// blocked on the channel via `blocking_recv` parks on a futex
749    /// directly — the wake is a single futex_wake() with no scheduler
750    /// involvement, an order of magnitude cheaper.
751    pub fn blocking_recv(&self) -> Option<FipsEndpointMessage> {
752        let mut state = self.inbound_endpoint_rx.blocking_lock();
753        if let Some(message) = state.pop_pending() {
754            return Some(message);
755        }
756        let event = state.rx.blocking_recv()?;
757        state.first_from_event(event)
758    }
759
760    /// Synchronous blocking batch receive into a caller-owned buffer.
761    ///
762    /// This is the blocking-thread counterpart to [`Self::recv_batch_into`]:
763    /// it parks the calling **OS thread** for the first message, then drains
764    /// ready follow-ons while holding the endpoint receiver lock. MUST NOT be
765    /// called from inside a tokio runtime; use this only from a dedicated
766    /// blocking thread.
767    pub fn blocking_recv_batch_into(
768        &self,
769        messages: &mut Vec<FipsEndpointMessage>,
770        max: usize,
771    ) -> Option<usize> {
772        messages.clear();
773        self.blocking_recv_batch_for_each(max, |message| {
774            messages.push(message);
775            true
776        })
777    }
778
779    /// Synchronous blocking batch receive that invokes a callback for each
780    /// delivered endpoint message without staging them in a caller-owned
781    /// `Vec`.
782    ///
783    /// This is for dedicated packet-mover threads that immediately forward
784    /// messages onward. It preserves internal batch-tail handling and the receive limit as
785    /// [`Self::blocking_recv_batch_into`]. Returning `false` from the callback
786    /// stops the current drain after that message; any unconsumed messages from
787    /// the current internal batch are retained for the next receive.
788    pub fn blocking_recv_batch_for_each(
789        &self,
790        max: usize,
791        mut handle_message: impl FnMut(FipsEndpointMessage) -> bool,
792    ) -> Option<usize> {
793        let max = max.clamp(1, ENDPOINT_RECV_BATCH_MAX);
794        let mut drained = 0usize;
795
796        let mut state = self.inbound_endpoint_rx.blocking_lock();
797        if !state.drain_pending_for_each(&mut drained, max, &mut handle_message) {
798            return Some(drained);
799        }
800
801        while drained < max {
802            let event = if drained == 0 {
803                state.rx.blocking_recv()?
804            } else {
805                match state.rx.try_recv() {
806                    Ok(event) => event,
807                    Err(_) => break,
808                }
809            };
810            if !state.push_event_for_each(event, &mut drained, max, &mut handle_message) {
811                return Some(drained);
812            }
813        }
814
815        Some(drained)
816    }
817
818    /// Non-blocking receive — returns the next ready endpoint message
819    /// if one is queued, otherwise `None`. Pair with `recv()` to drain
820    /// follow-on packets without paying a scheduler wake per packet:
821    ///
822    /// ```ignore
823    /// // wake on the first packet, then drain everything ready
824    /// while let Some(msg) = endpoint.recv().await { process(msg); }
825    /// while let Some(msg) = endpoint.try_recv() { process(msg); }
826    /// ```
827    ///
828    /// On the bench's FIPS-tunnel receive path the kernel UDP socket
829    /// delivers packets in `recvmmsg`-sized bursts, so after a `.recv()`
830    /// await there are typically 5–30 packets queued waiting. Draining
831    /// them inline with `try_recv` saves N-1 scheduler hops per burst
832    /// at line rate, freeing the consumer task to spend its time on
833    /// the TUN write syscall instead of cross-task plumbing.
834    ///
835    /// Returns `None` if the channel is empty, closed, or briefly
836    /// contested by another consumer.
837    pub fn try_recv(&self) -> Option<FipsEndpointMessage> {
838        let mut state = self.inbound_endpoint_rx.try_lock().ok()?;
839        if let Some(message) = state.pop_pending() {
840            return Some(message);
841        }
842        let event = state.rx.try_recv().ok()?;
843        state.first_from_event(event)
844    }
845
846    /// Replace the runtime peer list. Newly added auto-connect peers get
847    /// dialed immediately using every known address (overlay-fresh first,
848    /// then operator/cache hints). Removed peers are dropped from the
849    /// retry queue but stay connected if they currently are — the regular
850    /// liveness timeout reaps idle sessions. Existing entries get their
851    /// `addresses` field refreshed so the next retry sees the latest hints.
852    ///
853    /// Pass an empty `addresses` vector for a peer if you want fips to
854    /// resolve them entirely from the Nostr advert at dial time.
855    pub async fn update_peers(
856        &self,
857        peers: Vec<crate::config::PeerConfig>,
858    ) -> Result<UpdatePeersOutcome, FipsEndpointError> {
859        let (response_tx, response_rx) = oneshot::channel();
860        self.endpoint_control_tx
861            .send(NodeEndpointControlCommand::UpdatePeers { peers, response_tx })
862            .await
863            .map_err(|_| FipsEndpointError::Closed)?;
864
865        match response_rx.await.map_err(|_| FipsEndpointError::Closed)? {
866            Ok(outcome) => Ok(UpdatePeersOutcome::from(outcome)),
867            Err(error) => Err(FipsEndpointError::Node(error)),
868        }
869    }
870
871    /// Force immediate direct-path refresh attempts for configured peers.
872    ///
873    /// Unlike [`FipsEndpoint::update_peers`], this does not require a config
874    /// diff. It asks the running node to race a fresh direct handshake for the
875    /// supplied active peers while preserving existing sessions and routes.
876    pub async fn refresh_peer_paths(
877        &self,
878        peers: Vec<PeerIdentity>,
879    ) -> Result<usize, FipsEndpointError> {
880        let (response_tx, response_rx) = oneshot::channel();
881        let npubs = peers.into_iter().map(|peer| peer.npub()).collect();
882        self.endpoint_control_tx
883            .send(NodeEndpointControlCommand::RefreshPeerPaths { npubs, response_tx })
884            .await
885            .map_err(|_| FipsEndpointError::Closed)?;
886
887        match response_rx.await.map_err(|_| FipsEndpointError::Closed)? {
888            Ok(refreshed) => Ok(refreshed),
889            Err(error) => Err(FipsEndpointError::Node(error)),
890        }
891    }
892
893    /// Snapshot authenticated peers known by the endpoint.
894    pub async fn peers(&self) -> Result<Vec<FipsEndpointPeer>, FipsEndpointError> {
895        let (response_tx, response_rx) = oneshot::channel();
896        self.endpoint_control_tx
897            .send(NodeEndpointControlCommand::PeerSnapshot { response_tx })
898            .await
899            .map_err(|_| FipsEndpointError::Closed)?;
900
901        response_rx
902            .await
903            .map(|peers| peers.into_iter().map(FipsEndpointPeer::from).collect())
904            .map_err(|_| FipsEndpointError::Closed)
905    }
906
907    /// Snapshot the endpoint addresses this node is currently advertising via
908    /// Nostr discovery.
909    pub async fn local_advertised_endpoints(
910        &self,
911    ) -> Result<Vec<crate::discovery::nostr::OverlayEndpointAdvert>, FipsEndpointError> {
912        let (response_tx, response_rx) = oneshot::channel();
913        self.endpoint_control_tx
914            .send(NodeEndpointControlCommand::LocalAdvertSnapshot { response_tx })
915            .await
916            .map_err(|_| FipsEndpointError::Closed)?;
917
918        response_rx.await.map_err(|_| FipsEndpointError::Closed)
919    }
920
921    /// Snapshot live Nostr relay states used by the embedded endpoint.
922    pub async fn relay_statuses(&self) -> Result<Vec<FipsEndpointRelayStatus>, FipsEndpointError> {
923        let (response_tx, response_rx) = oneshot::channel();
924        self.endpoint_control_tx
925            .send(NodeEndpointControlCommand::RelaySnapshot { response_tx })
926            .await
927            .map_err(|_| FipsEndpointError::Closed)?;
928
929        response_rx
930            .await
931            .map(|relays| {
932                relays
933                    .into_iter()
934                    .map(FipsEndpointRelayStatus::from)
935                    .collect()
936            })
937            .map_err(|_| FipsEndpointError::Closed)
938    }
939
940    /// Replace Nostr discovery relays without rebuilding the endpoint.
941    pub async fn update_relays(
942        &self,
943        advert_relays: Vec<String>,
944        dm_relays: Vec<String>,
945    ) -> Result<(), FipsEndpointError> {
946        let (response_tx, response_rx) = oneshot::channel();
947        self.endpoint_control_tx
948            .send(NodeEndpointControlCommand::UpdateRelays {
949                advert_relays,
950                dm_relays,
951                response_tx,
952            })
953            .await
954            .map_err(|_| FipsEndpointError::Closed)?;
955
956        response_rx
957            .await
958            .map_err(|_| FipsEndpointError::Closed)?
959            .map_err(FipsEndpointError::Node)
960    }
961
962    /// Send an outbound IPv6 packet into the FIPS session pipeline.
963    pub async fn send_ip_packet(
964        &self,
965        packet: impl Into<Vec<u8>>,
966    ) -> Result<(), FipsEndpointError> {
967        self.outbound_packets
968            .send(packet.into())
969            .await
970            .map_err(|_| FipsEndpointError::Closed)
971    }
972
973    /// Receive the next source-attributed IPv6 packet delivered by FIPS.
974    pub async fn recv_ip_packet(&self) -> Option<NodeDeliveredPacket> {
975        self.delivered_packets.lock().await.recv().await
976    }
977
978    /// Shut down the endpoint and wait for the node task to stop.
979    pub async fn shutdown(&self) -> Result<(), FipsEndpointError> {
980        let shutdown_tx = self
981            .shutdown_tx
982            .lock()
983            .map_err(|_| FipsEndpointError::Closed)?
984            .take();
985        if let Some(shutdown_tx) = shutdown_tx {
986            let _ = shutdown_tx.send(());
987        }
988        let task = self
989            .task
990            .lock()
991            .map_err(|_| FipsEndpointError::Closed)?
992            .take();
993        if let Some(task) = task {
994            task.await??;
995        }
996        Ok(())
997    }
998}
999
1000impl Drop for FipsEndpoint {
1001    fn drop(&mut self) {
1002        if let Ok(mut shutdown_tx) = self.shutdown_tx.lock()
1003            && let Some(shutdown_tx) = shutdown_tx.take()
1004        {
1005            let _ = shutdown_tx.send(());
1006        }
1007        if let Ok(mut task) = self.task.lock()
1008            && let Some(task) = task.take()
1009        {
1010            task.abort();
1011        }
1012    }
1013}