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