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