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