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