Skip to main content

fips_core/
endpoint.rs

1//! Library-first endpoint API for embedding FIPS in applications.
2//!
3//! This module exposes a no-system-TUN runtime shape for apps that want to own
4//! peer admission and local routing policy while reusing FIPS connectivity.
5
6use crate::config::{EthernetConfig, NostrDiscoveryPolicy, TransportInstances, UdpConfig};
7#[cfg(test)]
8use crate::node::ENDPOINT_EVENT_TEST_PAYLOAD_LEN;
9use crate::node::{
10    EndpointDataBatchTx, EndpointDataPayload, EndpointDirectSink, EndpointEventSender,
11    EndpointServiceEventSender, NodeEndpointControlCommand, NodeEndpointDataBatch,
12    NodeEndpointEvent,
13};
14use crate::upper::tun::TunOutboundTx;
15use crate::{
16    Config, FipsAddress, IdentityConfig, Node, NodeAddr, NodeDeliveredPacket, NodeError,
17    PeerIdentity,
18};
19use std::collections::HashMap;
20use std::sync::{Arc, Mutex as StdMutex};
21use std::time::Duration;
22use thiserror::Error;
23use tokio::sync::{Mutex, mpsc, oneshot};
24use tokio::task::JoinHandle;
25
26const ENDPOINT_DATA_BATCH_MAX: usize = 128;
27const ENDPOINT_RECV_BATCH_MAX: usize = 128;
28const ENDPOINT_OPERATION_TIMEOUT: Duration = Duration::from_secs(5);
29
30mod builder;
31mod receive;
32mod service_receiver;
33mod status;
34
35#[cfg(test)]
36mod tests;
37
38pub use crate::node::{
39    FIPS_ENDPOINT_DIRECT_PACKET_QUEUE_MAX_PACKETS, FIPS_ENDPOINT_DIRECT_PACKET_RUN_MAX_PACKETS,
40    FipsEndpointDirectDeliveryError, FipsEndpointDirectPacketBatch, FipsEndpointDirectPacketRun,
41    FipsEndpointDirectReceiver, FipsEndpointDirectSink,
42};
43pub use builder::FipsEndpointBuilder;
44use receive::{EndpointReceiveState, ServiceReceiveState};
45pub use status::{FipsEndpointPeer, FipsEndpointRelayStatus};
46
47/// Endpoint data bytes delivered by FIPS.
48///
49/// This is the same pooled packet owner used by the transport/dataplane, so
50/// embedders can forward endpoint data without forcing another hot-path copy.
51pub type FipsEndpointData = crate::transport::PacketBuffer;
52
53/// Errors returned by the endpoint API.
54#[derive(Debug, Error)]
55pub enum FipsEndpointError {
56    #[error("node error: {0}")]
57    Node(#[from] NodeError),
58
59    #[error("endpoint task failed: {0}")]
60    TaskJoin(#[from] tokio::task::JoinError),
61
62    #[error("endpoint is closed")]
63    Closed,
64
65    #[error("endpoint {operation} timed out")]
66    Timeout { operation: &'static str },
67
68    #[error("endpoint data payload is too large: {len} bytes exceeds max {max} bytes")]
69    EndpointDataTooLarge { len: usize, max: usize },
70
71    #[error("service datagram payload is too large: {len} bytes exceeds max {max} bytes")]
72    ServiceDatagramTooLarge { len: usize, max: usize },
73
74    #[error("FSP service port {port} is reserved")]
75    ServicePortReserved { port: u16 },
76
77    #[error("FSP service port {port} is already registered")]
78    ServicePortAlreadyRegistered { port: u16 },
79}
80
81/// Source-attributed endpoint data delivered to an embedded application.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct FipsEndpointMessage {
84    /// Authenticated FIPS peer that originated the endpoint data.
85    pub source_peer: PeerIdentity,
86    /// Application-owned payload bytes.
87    pub data: FipsEndpointData,
88    /// Unix-millisecond time when FIPS queued this message for the embedder.
89    pub enqueued_at_ms: u64,
90}
91
92/// One owned outbound FSP DataPacket service payload.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct FipsEndpointOutboundDatagram {
95    pub source_port: u16,
96    pub destination_port: u16,
97    pub data: Vec<u8>,
98}
99
100impl FipsEndpointOutboundDatagram {
101    pub fn new(source_port: u16, destination_port: u16, data: Vec<u8>) -> Self {
102        Self {
103            source_port,
104            destination_port,
105            data,
106        }
107    }
108}
109
110/// Authenticated FSP DataPacket service payload delivered to an embedder.
111#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct FipsEndpointServiceDatagram {
113    pub source_peer: PeerIdentity,
114    pub source_port: u16,
115    pub destination_port: u16,
116    pub data: FipsEndpointData,
117    pub enqueued_at_ms: u64,
118}
119
120/// Port-scoped receiver for one registered FSP service.
121///
122/// Unlike [`FipsEndpoint::recv_service_datagram_batch_into`], this receiver
123/// cannot consume datagrams registered by another service owner.
124pub struct FipsEndpointServiceReceiver {
125    state: Mutex<ServiceReceiveState>,
126}
127
128/// Reports what changed in response to [`FipsEndpoint::update_peers`].
129#[derive(Debug, Clone, Default, PartialEq, Eq)]
130pub struct UpdatePeersOutcome {
131    /// Number of npubs that were not previously in the runtime peer list
132    /// and got an `initiate_peer_connection` call.
133    pub added: usize,
134    /// Number of npubs that were dropped from the runtime peer list. Their
135    /// retry entries are gone; any active session stays up until the
136    /// regular liveness timeout reaps it.
137    pub removed: usize,
138    /// Number of npubs that were already in the list but had a different
139    /// `addresses`, `alias`, `connect_policy`, or `auto_reconnect` value.
140    /// The new values are now in effect for retries and aliasing; refreshed
141    /// direct addresses may also trigger a new direct dial for auto peers.
142    pub updated: usize,
143    /// Number of npubs that were in the list and identical to the new entry.
144    pub unchanged: usize,
145}
146
147impl From<crate::node::UpdatePeersOutcome> for UpdatePeersOutcome {
148    fn from(value: crate::node::UpdatePeersOutcome) -> Self {
149        Self {
150            added: value.added,
151            removed: value.removed,
152            updated: value.updated,
153            unchanged: value.unchanged,
154        }
155    }
156}
157
158fn apply_default_scoped_discovery(config: &mut Config, scope: &str) {
159    if config.node.discovery.nostr.enabled || !config.transports.is_empty() {
160        return;
161    }
162
163    config.node.discovery.nostr.enabled = true;
164    config.node.discovery.nostr.advertise = true;
165    config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
166    config.node.discovery.nostr.share_local_candidates = true;
167    config.node.discovery.nostr.app = scope.to_string();
168    config.node.discovery.lan.scope = Some(scope.to_string());
169    config.node.discovery.local.enabled = true;
170    config.transports.udp = TransportInstances::Single(UdpConfig {
171        bind_addr: Some("0.0.0.0:0".to_string()),
172        advertise_on_nostr: Some(true),
173        public: Some(false),
174        outbound_only: Some(false),
175        accept_connections: Some(true),
176        ..UdpConfig::default()
177    });
178}
179
180fn endpoint_ethernet_config(interface: &str, scope: Option<&str>) -> EthernetConfig {
181    EthernetConfig {
182        interface: interface.to_string(),
183        discovery: Some(true),
184        announce: Some(true),
185        auto_connect: Some(true),
186        accept_connections: Some(true),
187        discovery_scope: scope
188            .map(str::trim)
189            .filter(|s| !s.is_empty())
190            .map(str::to_string),
191        ..EthernetConfig::default()
192    }
193}
194
195fn add_endpoint_ethernet_transport(config: &mut Config, interface: &str, scope: Option<&str>) {
196    let eth = endpoint_ethernet_config(interface, scope);
197    if config.transports.ethernet.is_empty() {
198        config.transports.ethernet = TransportInstances::Single(eth);
199        return;
200    }
201
202    let existing = std::mem::take(&mut config.transports.ethernet);
203    let mut named = match existing {
204        TransportInstances::Single(config) => {
205            let mut map = std::collections::HashMap::new();
206            map.insert("default".to_string(), config);
207            map
208        }
209        TransportInstances::Named(map) => map,
210    };
211
212    let base_name = endpoint_ethernet_instance_name(interface);
213    let mut name = base_name.clone();
214    let mut suffix = 2usize;
215    while named.contains_key(&name) {
216        name = format!("{base_name}-{suffix}");
217        suffix += 1;
218    }
219    named.insert(name, eth);
220    config.transports.ethernet = TransportInstances::Named(named);
221}
222
223fn endpoint_ethernet_instance_name(interface: &str) -> String {
224    let suffix: String = interface
225        .chars()
226        .map(|c| {
227            if c.is_ascii_alphanumeric() {
228                c.to_ascii_lowercase()
229            } else {
230                '-'
231            }
232        })
233        .collect();
234    let suffix = suffix.trim_matches('-');
235    if suffix.is_empty() {
236        "local-ethernet".to_string()
237    } else {
238        format!("local-ethernet-{suffix}")
239    }
240}
241
242fn endpoint_data_payloads_from_vecs(
243    payloads: Vec<Vec<u8>>,
244) -> Result<Vec<EndpointDataPayload>, FipsEndpointError> {
245    let mut converted = Vec::with_capacity(payloads.len());
246    for payload in payloads {
247        let len = payload.len();
248        let Some(payload) = EndpointDataPayload::from_packet_payload(payload) else {
249            let max = crate::node::session_wire::fsp_endpoint_data_max_body_len();
250            return Err(FipsEndpointError::EndpointDataTooLarge { len, max });
251        };
252        converted.push(payload);
253    }
254    Ok(converted)
255}
256
257fn service_datagram_payloads(
258    datagrams: Vec<FipsEndpointOutboundDatagram>,
259) -> Result<Vec<EndpointDataPayload>, FipsEndpointError> {
260    let max = crate::node::session_wire::fsp_service_datagram_max_body_len();
261    let mut payloads = Vec::with_capacity(datagrams.len());
262    for datagram in datagrams {
263        let len = datagram.data.len();
264        let Some(payload) = EndpointDataPayload::from_service_datagram(
265            datagram.source_port,
266            datagram.destination_port,
267            datagram.data,
268        ) else {
269            return Err(FipsEndpointError::ServiceDatagramTooLarge { len, max });
270        };
271        payloads.push(payload);
272    }
273    Ok(payloads)
274}
275
276fn spawn_node_task(
277    mut node: Node,
278    shutdown_rx: oneshot::Receiver<()>,
279) -> JoinHandle<Result<(), NodeError>> {
280    tokio::spawn(async move {
281        tokio::pin!(shutdown_rx);
282        let loop_result = tokio::select! {
283            result = node.run_rx_loop() => result,
284            _ = &mut shutdown_rx => Ok(()),
285        };
286        let stop_result = if node.state().can_stop() {
287            node.stop().await
288        } else {
289            Ok(())
290        };
291        loop_result?;
292        stop_result
293    })
294}
295
296/// A running embedded FIPS endpoint.
297pub struct FipsEndpoint {
298    identity: PeerIdentity,
299    npub: String,
300    node_addr: NodeAddr,
301    address: FipsAddress,
302    discovery_scope: Option<String>,
303    outbound_packets: TunOutboundTx,
304    delivered_packets: Arc<Mutex<mpsc::Receiver<NodeDeliveredPacket>>>,
305    endpoint_control_tx: mpsc::Sender<NodeEndpointControlCommand>,
306    endpoint_data_batches: EndpointDataBatchTx,
307    /// In-process loopback sender for local peer sends. It injects an event into
308    /// the same queue without going through the wire/encrypt path. The node's
309    /// rx_loop also sends into this channel directly (it holds a clone of this
310    /// sender) so there is no per-packet relay task between the node task and
311    /// `recv_batch_into()`.
312    inbound_endpoint_tx: EndpointEventSender,
313    /// Unbounded receiver plus pending tail from an internal batch. This was
314    /// previously fed by a per-packet relay task
315    /// that translated node endpoint events into `FipsEndpointMessage`
316    /// across an additional bounded mpsc; collapsed into a single channel
317    /// -- the translation happens inline in `recv()` and the second hop
318    /// (with its scheduler wake per packet) is gone.
319    inbound_endpoint_rx: Arc<Mutex<EndpointReceiveState>>,
320    inbound_service_tx: EndpointServiceEventSender,
321    inbound_service_rx: Arc<Mutex<ServiceReceiveState>>,
322    registered_services: Arc<StdMutex<HashMap<u16, EndpointServiceEventSender>>>,
323    service_channel_capacity: usize,
324    shutdown_tx: StdMutex<Option<oneshot::Sender<()>>>,
325    task: StdMutex<Option<JoinHandle<Result<(), NodeError>>>>,
326}
327
328impl FipsEndpoint {
329    /// Create a builder for an embedded endpoint.
330    pub fn builder() -> FipsEndpointBuilder {
331        FipsEndpointBuilder::default()
332    }
333
334    async fn control<T>(
335        &self,
336        operation: &'static str,
337        command: NodeEndpointControlCommand,
338        response_rx: oneshot::Receiver<T>,
339    ) -> Result<T, FipsEndpointError> {
340        tokio::time::timeout(ENDPOINT_OPERATION_TIMEOUT, async {
341            self.endpoint_control_tx
342                .send(command)
343                .await
344                .map_err(|_| FipsEndpointError::Closed)?;
345            response_rx.await.map_err(|_| FipsEndpointError::Closed)
346        })
347        .await
348        .map_err(|_| FipsEndpointError::Timeout { operation })?
349    }
350
351    /// Local endpoint npub.
352    pub fn npub(&self) -> &str {
353        &self.npub
354    }
355
356    /// Local FIPS node address.
357    pub fn node_addr(&self) -> &NodeAddr {
358        &self.node_addr
359    }
360
361    /// Local FIPS IPv6-compatible address.
362    pub fn address(&self) -> FipsAddress {
363        self.address
364    }
365
366    /// Application-level discovery scope, if configured.
367    pub fn discovery_scope(&self) -> Option<&str> {
368        self.discovery_scope.as_deref()
369    }
370
371    /// Send application-owned endpoint payloads to one resolved peer.
372    ///
373    /// This is the canonical endpoint-data send path for applications that
374    /// already validate and cache peer identities in their own routing table.
375    /// It avoids per-packet npub allocation, endpoint cache lookup, and
376    /// `PeerIdentity::from_npub` parsing while preserving owned-payload
377    /// semantics.
378    pub async fn send_batch_to_peer(
379        &self,
380        remote: PeerIdentity,
381        payloads: Vec<Vec<u8>>,
382    ) -> Result<(), FipsEndpointError> {
383        self.send_payloads_to_peer(remote, payloads)
384    }
385
386    /// Register one local FSP DataPacket destination port.
387    ///
388    /// Port 256 remains reserved for the built-in IPv6 shim. Datagrams for
389    /// unregistered ports are discarded by the authenticated receive path.
390    pub async fn register_service(&self, port: u16) -> Result<(), FipsEndpointError> {
391        self.register_service_with_sender(port, self.inbound_service_tx.clone())
392            .await
393    }
394
395    /// Register one local FSP service port with an isolated receiver.
396    pub async fn register_service_receiver(
397        &self,
398        port: u16,
399    ) -> Result<FipsEndpointServiceReceiver, FipsEndpointError> {
400        let (sender, receiver) = EndpointServiceEventSender::channel(self.service_channel_capacity);
401        self.register_service_with_sender(port, sender).await?;
402        Ok(FipsEndpointServiceReceiver {
403            state: Mutex::new(ServiceReceiveState::new(receiver)),
404        })
405    }
406
407    async fn register_service_with_sender(
408        &self,
409        port: u16,
410        sender: EndpointServiceEventSender,
411    ) -> Result<(), FipsEndpointError> {
412        if port == crate::node::session_wire::FSP_PORT_IPV6_SHIM {
413            return Err(FipsEndpointError::ServicePortReserved { port });
414        }
415
416        let (response_tx, response_rx) = oneshot::channel();
417        if !self
418            .control(
419                "service registration",
420                NodeEndpointControlCommand::RegisterService {
421                    port,
422                    sender: sender.clone(),
423                    response_tx,
424                },
425                response_rx,
426            )
427            .await?
428        {
429            return Err(FipsEndpointError::ServicePortAlreadyRegistered { port });
430        }
431        self.registered_services
432            .lock()
433            .map_err(|_| FipsEndpointError::Closed)?
434            .insert(port, sender);
435        Ok(())
436    }
437
438    /// Send one owned FSP DataPacket service payload to a resolved peer.
439    pub async fn send_datagram(
440        &self,
441        remote: PeerIdentity,
442        source_port: u16,
443        destination_port: u16,
444        payload: Vec<u8>,
445    ) -> Result<(), FipsEndpointError> {
446        self.send_service_datagrams_to_peer(
447            remote,
448            vec![FipsEndpointOutboundDatagram::new(
449                source_port,
450                destination_port,
451                payload,
452            )],
453        )
454    }
455
456    /// Send a caller-owned batch of FSP DataPacket service payloads to one peer.
457    pub async fn send_datagram_batch_to_peer(
458        &self,
459        remote: PeerIdentity,
460        datagrams: Vec<FipsEndpointOutboundDatagram>,
461    ) -> Result<(), FipsEndpointError> {
462        self.send_service_datagrams_to_peer(remote, datagrams)
463    }
464
465    fn send_service_datagrams_to_peer(
466        &self,
467        remote: PeerIdentity,
468        datagrams: Vec<FipsEndpointOutboundDatagram>,
469    ) -> Result<(), FipsEndpointError> {
470        let max = crate::node::session_wire::fsp_service_datagram_max_body_len();
471        if let Some(datagram) = datagrams.iter().find(|datagram| datagram.data.len() > max) {
472            return Err(FipsEndpointError::ServiceDatagramTooLarge {
473                len: datagram.data.len(),
474                max,
475            });
476        }
477        if datagrams.is_empty() {
478            return Ok(());
479        }
480
481        if *remote.node_addr() == self.node_addr {
482            let deliveries_by_port = {
483                let registered = self
484                    .registered_services
485                    .lock()
486                    .map_err(|_| FipsEndpointError::Closed)?;
487                let mut grouped: HashMap<
488                    u16,
489                    (
490                        EndpointServiceEventSender,
491                        Vec<crate::node::EndpointServiceDatagramDelivery>,
492                    ),
493                > = HashMap::new();
494                for datagram in datagrams {
495                    let Some(sender) = registered.get(&datagram.destination_port) else {
496                        continue;
497                    };
498                    grouped
499                        .entry(datagram.destination_port)
500                        .or_insert_with(|| (sender.clone(), Vec::new()))
501                        .1
502                        .push(crate::node::EndpointServiceDatagramDelivery::new(
503                            self.identity,
504                            datagram.source_port,
505                            datagram.destination_port,
506                            crate::transport::PacketBuffer::new(datagram.data),
507                        ));
508                }
509                grouped
510            };
511            for (_, (sender, deliveries)) in deliveries_by_port {
512                sender
513                    .send(deliveries)
514                    .map_err(|_| FipsEndpointError::Closed)?;
515            }
516            return Ok(());
517        }
518
519        self.send_endpoint_data_batch(remote, service_datagram_payloads(datagrams)?)
520    }
521
522    fn send_payloads_to_peer(
523        &self,
524        remote: PeerIdentity,
525        payloads: Vec<Vec<u8>>,
526    ) -> Result<(), FipsEndpointError> {
527        let payloads = endpoint_data_payloads_from_vecs(payloads)?;
528        if *remote.node_addr() == self.node_addr {
529            for payload in payloads {
530                self.send_loopback(payload)?;
531            }
532            return Ok(());
533        }
534
535        self.send_endpoint_data_batch(remote, payloads)
536    }
537
538    fn send_endpoint_data_batch(
539        &self,
540        remote: PeerIdentity,
541        payloads: Vec<EndpointDataPayload>,
542    ) -> Result<(), FipsEndpointError> {
543        if payloads.is_empty() {
544            return Ok(());
545        }
546
547        if payloads.len() <= ENDPOINT_DATA_BATCH_MAX {
548            self.enqueue_endpoint_data_batch(remote, payloads)?;
549            return Ok(());
550        }
551
552        let mut payloads = payloads.into_iter();
553        loop {
554            let payload_batch: Vec<_> = payloads.by_ref().take(ENDPOINT_DATA_BATCH_MAX).collect();
555            if payload_batch.is_empty() {
556                break;
557            }
558            self.enqueue_endpoint_data_batch(remote, payload_batch)?;
559        }
560        Ok(())
561    }
562
563    fn enqueue_endpoint_data_batch(
564        &self,
565        remote: PeerIdentity,
566        payload_batch: Vec<EndpointDataPayload>,
567    ) -> Result<(), FipsEndpointError> {
568        // Fire-and-forget: caller already drops the result, so skip
569        // the per-packet `oneshot::channel()` allocation entirely.
570        // Endpoint data now enters the dataplane bulk lane directly, without a
571        // per-packet oneshot or control-command hop.
572        if let Some(batch) = NodeEndpointDataBatch::from_payloads(
573            remote,
574            payload_batch,
575            crate::perf_profile::stamp(),
576        ) {
577            self.endpoint_data_batches
578                .send_or_drop(batch)
579                .map_err(|_| FipsEndpointError::Closed)?;
580        }
581        Ok(())
582    }
583
584    fn send_loopback(&self, payload: EndpointDataPayload) -> Result<(), FipsEndpointError> {
585        self.inbound_endpoint_tx
586            .send(NodeEndpointEvent {
587                messages: vec![crate::node::EndpointDataDelivery::new(
588                    self.identity,
589                    payload.into_body(),
590                )],
591                queued_at: crate::perf_profile::stamp(),
592            })
593            .map_err(|_| FipsEndpointError::Closed)
594    }
595
596    /// Receive one endpoint message, then drain ready follow-ons into a caller-owned buffer.
597    ///
598    /// This is the receive-side counterpart to [`Self::send_batch_to_peer`]:
599    /// callers still get individual source-attributed messages, but a hot
600    /// dataplane consumer can amortize the endpoint receiver lock, task wake,
601    /// and message buffer allocation across a bounded burst.
602    pub async fn recv_batch_into(
603        &self,
604        messages: &mut Vec<FipsEndpointMessage>,
605        max: usize,
606    ) -> Option<usize> {
607        let max = max.clamp(1, ENDPOINT_RECV_BATCH_MAX);
608        messages.clear();
609
610        let mut state = self.inbound_endpoint_rx.lock().await;
611        state.drain_pending_into(messages, max);
612
613        while messages.len() < max {
614            let event = if messages.is_empty() {
615                state.rx.recv().await?
616            } else {
617                match state.rx.try_recv() {
618                    Ok(event) => event,
619                    Err(_) => break,
620                }
621            };
622            state.push_event_into(event, messages, max);
623        }
624
625        Some(messages.len())
626    }
627
628    /// Receive one registered service datagram and drain ready follow-ons.
629    pub async fn recv_service_datagram_batch_into(
630        &self,
631        datagrams: &mut Vec<FipsEndpointServiceDatagram>,
632        max: usize,
633    ) -> Option<usize> {
634        let max = max.clamp(1, ENDPOINT_RECV_BATCH_MAX);
635        datagrams.clear();
636
637        let mut state = self.inbound_service_rx.lock().await;
638        state.drain_pending_into(datagrams, max);
639        while datagrams.len() < max {
640            let event = if datagrams.is_empty() {
641                state.rx.recv().await?
642            } else {
643                match state.rx.try_recv() {
644                    Ok(event) => event,
645                    Err(_) => break,
646                }
647            };
648            state.push_event_into(event, datagrams, max);
649        }
650        Some(datagrams.len())
651    }
652
653    /// Synchronous blocking batch send to one resolved remote identity.
654    ///
655    /// This is the blocking-thread counterpart to [`Self::send_batch_to_peer`].
656    /// The caller keeps routing authority: FIPS only receives already-owned
657    /// endpoint payloads for the resolved peer.
658    pub fn blocking_send_batch_to_peer(
659        &self,
660        remote: PeerIdentity,
661        payloads: Vec<Vec<u8>>,
662    ) -> Result<(), FipsEndpointError> {
663        self.send_payloads_to_peer(remote, payloads)
664    }
665
666    /// Synchronous blocking send of one FSP DataPacket service payload.
667    pub fn blocking_send_datagram(
668        &self,
669        remote: PeerIdentity,
670        source_port: u16,
671        destination_port: u16,
672        payload: Vec<u8>,
673    ) -> Result<(), FipsEndpointError> {
674        self.send_service_datagrams_to_peer(
675            remote,
676            vec![FipsEndpointOutboundDatagram::new(
677                source_port,
678                destination_port,
679                payload,
680            )],
681        )
682    }
683
684    /// Synchronous blocking send of an owned service datagram batch.
685    pub fn blocking_send_datagram_batch_to_peer(
686        &self,
687        remote: PeerIdentity,
688        datagrams: Vec<FipsEndpointOutboundDatagram>,
689    ) -> Result<(), FipsEndpointError> {
690        self.send_service_datagrams_to_peer(remote, datagrams)
691    }
692
693    /// Synchronous blocking batch receive into a caller-owned buffer.
694    ///
695    /// This is the blocking-thread counterpart to [`Self::recv_batch_into`]:
696    /// it parks the calling **OS thread** for the first message, then drains
697    /// ready follow-ons while holding the endpoint receiver lock. MUST NOT be
698    /// called from inside a tokio runtime; use this only from a dedicated
699    /// blocking thread.
700    pub fn blocking_recv_batch_into(
701        &self,
702        messages: &mut Vec<FipsEndpointMessage>,
703        max: usize,
704    ) -> Option<usize> {
705        let max = max.clamp(1, ENDPOINT_RECV_BATCH_MAX);
706        messages.clear();
707
708        let mut state = self.inbound_endpoint_rx.blocking_lock();
709        state.drain_pending_into(messages, max);
710
711        while messages.len() < max {
712            let event = if messages.is_empty() {
713                state.rx.blocking_recv()?
714            } else {
715                match state.rx.try_recv() {
716                    Ok(event) => event,
717                    Err(_) => break,
718                }
719            };
720            state.push_event_into(event, messages, max);
721        }
722
723        Some(messages.len())
724    }
725
726    /// Synchronous blocking receive of registered service datagrams.
727    pub fn blocking_recv_service_datagram_batch_into(
728        &self,
729        datagrams: &mut Vec<FipsEndpointServiceDatagram>,
730        max: usize,
731    ) -> Option<usize> {
732        let max = max.clamp(1, ENDPOINT_RECV_BATCH_MAX);
733        datagrams.clear();
734
735        let mut state = self.inbound_service_rx.blocking_lock();
736        state.drain_pending_into(datagrams, max);
737        while datagrams.len() < max {
738            let event = if datagrams.is_empty() {
739                state.rx.blocking_recv()?
740            } else {
741                match state.rx.try_recv() {
742                    Ok(event) => event,
743                    Err(_) => break,
744                }
745            };
746            state.push_event_into(event, datagrams, max);
747        }
748        Some(datagrams.len())
749    }
750
751    /// Replace the runtime peer list. Newly added auto-connect peers get
752    /// dialed immediately using every known address (overlay-fresh first,
753    /// then operator/cache hints). Removed peers are dropped from the
754    /// retry queue but stay connected if they currently are — the regular
755    /// liveness timeout reaps idle sessions. Existing entries get their
756    /// `addresses` field refreshed so the next retry sees the latest hints.
757    ///
758    /// Pass an empty `addresses` vector for a peer if you want fips to
759    /// resolve them entirely from the Nostr advert at dial time.
760    pub async fn update_peers(
761        &self,
762        peers: Vec<crate::config::PeerConfig>,
763    ) -> Result<UpdatePeersOutcome, FipsEndpointError> {
764        let (response_tx, response_rx) = oneshot::channel();
765        match self
766            .control(
767                "peer update",
768                NodeEndpointControlCommand::UpdatePeers { peers, response_tx },
769                response_rx,
770            )
771            .await?
772        {
773            Ok(outcome) => Ok(UpdatePeersOutcome::from(outcome)),
774            Err(error) => Err(FipsEndpointError::Node(error)),
775        }
776    }
777
778    /// Force immediate direct-path refresh attempts for configured peers.
779    ///
780    /// Unlike [`FipsEndpoint::update_peers`], this does not require a config
781    /// diff. It asks the running node to race a fresh direct handshake for the
782    /// supplied active peers while preserving existing sessions and routes.
783    /// Established end-to-end sessions also start an in-place recovery rekey
784    /// when rekeying is enabled; pending handshakes keep their retry state.
785    pub async fn refresh_peer_paths(
786        &self,
787        peers: Vec<PeerIdentity>,
788    ) -> Result<usize, FipsEndpointError> {
789        let (response_tx, response_rx) = oneshot::channel();
790        let npubs = peers.into_iter().map(|peer| peer.npub()).collect();
791        match self
792            .control(
793                "peer path refresh",
794                NodeEndpointControlCommand::RefreshPeerPaths { npubs, response_tx },
795                response_rx,
796            )
797            .await?
798        {
799            Ok(refreshed) => Ok(refreshed),
800            Err(error) => Err(FipsEndpointError::Node(error)),
801        }
802    }
803
804    /// Snapshot authenticated peers known by the endpoint.
805    pub async fn peers(&self) -> Result<Vec<FipsEndpointPeer>, FipsEndpointError> {
806        let (response_tx, response_rx) = oneshot::channel();
807        self.control(
808            "peer snapshot",
809            NodeEndpointControlCommand::PeerSnapshot { response_tx },
810            response_rx,
811        )
812        .await
813        .map(|peers| peers.into_iter().map(FipsEndpointPeer::from).collect())
814    }
815
816    /// Snapshot signed machine-rating events for peers with enough local
817    /// health evidence. Event signing remains inside the FIPS node identity.
818    pub async fn peer_rating_events(
819        &self,
820        scope: impl Into<String>,
821    ) -> Result<Vec<nostr::Event>, FipsEndpointError> {
822        let (response_tx, response_rx) = oneshot::channel();
823        self.control(
824            "peer rating snapshot",
825            NodeEndpointControlCommand::PeerRatingEvents {
826                scope: scope.into(),
827                response_tx,
828            },
829            response_rx,
830        )
831        .await?
832        .map_err(FipsEndpointError::Node)
833    }
834
835    /// Feed a signed Nostr discovery event received outside the endpoint's
836    /// relay client into the same advert/rating validation and caches.
837    ///
838    /// This transport-neutral boundary accepts kind 37195 peer adverts and
839    /// configured/trusted kind 7368 rating facts; unsupported, invalid, stale,
840    /// or disabled-discovery events return `false`.
841    pub async fn ingest_nostr_discovery_event(
842        &self,
843        event: nostr::Event,
844    ) -> Result<bool, FipsEndpointError> {
845        let (response_tx, response_rx) = oneshot::channel();
846        self.control(
847            "Nostr event ingest",
848            NodeEndpointControlCommand::IngestNostrDiscoveryEvent { event, response_tx },
849            response_rx,
850        )
851        .await
852    }
853
854    #[deprecated(since = "0.3.98", note = "use ingest_nostr_discovery_event")]
855    pub async fn ingest_nostr_pubsub_event(
856        &self,
857        event: nostr::Event,
858    ) -> Result<bool, FipsEndpointError> {
859        self.ingest_nostr_discovery_event(event).await
860    }
861
862    /// Snapshot the endpoint addresses this node is currently advertising via
863    /// Nostr discovery.
864    pub async fn local_advertised_endpoints(
865        &self,
866    ) -> Result<Vec<crate::discovery::nostr::OverlayEndpointAdvert>, FipsEndpointError> {
867        let (response_tx, response_rx) = oneshot::channel();
868        self.control(
869            "local advert snapshot",
870            NodeEndpointControlCommand::LocalAdvertSnapshot { response_tx },
871            response_rx,
872        )
873        .await
874    }
875
876    /// Return the signed local peer advert for an external peerfinding provider.
877    ///
878    /// This only creates the ordinary kind 37195 event; it does not select or
879    /// contact relays. `None` means advertising is disabled or no local
880    /// transport currently has an advert-eligible endpoint.
881    pub async fn local_nostr_discovery_advert_event(
882        &self,
883    ) -> Result<Option<nostr::Event>, FipsEndpointError> {
884        let (response_tx, response_rx) = oneshot::channel();
885        match self
886            .control(
887                "local Nostr discovery advert",
888                NodeEndpointControlCommand::LocalNostrDiscoveryAdvertEvent { response_tx },
889                response_rx,
890            )
891            .await?
892        {
893            Ok(event) => Ok(event),
894            Err(error) => Err(FipsEndpointError::Node(error)),
895        }
896    }
897
898    /// Snapshot live Nostr relay states used by the embedded endpoint.
899    pub async fn relay_statuses(&self) -> Result<Vec<FipsEndpointRelayStatus>, FipsEndpointError> {
900        let (response_tx, response_rx) = oneshot::channel();
901        self.control(
902            "relay snapshot",
903            NodeEndpointControlCommand::RelaySnapshot { response_tx },
904            response_rx,
905        )
906        .await
907        .map(|relays| {
908            relays
909                .into_iter()
910                .map(FipsEndpointRelayStatus::from)
911                .collect()
912        })
913    }
914
915    /// Replace Nostr discovery relays without rebuilding the endpoint.
916    pub async fn update_relays(
917        &self,
918        advert_relays: Vec<String>,
919        dm_relays: Vec<String>,
920    ) -> Result<(), FipsEndpointError> {
921        let (response_tx, response_rx) = oneshot::channel();
922        self.control(
923            "relay update",
924            NodeEndpointControlCommand::UpdateRelays {
925                advert_relays,
926                dm_relays,
927                response_tx,
928            },
929            response_rx,
930        )
931        .await?
932        .map_err(FipsEndpointError::Node)
933    }
934
935    /// Send an outbound IPv6 packet into the FIPS session pipeline.
936    pub async fn send_ip_packet(
937        &self,
938        packet: impl Into<Vec<u8>>,
939    ) -> Result<(), FipsEndpointError> {
940        self.outbound_packets
941            .send(packet.into())
942            .await
943            .map_err(|_| FipsEndpointError::Closed)
944    }
945
946    /// Receive the next source-attributed IPv6 packet delivered by FIPS.
947    pub async fn recv_ip_packet(&self) -> Option<NodeDeliveredPacket> {
948        self.delivered_packets.lock().await.recv().await
949    }
950
951    /// Shut down the endpoint and wait for the node task to stop.
952    pub async fn shutdown(&self) -> Result<(), FipsEndpointError> {
953        let shutdown_tx = self
954            .shutdown_tx
955            .lock()
956            .map_err(|_| FipsEndpointError::Closed)?
957            .take();
958        if let Some(shutdown_tx) = shutdown_tx {
959            let _ = shutdown_tx.send(());
960        }
961        let task = self
962            .task
963            .lock()
964            .map_err(|_| FipsEndpointError::Closed)?
965            .take();
966        if let Some(mut task) = task {
967            match tokio::time::timeout(ENDPOINT_OPERATION_TIMEOUT, &mut task).await {
968                Ok(result) => result??,
969                Err(_) => {
970                    task.abort();
971                    let _ = task.await;
972                    return Err(FipsEndpointError::Timeout {
973                        operation: "shutdown",
974                    });
975                }
976            }
977        }
978        Ok(())
979    }
980}
981
982impl Drop for FipsEndpoint {
983    fn drop(&mut self) {
984        if let Ok(mut shutdown_tx) = self.shutdown_tx.lock()
985            && let Some(shutdown_tx) = shutdown_tx.take()
986        {
987            let _ = shutdown_tx.send(());
988        }
989        if let Ok(mut task) = self.task.lock()
990            && let Some(task) = task.take()
991        {
992            task.abort();
993        }
994    }
995}