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