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