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