1use 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct FipsEndpointMessage {
59 pub source_node_addr: NodeAddr,
61 pub source_npub: Option<String>,
63 pub data: Vec<u8>,
65}
66
67#[derive(Debug, Clone, Default, PartialEq, Eq)]
69pub struct UpdatePeersOutcome {
70 pub added: usize,
73 pub removed: usize,
77 pub updated: usize,
82 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#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct FipsEndpointPeer {
100 pub npub: String,
102 pub transport_addr: Option<String>,
104 pub transport_type: Option<String>,
106 pub link_id: u64,
108 pub srtt_ms: Option<u64>,
110 pub packets_sent: u64,
112 pub packets_recv: u64,
114 pub bytes_sent: u64,
116 pub bytes_recv: u64,
118 pub direct_probe_pending: bool,
121 pub direct_probe_after_ms: Option<u64>,
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct FipsEndpointRelayStatus {
128 pub url: String,
129 pub status: String,
130}
131
132#[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 pub fn config(mut self, config: Config) -> Self {
159 self.config = config;
160 self
161 }
162
163 pub fn identity_nsec(mut self, nsec: impl Into<String>) -> Self {
165 self.identity_nsec = Some(nsec.into());
166 self
167 }
168
169 pub fn discovery_scope(mut self, scope: impl Into<String>) -> Self {
177 self.discovery_scope = Some(scope.into());
178 self
179 }
180
181 pub fn local_ethernet(mut self, interface: impl Into<String>) -> Self {
188 self.local_ethernet_interfaces.push(interface.into());
189 self
190 }
191
192 pub fn without_system_tun(mut self) -> Self {
194 self.disable_system_networking = true;
195 self
196 }
197
198 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 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
377pub 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 inbound_endpoint_tx: mpsc::UnboundedSender<NodeEndpointEvent>,
392 inbound_endpoint_rx: Arc<Mutex<mpsc::UnboundedReceiver<NodeEndpointEvent>>>,
398 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 pub fn builder() -> FipsEndpointBuilder {
410 FipsEndpointBuilder::default()
411 }
412
413 pub fn npub(&self) -> &str {
415 &self.npub
416 }
417
418 pub fn node_addr(&self) -> &NodeAddr {
420 &self.node_addr
421 }
422
423 pub fn address(&self) -> FipsAddress {
425 self.address
426 }
427
428 pub fn discovery_scope(&self) -> Option<&str> {
430 self.discovery_scope.as_deref()
431 }
432
433 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn recv_ip_packet(&self) -> Option<NodeDeliveredPacket> {
726 self.delivered_packets.lock().await.recv().await
727 }
728
729 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}