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