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