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};
7use crate::node::{
8    NodeEndpointCommand, NodeEndpointEvent, NodeEndpointPeer, NodeEndpointRelayStatus,
9};
10use crate::{
11    Config, FipsAddress, IdentityConfig, Node, NodeAddr, NodeDeliveredPacket, NodeError,
12    PeerIdentity,
13};
14use std::sync::Arc;
15use thiserror::Error;
16use tokio::sync::{Mutex, mpsc, oneshot};
17use tokio::task::JoinHandle;
18
19#[cfg(debug_assertions)]
20fn endpoint_debug_log(message: impl AsRef<str>) {
21    use std::io::Write as _;
22
23    if let Ok(mut file) = std::fs::OpenOptions::new()
24        .create(true)
25        .append(true)
26        .open(std::env::temp_dir().join("nvpn-fips-endpoint-debug.log"))
27    {
28        let _ = writeln!(
29            file,
30            "{:?} {}",
31            std::time::SystemTime::now(),
32            message.as_ref()
33        );
34    }
35}
36
37#[cfg(not(debug_assertions))]
38fn endpoint_debug_log(_message: impl AsRef<str>) {}
39
40/// Errors returned by the endpoint API.
41#[derive(Debug, Error)]
42pub enum FipsEndpointError {
43    #[error("node error: {0}")]
44    Node(#[from] NodeError),
45
46    #[error("endpoint task failed: {0}")]
47    TaskJoin(#[from] tokio::task::JoinError),
48
49    #[error("endpoint is closed")]
50    Closed,
51
52    #[error("invalid remote npub '{npub}': {reason}")]
53    InvalidRemoteNpub { npub: String, reason: String },
54}
55
56/// Source-attributed endpoint data delivered to an embedded application.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct FipsEndpointMessage {
59    /// FIPS node address that originated the endpoint data.
60    pub source_node_addr: NodeAddr,
61    /// Source Nostr public key when the node has learned it.
62    pub source_npub: Option<String>,
63    /// Application-owned payload bytes.
64    pub data: Vec<u8>,
65}
66
67/// Reports what changed in response to [`FipsEndpoint::update_peers`].
68#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct UpdatePeersOutcome {
70    /// Number of npubs that were not previously in the runtime peer list
71    /// and got an `initiate_peer_connection` call.
72    pub added: usize,
73    /// Number of npubs that were dropped from the runtime peer list. Their
74    /// retry entries are gone; any active session stays up until the
75    /// regular liveness timeout reaps it.
76    pub removed: usize,
77    /// Number of npubs that were already in the list but had a different
78    /// `addresses`, `alias`, `connect_policy`, or `auto_reconnect` value.
79    /// The new values are now in effect for retries and aliasing; refreshed
80    /// direct addresses may also trigger a new direct dial for auto peers.
81    pub updated: usize,
82    /// Number of npubs that were in the list and identical to the new entry.
83    pub unchanged: usize,
84}
85
86impl From<crate::node::UpdatePeersOutcome> for UpdatePeersOutcome {
87    fn from(value: crate::node::UpdatePeersOutcome) -> Self {
88        Self {
89            added: value.added,
90            removed: value.removed,
91            updated: value.updated,
92            unchanged: value.unchanged,
93        }
94    }
95}
96
97/// Authenticated FIPS peer state visible to an embedded application.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct FipsEndpointPeer {
100    /// Peer Nostr public key.
101    pub npub: String,
102    /// Current underlay transport address, when a link has authenticated.
103    pub transport_addr: Option<String>,
104    /// Current underlay transport kind, when known.
105    pub transport_type: Option<String>,
106    /// Authenticated link id.
107    pub link_id: u64,
108    /// Smoothed RTT in milliseconds, once measured by FIPS MMP.
109    pub srtt_ms: Option<u64>,
110    /// Link packets sent.
111    pub packets_sent: u64,
112    /// Link packets received.
113    pub packets_recv: u64,
114    /// Link bytes sent.
115    pub bytes_sent: u64,
116    /// Link bytes received.
117    pub bytes_recv: u64,
118    /// Whether direct UDP probing is queued while this peer may still be
119    /// reachable through a fallback transport.
120    pub direct_probe_pending: bool,
121    /// Millisecond timestamp when the queued direct probe becomes eligible.
122    pub direct_probe_after_ms: Option<u64>,
123}
124
125/// Live Nostr relay state visible to an embedded application.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct FipsEndpointRelayStatus {
128    pub url: String,
129    pub status: String,
130}
131
132/// Builder for an embedded FIPS endpoint.
133#[derive(Debug, Clone)]
134pub struct FipsEndpointBuilder {
135    config: Config,
136    identity_nsec: Option<String>,
137    discovery_scope: Option<String>,
138    local_ethernet_interfaces: Vec<String>,
139    disable_system_networking: bool,
140    packet_channel_capacity: usize,
141}
142
143impl Default for FipsEndpointBuilder {
144    fn default() -> Self {
145        Self {
146            config: Config::new(),
147            identity_nsec: None,
148            discovery_scope: None,
149            local_ethernet_interfaces: Vec::new(),
150            disable_system_networking: true,
151            packet_channel_capacity: 1024,
152        }
153    }
154}
155
156impl FipsEndpointBuilder {
157    /// Start from an explicit FIPS config.
158    pub fn config(mut self, config: Config) -> Self {
159        self.config = config;
160        self
161    }
162
163    /// Use an `nsec` or hex secret for the endpoint identity.
164    pub fn identity_nsec(mut self, nsec: impl Into<String>) -> Self {
165        self.identity_nsec = Some(nsec.into());
166        self
167    }
168
169    /// Set an application-level discovery scope.
170    ///
171    /// When the builder owns the default empty connectivity config, this also
172    /// enables scoped Nostr discovery, open same-scope peer discovery, local
173    /// LAN candidates, and a UDP NAT advert. If an explicit transport or
174    /// Nostr config was supplied, the explicit config is left in control and
175    /// the scope is retained as endpoint metadata.
176    pub fn discovery_scope(mut self, scope: impl Into<String>) -> Self {
177        self.discovery_scope = Some(scope.into());
178        self
179    }
180
181    /// Enable host-local Ethernet discovery on a private L2 interface.
182    ///
183    /// This is intended for veth/TAP interfaces attached to a per-host bridge
184    /// shared by FIPS-aware applications. The endpoint announces Ethernet
185    /// beacons, listens for matching peers, auto-connects to them, and accepts
186    /// inbound handshakes over the interface.
187    pub fn local_ethernet(mut self, interface: impl Into<String>) -> Self {
188        self.local_ethernet_interfaces.push(interface.into());
189        self
190    }
191
192    /// Disable FIPS-owned TUN and DNS system integration.
193    pub fn without_system_tun(mut self) -> Self {
194        self.disable_system_networking = true;
195        self
196    }
197
198    /// Set the app packet/data channel capacity.
199    pub fn packet_channel_capacity(mut self, capacity: usize) -> Self {
200        self.packet_channel_capacity = capacity.max(1);
201        self
202    }
203
204    fn prepared_config(&self) -> Config {
205        let mut config = self.config.clone();
206        if let Some(nsec) = &self.identity_nsec {
207            config.node.identity = IdentityConfig {
208                nsec: Some(nsec.clone()),
209                persistent: false,
210            };
211        }
212        if self.disable_system_networking {
213            config.tun.enabled = false;
214            config.dns.enabled = false;
215            config.node.system_files_enabled = false;
216        }
217        if let Some(scope) = self.discovery_scope.as_deref() {
218            config.node.discovery.lan.scope = Some(scope.to_string());
219            config.node.discovery.local.enabled = true;
220            apply_default_scoped_discovery(&mut config, scope);
221        }
222        for interface in &self.local_ethernet_interfaces {
223            add_endpoint_ethernet_transport(
224                &mut config,
225                interface,
226                self.discovery_scope.as_deref(),
227            );
228        }
229        config
230    }
231
232    /// Bind and start the embedded endpoint.
233    pub async fn bind(self) -> Result<FipsEndpoint, FipsEndpointError> {
234        endpoint_debug_log("FipsEndpointBuilder::bind begin");
235        let config = self.prepared_config();
236        endpoint_debug_log("FipsEndpointBuilder::bind config prepared");
237
238        let mut node = Node::new(config)?;
239        endpoint_debug_log("FipsEndpointBuilder::bind node created");
240        let npub = node.npub();
241        let node_addr = *node.node_addr();
242        let address = *node.identity().address();
243        let packet_io = node.attach_external_packet_io(self.packet_channel_capacity)?;
244        endpoint_debug_log("FipsEndpointBuilder::bind packet io attached");
245        let endpoint_data_io = node.attach_endpoint_data_io(self.packet_channel_capacity)?;
246        endpoint_debug_log("FipsEndpointBuilder::bind endpoint data io attached");
247        endpoint_debug_log("FipsEndpointBuilder::bind node.start begin");
248        node.start().await?;
249        endpoint_debug_log("FipsEndpointBuilder::bind node.start complete");
250
251        let (shutdown_tx, shutdown_rx) = oneshot::channel();
252        let task = spawn_node_task(node, shutdown_rx);
253        endpoint_debug_log("FipsEndpointBuilder::bind node task spawned");
254        let endpoint_commands = endpoint_data_io.command_tx;
255
256        Ok(FipsEndpoint {
257            npub,
258            node_addr,
259            address,
260            discovery_scope: self.discovery_scope,
261            outbound_packets: packet_io.outbound_tx,
262            delivered_packets: Arc::new(Mutex::new(packet_io.inbound_rx)),
263            endpoint_commands,
264            inbound_endpoint_tx: endpoint_data_io.event_tx,
265            inbound_endpoint_rx: Arc::new(Mutex::new(endpoint_data_io.event_rx)),
266            peer_identity_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
267            shutdown_tx: Some(shutdown_tx),
268            task,
269        })
270    }
271}
272
273fn apply_default_scoped_discovery(config: &mut Config, scope: &str) {
274    if config.node.discovery.nostr.enabled || !config.transports.is_empty() {
275        return;
276    }
277
278    config.node.discovery.nostr.enabled = true;
279    config.node.discovery.nostr.advertise = true;
280    config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
281    config.node.discovery.nostr.share_local_candidates = true;
282    config.node.discovery.nostr.app = scope.to_string();
283    config.node.discovery.lan.scope = Some(scope.to_string());
284    config.node.discovery.local.enabled = true;
285    config.transports.udp = TransportInstances::Single(UdpConfig {
286        bind_addr: Some("0.0.0.0:0".to_string()),
287        advertise_on_nostr: Some(true),
288        public: Some(false),
289        outbound_only: Some(false),
290        accept_connections: Some(true),
291        ..UdpConfig::default()
292    });
293}
294
295fn endpoint_ethernet_config(interface: &str, scope: Option<&str>) -> EthernetConfig {
296    EthernetConfig {
297        interface: interface.to_string(),
298        discovery: Some(true),
299        announce: Some(true),
300        auto_connect: Some(true),
301        accept_connections: Some(true),
302        discovery_scope: scope
303            .map(str::trim)
304            .filter(|s| !s.is_empty())
305            .map(str::to_string),
306        ..EthernetConfig::default()
307    }
308}
309
310fn add_endpoint_ethernet_transport(config: &mut Config, interface: &str, scope: Option<&str>) {
311    let eth = endpoint_ethernet_config(interface, scope);
312    if config.transports.ethernet.is_empty() {
313        config.transports.ethernet = TransportInstances::Single(eth);
314        return;
315    }
316
317    let existing = std::mem::take(&mut config.transports.ethernet);
318    let mut named = match existing {
319        TransportInstances::Single(config) => {
320            let mut map = std::collections::HashMap::new();
321            map.insert("default".to_string(), config);
322            map
323        }
324        TransportInstances::Named(map) => map,
325    };
326
327    let base_name = endpoint_ethernet_instance_name(interface);
328    let mut name = base_name.clone();
329    let mut suffix = 2usize;
330    while named.contains_key(&name) {
331        name = format!("{base_name}-{suffix}");
332        suffix += 1;
333    }
334    named.insert(name, eth);
335    config.transports.ethernet = TransportInstances::Named(named);
336}
337
338fn endpoint_ethernet_instance_name(interface: &str) -> String {
339    let suffix: String = interface
340        .chars()
341        .map(|c| {
342            if c.is_ascii_alphanumeric() {
343                c.to_ascii_lowercase()
344            } else {
345                '-'
346            }
347        })
348        .collect();
349    let suffix = suffix.trim_matches('-');
350    if suffix.is_empty() {
351        "local-ethernet".to_string()
352    } else {
353        format!("local-ethernet-{suffix}")
354    }
355}
356
357fn spawn_node_task(
358    mut node: Node,
359    shutdown_rx: oneshot::Receiver<()>,
360) -> JoinHandle<Result<(), NodeError>> {
361    tokio::spawn(async move {
362        tokio::pin!(shutdown_rx);
363        let loop_result = tokio::select! {
364            result = node.run_rx_loop() => result,
365            _ = &mut shutdown_rx => Ok(()),
366        };
367        let stop_result = if node.state().can_stop() {
368            node.stop().await
369        } else {
370            Ok(())
371        };
372        loop_result?;
373        stop_result
374    })
375}
376
377/// A running embedded FIPS endpoint.
378pub struct FipsEndpoint {
379    npub: String,
380    node_addr: NodeAddr,
381    address: FipsAddress,
382    discovery_scope: Option<String>,
383    outbound_packets: mpsc::Sender<Vec<u8>>,
384    delivered_packets: Arc<Mutex<mpsc::Receiver<NodeDeliveredPacket>>>,
385    endpoint_commands: mpsc::Sender<NodeEndpointCommand>,
386    /// In-process loopback sender — `send()` to our own npub injects an
387    /// event into the same queue without going through the wire/encrypt
388    /// path. The node's rx_loop also sends into this channel directly
389    /// (it holds a clone of this sender) so there is no per-packet relay
390    /// task between the node task and `recv()`.
391    inbound_endpoint_tx: mpsc::UnboundedSender<NodeEndpointEvent>,
392    /// Unbounded receiver. Was previously fed by a per-packet relay task
393    /// that translated `NodeEndpointEvent::Data` into `FipsEndpointMessage`
394    /// across an additional bounded mpsc; collapsed into a single channel
395    /// — the translation happens inline in `recv()` and the second hop
396    /// (with its scheduler wake per packet) is gone.
397    inbound_endpoint_rx: Arc<Mutex<mpsc::UnboundedReceiver<NodeEndpointEvent>>>,
398    /// Cache of resolved PeerIdentity by npub string. Avoids the per-packet
399    /// secp256k1 EC point parse that `PeerIdentity::from_npub` performs;
400    /// without this cache the bulk-data send hot path spends ~10–30% of CPU
401    /// re-validating identity bytes the application has already configured.
402    peer_identity_cache: std::sync::Mutex<std::collections::HashMap<String, PeerIdentity>>,
403    shutdown_tx: Option<oneshot::Sender<()>>,
404    task: JoinHandle<Result<(), NodeError>>,
405}
406
407impl FipsEndpoint {
408    /// Create a builder for an embedded endpoint.
409    pub fn builder() -> FipsEndpointBuilder {
410        FipsEndpointBuilder::default()
411    }
412
413    /// Local endpoint npub.
414    pub fn npub(&self) -> &str {
415        &self.npub
416    }
417
418    /// Local FIPS node address.
419    pub fn node_addr(&self) -> &NodeAddr {
420        &self.node_addr
421    }
422
423    /// Local FIPS IPv6-compatible address.
424    pub fn address(&self) -> FipsAddress {
425        self.address
426    }
427
428    /// Application-level discovery scope, if configured.
429    pub fn discovery_scope(&self) -> Option<&str> {
430        self.discovery_scope.as_deref()
431    }
432
433    /// Send application-owned endpoint data to a remote npub.
434    ///
435    /// Fire-and-forget: enqueues the Send command on the node task and
436    /// returns once the command channel accepts it. The node task's send
437    /// result is discarded — TCP and the upper protocol handle loss
438    /// recovery, and the per-packet oneshot round-trip the previous design
439    /// used for error reporting added several hundred microseconds of
440    /// queueing latency under load (measured: 456ms avg ping under iperf3
441    /// saturation → 1ms after this change, 430× lower).
442    ///
443    /// PeerIdentity for `remote_npub` is cached after first resolution to
444    /// avoid the secp256k1 EC point parse on every packet.
445    pub async fn send(
446        &self,
447        remote_npub: impl Into<String>,
448        data: impl Into<Vec<u8>>,
449    ) -> Result<(), FipsEndpointError> {
450        let remote_npub = remote_npub.into();
451        let data = data.into();
452        if remote_npub == self.npub {
453            self.inbound_endpoint_tx
454                .send(NodeEndpointEvent::Data {
455                    source_node_addr: self.node_addr,
456                    source_npub: Some(self.npub.clone()),
457                    payload: data,
458                    queued_at: crate::perf_profile::stamp(),
459                })
460                .map_err(|_| FipsEndpointError::Closed)?;
461            return Ok(());
462        }
463
464        let remote = self.resolve_peer_identity(&remote_npub)?;
465
466        // Fire-and-forget: caller already drops the result, so skip
467        // the per-packet `oneshot::channel()` allocation entirely.
468        // The node task's `SendOneway` arm runs the same code path as
469        // `Send` but without writing the result into a oneshot.
470        self.endpoint_commands
471            .send(NodeEndpointCommand::SendOneway {
472                remote,
473                payload: data,
474                queued_at: crate::perf_profile::stamp(),
475            })
476            .await
477            .map_err(|_| FipsEndpointError::Closed)?;
478        Ok(())
479    }
480
481    fn resolve_peer_identity(&self, remote_npub: &str) -> Result<PeerIdentity, FipsEndpointError> {
482        // Fast path: cached identity (PeerIdentity is Copy after eager
483        // pubkey_full precompute landed in b1e92af, so dereference is free).
484        if let Ok(cache) = self.peer_identity_cache.lock()
485            && let Some(remote) = cache.get(remote_npub)
486        {
487            return Ok(*remote);
488        }
489
490        let remote = PeerIdentity::from_npub(remote_npub).map_err(|error| {
491            FipsEndpointError::InvalidRemoteNpub {
492                npub: remote_npub.to_string(),
493                reason: error.to_string(),
494            }
495        })?;
496
497        if let Ok(mut cache) = self.peer_identity_cache.lock() {
498            cache.entry(remote_npub.to_string()).or_insert(remote);
499        }
500        Ok(remote)
501    }
502
503    /// Receive the next source-attributed endpoint data message.
504    ///
505    /// Translation from the internal `NodeEndpointEvent::Data` shape to
506    /// the public `FipsEndpointMessage` shape happens inline here — the
507    /// rx_loop pushes directly onto this channel, no relay task in
508    /// between, no extra cross-task hop per packet.
509    pub async fn recv(&self) -> Option<FipsEndpointMessage> {
510        let event = self.inbound_endpoint_rx.lock().await.recv().await?;
511        let NodeEndpointEvent::Data {
512            source_node_addr,
513            source_npub,
514            payload,
515            queued_at,
516        } = event;
517        crate::perf_profile::record_since(crate::perf_profile::Stage::EndpointEventWait, queued_at);
518        Some(FipsEndpointMessage {
519            source_node_addr,
520            source_npub,
521            data: payload,
522        })
523    }
524
525    /// Synchronous blocking send — parks the calling **OS thread** on
526    /// the FIPS endpoint command channel until the runtime accepts
527    /// the send. MUST be called only from a thread spawned via
528    /// `std::thread::spawn`, not from inside a tokio runtime.
529    ///
530    /// Companion to [`Self::blocking_recv`] for control-frame replies
531    /// (e.g. responding to a Ping with a Pong) issued from the
532    /// dedicated TUN-write thread. Failures are returned via
533    /// `FipsEndpointError::Closed` if the runtime has stopped.
534    pub fn blocking_send(
535        &self,
536        remote_npub: impl Into<String>,
537        data: impl Into<Vec<u8>>,
538    ) -> Result<(), FipsEndpointError> {
539        let remote_npub = remote_npub.into();
540        let data = data.into();
541        if remote_npub == self.npub {
542            self.inbound_endpoint_tx
543                .send(NodeEndpointEvent::Data {
544                    source_node_addr: self.node_addr,
545                    source_npub: Some(self.npub.clone()),
546                    payload: data,
547                    queued_at: crate::perf_profile::stamp(),
548                })
549                .map_err(|_| FipsEndpointError::Closed)?;
550            return Ok(());
551        }
552        let remote = self.resolve_peer_identity(&remote_npub)?;
553        let (response_tx, _response_rx) = oneshot::channel();
554        self.endpoint_commands
555            .blocking_send(NodeEndpointCommand::Send {
556                remote,
557                payload: data,
558                queued_at: crate::perf_profile::stamp(),
559                response_tx,
560            })
561            .map_err(|_| FipsEndpointError::Closed)?;
562        Ok(())
563    }
564
565    /// Synchronous blocking receive — parks the calling **OS thread**
566    /// on the channel until an event arrives or the channel closes.
567    ///
568    /// MUST NOT be called from inside a tokio runtime; use this only
569    /// from a thread spawned via `std::thread::spawn` so the tokio
570    /// scheduler doesn't deadlock.
571    ///
572    /// The motivation is the bench's CLI receive task: when run as a
573    /// regular tokio task each `recv().await` is a full task-wake on
574    /// the runtime (~1–3 µs scheduler bookkeeping), and at 113 kpps
575    /// that's ~10–30% of one core spent in plumbing the wake-up
576    /// rather than writing the packet to TUN. A dedicated OS thread
577    /// blocked on the channel via `blocking_recv` parks on a futex
578    /// directly — the wake is a single futex_wake() with no scheduler
579    /// involvement, an order of magnitude cheaper.
580    pub fn blocking_recv(&self) -> Option<FipsEndpointMessage> {
581        let mut rx = self.inbound_endpoint_rx.blocking_lock();
582        let event = rx.blocking_recv()?;
583        let NodeEndpointEvent::Data {
584            source_node_addr,
585            source_npub,
586            payload,
587            queued_at,
588        } = event;
589        crate::perf_profile::record_since(crate::perf_profile::Stage::EndpointEventWait, queued_at);
590        Some(FipsEndpointMessage {
591            source_node_addr,
592            source_npub,
593            data: payload,
594        })
595    }
596
597    /// Non-blocking receive — returns the next ready endpoint message
598    /// if one is queued, otherwise `None`. Pair with `recv()` to drain
599    /// follow-on packets without paying a scheduler wake per packet:
600    ///
601    /// ```ignore
602    /// // wake on the first packet, then drain everything ready
603    /// while let Some(msg) = endpoint.recv().await { process(msg); }
604    /// while let Some(msg) = endpoint.try_recv() { process(msg); }
605    /// ```
606    ///
607    /// On the bench's FIPS-tunnel receive path the kernel UDP socket
608    /// delivers packets in `recvmmsg`-sized bursts, so after a `.recv()`
609    /// await there are typically 5–30 packets queued waiting. Draining
610    /// them inline with `try_recv` saves N-1 scheduler hops per burst
611    /// at line rate, freeing the consumer task to spend its time on
612    /// the TUN write syscall instead of cross-task plumbing.
613    ///
614    /// Returns `None` if the channel is empty, closed, or briefly
615    /// contested by another consumer.
616    pub fn try_recv(&self) -> Option<FipsEndpointMessage> {
617        let mut rx = self.inbound_endpoint_rx.try_lock().ok()?;
618        let event = rx.try_recv().ok()?;
619        let NodeEndpointEvent::Data {
620            source_node_addr,
621            source_npub,
622            payload,
623            queued_at,
624        } = event;
625        crate::perf_profile::record_since(crate::perf_profile::Stage::EndpointEventWait, queued_at);
626        Some(FipsEndpointMessage {
627            source_node_addr,
628            source_npub,
629            data: payload,
630        })
631    }
632
633    /// Replace the runtime peer list. Newly added auto-connect peers get
634    /// dialed immediately using every known address (overlay-fresh first,
635    /// then operator/cache hints). Removed peers are dropped from the
636    /// retry queue but stay connected if they currently are — the regular
637    /// liveness timeout reaps idle sessions. Existing entries get their
638    /// `addresses` field refreshed so the next retry sees the latest hints.
639    ///
640    /// Pass an empty `addresses` vector for a peer if you want fips to
641    /// resolve them entirely from the Nostr advert at dial time.
642    pub async fn update_peers(
643        &self,
644        peers: Vec<crate::config::PeerConfig>,
645    ) -> Result<UpdatePeersOutcome, FipsEndpointError> {
646        let (response_tx, response_rx) = oneshot::channel();
647        self.endpoint_commands
648            .send(NodeEndpointCommand::UpdatePeers { peers, response_tx })
649            .await
650            .map_err(|_| FipsEndpointError::Closed)?;
651
652        match response_rx.await.map_err(|_| FipsEndpointError::Closed)? {
653            Ok(outcome) => Ok(UpdatePeersOutcome::from(outcome)),
654            Err(error) => Err(FipsEndpointError::Node(error)),
655        }
656    }
657
658    /// Snapshot authenticated peers known by the endpoint.
659    pub async fn peers(&self) -> Result<Vec<FipsEndpointPeer>, FipsEndpointError> {
660        let (response_tx, response_rx) = oneshot::channel();
661        self.endpoint_commands
662            .send(NodeEndpointCommand::PeerSnapshot { response_tx })
663            .await
664            .map_err(|_| FipsEndpointError::Closed)?;
665
666        response_rx
667            .await
668            .map(|peers| peers.into_iter().map(FipsEndpointPeer::from).collect())
669            .map_err(|_| FipsEndpointError::Closed)
670    }
671
672    /// Snapshot live Nostr relay states used by the embedded endpoint.
673    pub async fn relay_statuses(&self) -> Result<Vec<FipsEndpointRelayStatus>, FipsEndpointError> {
674        let (response_tx, response_rx) = oneshot::channel();
675        self.endpoint_commands
676            .send(NodeEndpointCommand::RelaySnapshot { response_tx })
677            .await
678            .map_err(|_| FipsEndpointError::Closed)?;
679
680        response_rx
681            .await
682            .map(|relays| {
683                relays
684                    .into_iter()
685                    .map(FipsEndpointRelayStatus::from)
686                    .collect()
687            })
688            .map_err(|_| FipsEndpointError::Closed)
689    }
690
691    /// Replace Nostr discovery relays without rebuilding the endpoint.
692    pub async fn update_relays(
693        &self,
694        advert_relays: Vec<String>,
695        dm_relays: Vec<String>,
696    ) -> Result<(), FipsEndpointError> {
697        let (response_tx, response_rx) = oneshot::channel();
698        self.endpoint_commands
699            .send(NodeEndpointCommand::UpdateRelays {
700                advert_relays,
701                dm_relays,
702                response_tx,
703            })
704            .await
705            .map_err(|_| FipsEndpointError::Closed)?;
706
707        response_rx
708            .await
709            .map_err(|_| FipsEndpointError::Closed)?
710            .map_err(FipsEndpointError::Node)
711    }
712
713    /// Send an outbound IPv6 packet into the FIPS session pipeline.
714    pub async fn send_ip_packet(
715        &self,
716        packet: impl Into<Vec<u8>>,
717    ) -> Result<(), FipsEndpointError> {
718        self.outbound_packets
719            .send(packet.into())
720            .await
721            .map_err(|_| FipsEndpointError::Closed)
722    }
723
724    /// Receive the next source-attributed IPv6 packet delivered by FIPS.
725    pub async fn recv_ip_packet(&self) -> Option<NodeDeliveredPacket> {
726        self.delivered_packets.lock().await.recv().await
727    }
728
729    /// Shut down the endpoint and wait for the node task to stop.
730    pub async fn shutdown(mut self) -> Result<(), FipsEndpointError> {
731        if let Some(shutdown_tx) = self.shutdown_tx.take() {
732            let _ = shutdown_tx.send(());
733        }
734        self.task.await??;
735        Ok(())
736    }
737}
738
739impl From<NodeEndpointPeer> for FipsEndpointPeer {
740    fn from(peer: NodeEndpointPeer) -> Self {
741        Self {
742            npub: peer.npub,
743            transport_addr: peer.transport_addr,
744            transport_type: peer.transport_type,
745            link_id: peer.link_id,
746            srtt_ms: peer.srtt_ms,
747            packets_sent: peer.packets_sent,
748            packets_recv: peer.packets_recv,
749            bytes_sent: peer.bytes_sent,
750            bytes_recv: peer.bytes_recv,
751            direct_probe_pending: peer.direct_probe_pending,
752            direct_probe_after_ms: peer.direct_probe_after_ms,
753        }
754    }
755}
756
757impl From<NodeEndpointRelayStatus> for FipsEndpointRelayStatus {
758    fn from(relay: NodeEndpointRelayStatus) -> Self {
759        Self {
760            url: relay.url,
761            status: relay.status,
762        }
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769    use std::time::Duration;
770
771    #[tokio::test]
772    async fn endpoint_starts_without_system_tun() {
773        let endpoint = FipsEndpoint::builder()
774            .without_system_tun()
775            .bind()
776            .await
777            .expect("endpoint should bind");
778
779        assert!(!endpoint.npub().is_empty());
780        assert!(endpoint.discovery_scope().is_none());
781        endpoint.shutdown().await.expect("shutdown should succeed");
782    }
783
784    #[tokio::test]
785    async fn loopback_endpoint_data_roundtrips() {
786        let endpoint = FipsEndpoint::builder()
787            .without_system_tun()
788            .bind()
789            .await
790            .expect("endpoint should bind");
791
792        endpoint
793            .send(endpoint.npub().to_string(), b"ping".to_vec())
794            .await
795            .expect("loopback send should succeed");
796        let message = tokio::time::timeout(Duration::from_secs(1), endpoint.recv())
797            .await
798            .expect("recv should not time out")
799            .expect("message should arrive");
800        assert_eq!(message.source_node_addr, *endpoint.node_addr());
801        assert_eq!(message.source_npub, Some(endpoint.npub().to_string()));
802        assert_eq!(message.data, b"ping");
803        assert!(endpoint.discovery_scope().is_none());
804
805        endpoint.shutdown().await.expect("shutdown should succeed");
806    }
807
808    #[test]
809    fn discovery_scope_enables_default_scoped_udp_discovery() {
810        let config = FipsEndpoint::builder()
811            .discovery_scope("nostr-vpn:test")
812            .prepared_config();
813
814        assert!(!config.tun.enabled);
815        assert!(!config.dns.enabled);
816        assert!(!config.node.system_files_enabled);
817        assert!(config.node.discovery.nostr.enabled);
818        assert!(config.node.discovery.nostr.advertise);
819        assert_eq!(
820            config.node.discovery.nostr.policy,
821            NostrDiscoveryPolicy::Open
822        );
823        assert!(config.node.discovery.nostr.share_local_candidates);
824        assert_eq!(config.node.discovery.nostr.app, "nostr-vpn:test");
825        assert_eq!(
826            config.node.discovery.lan.scope.as_deref(),
827            Some("nostr-vpn:test")
828        );
829        assert!(config.node.discovery.local.enabled);
830
831        let udp = match config.transports.udp {
832            TransportInstances::Single(udp) => udp,
833            TransportInstances::Named(_) => panic!("expected a default UDP transport"),
834        };
835        assert_eq!(udp.bind_addr(), "0.0.0.0:0");
836        assert!(udp.advertise_on_nostr());
837        assert!(!udp.is_public());
838        assert!(!udp.outbound_only());
839        assert!(udp.accept_connections());
840    }
841
842    #[test]
843    fn local_ethernet_adds_scoped_discovery_transport() {
844        let config = FipsEndpoint::builder()
845            .discovery_scope("iris-chat:host")
846            .local_ethernet("fips-app0")
847            .prepared_config();
848
849        assert!(config.node.discovery.nostr.enabled);
850        assert_eq!(
851            config.node.discovery.lan.scope.as_deref(),
852            Some("iris-chat:host")
853        );
854
855        let eth = match config.transports.ethernet {
856            TransportInstances::Single(eth) => eth,
857            TransportInstances::Named(_) => panic!("expected a single Ethernet transport"),
858        };
859        assert_eq!(eth.interface, "fips-app0");
860        assert!(eth.discovery());
861        assert!(eth.announce());
862        assert!(eth.auto_connect());
863        assert!(eth.accept_connections());
864        assert_eq!(eth.discovery_scope(), Some("iris-chat:host"));
865    }
866
867    #[test]
868    fn local_ethernet_preserves_existing_ethernet_config() {
869        let mut explicit = Config::new();
870        explicit.transports.ethernet = TransportInstances::Single(EthernetConfig {
871            interface: "br-existing".to_string(),
872            announce: Some(false),
873            ..EthernetConfig::default()
874        });
875
876        let config = FipsEndpoint::builder()
877            .config(explicit)
878            .local_ethernet("fips-app0")
879            .prepared_config();
880
881        let TransportInstances::Named(map) = config.transports.ethernet else {
882            panic!("expected named Ethernet transports");
883        };
884        assert!(map.contains_key("default"));
885        let local = map
886            .get("local-ethernet-fips-app0")
887            .expect("local endpoint Ethernet transport");
888        assert_eq!(local.interface, "fips-app0");
889        assert!(local.announce());
890        assert!(local.auto_connect());
891        assert!(local.accept_connections());
892    }
893
894    #[test]
895    fn discovery_scope_preserves_explicit_connectivity_config() {
896        let mut explicit = Config::new();
897        explicit.node.discovery.nostr.enabled = true;
898        explicit.node.discovery.nostr.app = "custom-app".to_string();
899        explicit.node.discovery.nostr.policy = NostrDiscoveryPolicy::ConfiguredOnly;
900        explicit.node.discovery.nostr.share_local_candidates = false;
901        explicit.transports.udp = TransportInstances::Single(UdpConfig {
902            bind_addr: Some("127.0.0.1:34567".to_string()),
903            advertise_on_nostr: Some(false),
904            outbound_only: Some(true),
905            ..UdpConfig::default()
906        });
907
908        let config = FipsEndpoint::builder()
909            .config(explicit)
910            .discovery_scope("nostr-vpn:test")
911            .prepared_config();
912
913        assert_eq!(config.node.discovery.nostr.app, "custom-app");
914        assert_eq!(
915            config.node.discovery.nostr.policy,
916            NostrDiscoveryPolicy::ConfiguredOnly
917        );
918        assert!(!config.node.discovery.nostr.share_local_candidates);
919        assert_eq!(
920            config.node.discovery.lan.scope.as_deref(),
921            Some("nostr-vpn:test")
922        );
923        assert!(config.node.discovery.local.enabled);
924        let udp = match config.transports.udp {
925            TransportInstances::Single(udp) => udp,
926            TransportInstances::Named(_) => panic!("expected explicit UDP transport"),
927        };
928        assert_eq!(udp.bind_addr.as_deref(), Some("127.0.0.1:34567"));
929        assert_eq!(udp.bind_addr(), "0.0.0.0:0");
930        assert!(!udp.advertise_on_nostr());
931        assert!(udp.outbound_only());
932    }
933
934    #[tokio::test]
935    async fn invalid_remote_npub_is_rejected() {
936        let endpoint = FipsEndpoint::builder()
937            .without_system_tun()
938            .bind()
939            .await
940            .expect("endpoint should bind");
941
942        let error = endpoint
943            .send("not-an-npub", b"hello".to_vec())
944            .await
945            .expect_err("invalid npub should fail");
946        assert!(matches!(error, FipsEndpointError::InvalidRemoteNpub { .. }));
947
948        endpoint.shutdown().await.expect("shutdown should succeed");
949    }
950
951    #[tokio::test]
952    async fn endpoint_peer_snapshot_starts_empty() {
953        let endpoint = FipsEndpoint::builder()
954            .without_system_tun()
955            .bind()
956            .await
957            .expect("endpoint should bind");
958
959        let peers = endpoint.peers().await.expect("peer snapshot");
960        assert!(peers.is_empty());
961
962        endpoint.shutdown().await.expect("shutdown should succeed");
963    }
964}