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}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
122pub struct FipsEndpointRelayStatus {
123 pub url: String,
124 pub status: String,
125}
126
127#[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 pub fn config(mut self, config: Config) -> Self {
154 self.config = config;
155 self
156 }
157
158 pub fn identity_nsec(mut self, nsec: impl Into<String>) -> Self {
160 self.identity_nsec = Some(nsec.into());
161 self
162 }
163
164 pub fn discovery_scope(mut self, scope: impl Into<String>) -> Self {
172 self.discovery_scope = Some(scope.into());
173 self
174 }
175
176 pub fn local_ethernet(mut self, interface: impl Into<String>) -> Self {
183 self.local_ethernet_interfaces.push(interface.into());
184 self
185 }
186
187 pub fn without_system_tun(mut self) -> Self {
189 self.disable_system_networking = true;
190 self
191 }
192
193 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 apply_default_scoped_discovery(&mut config, scope);
215 }
216 for interface in &self.local_ethernet_interfaces {
217 add_endpoint_ethernet_transport(
218 &mut config,
219 interface,
220 self.discovery_scope.as_deref(),
221 );
222 }
223 config
224 }
225
226 pub async fn bind(self) -> Result<FipsEndpoint, FipsEndpointError> {
228 endpoint_debug_log("FipsEndpointBuilder::bind begin");
229 let config = self.prepared_config();
230 endpoint_debug_log("FipsEndpointBuilder::bind config prepared");
231
232 let mut node = Node::new(config)?;
233 endpoint_debug_log("FipsEndpointBuilder::bind node created");
234 let npub = node.npub();
235 let node_addr = *node.node_addr();
236 let address = *node.identity().address();
237 let packet_io = node.attach_external_packet_io(self.packet_channel_capacity)?;
238 endpoint_debug_log("FipsEndpointBuilder::bind packet io attached");
239 let endpoint_data_io = node.attach_endpoint_data_io(self.packet_channel_capacity)?;
240 endpoint_debug_log("FipsEndpointBuilder::bind endpoint data io attached");
241 endpoint_debug_log("FipsEndpointBuilder::bind node.start begin");
242 node.start().await?;
243 endpoint_debug_log("FipsEndpointBuilder::bind node.start complete");
244
245 let (shutdown_tx, shutdown_rx) = oneshot::channel();
246 let task = spawn_node_task(node, shutdown_rx);
247 endpoint_debug_log("FipsEndpointBuilder::bind node task spawned");
248 let endpoint_commands = endpoint_data_io.command_tx;
249
250 Ok(FipsEndpoint {
251 npub,
252 node_addr,
253 address,
254 discovery_scope: self.discovery_scope,
255 outbound_packets: packet_io.outbound_tx,
256 delivered_packets: Arc::new(Mutex::new(packet_io.inbound_rx)),
257 endpoint_commands,
258 inbound_endpoint_tx: endpoint_data_io.event_tx,
259 inbound_endpoint_rx: Arc::new(Mutex::new(endpoint_data_io.event_rx)),
260 peer_identity_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
261 shutdown_tx: Some(shutdown_tx),
262 task,
263 })
264 }
265}
266
267fn apply_default_scoped_discovery(config: &mut Config, scope: &str) {
268 if config.node.discovery.nostr.enabled || !config.transports.is_empty() {
269 return;
270 }
271
272 config.node.discovery.nostr.enabled = true;
273 config.node.discovery.nostr.advertise = true;
274 config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
275 config.node.discovery.nostr.share_local_candidates = true;
276 config.node.discovery.nostr.app = scope.to_string();
277 config.node.discovery.lan.scope = Some(scope.to_string());
278 config.transports.udp = TransportInstances::Single(UdpConfig {
279 bind_addr: Some("0.0.0.0:0".to_string()),
280 advertise_on_nostr: Some(true),
281 public: Some(false),
282 outbound_only: Some(false),
283 accept_connections: Some(true),
284 ..UdpConfig::default()
285 });
286}
287
288fn endpoint_ethernet_config(interface: &str, scope: Option<&str>) -> EthernetConfig {
289 EthernetConfig {
290 interface: interface.to_string(),
291 discovery: Some(true),
292 announce: Some(true),
293 auto_connect: Some(true),
294 accept_connections: Some(true),
295 discovery_scope: scope
296 .map(str::trim)
297 .filter(|s| !s.is_empty())
298 .map(str::to_string),
299 ..EthernetConfig::default()
300 }
301}
302
303fn add_endpoint_ethernet_transport(config: &mut Config, interface: &str, scope: Option<&str>) {
304 let eth = endpoint_ethernet_config(interface, scope);
305 if config.transports.ethernet.is_empty() {
306 config.transports.ethernet = TransportInstances::Single(eth);
307 return;
308 }
309
310 let existing = std::mem::take(&mut config.transports.ethernet);
311 let mut named = match existing {
312 TransportInstances::Single(config) => {
313 let mut map = std::collections::HashMap::new();
314 map.insert("default".to_string(), config);
315 map
316 }
317 TransportInstances::Named(map) => map,
318 };
319
320 let base_name = endpoint_ethernet_instance_name(interface);
321 let mut name = base_name.clone();
322 let mut suffix = 2usize;
323 while named.contains_key(&name) {
324 name = format!("{base_name}-{suffix}");
325 suffix += 1;
326 }
327 named.insert(name, eth);
328 config.transports.ethernet = TransportInstances::Named(named);
329}
330
331fn endpoint_ethernet_instance_name(interface: &str) -> String {
332 let suffix: String = interface
333 .chars()
334 .map(|c| {
335 if c.is_ascii_alphanumeric() {
336 c.to_ascii_lowercase()
337 } else {
338 '-'
339 }
340 })
341 .collect();
342 let suffix = suffix.trim_matches('-');
343 if suffix.is_empty() {
344 "local-ethernet".to_string()
345 } else {
346 format!("local-ethernet-{suffix}")
347 }
348}
349
350fn spawn_node_task(
351 mut node: Node,
352 shutdown_rx: oneshot::Receiver<()>,
353) -> JoinHandle<Result<(), NodeError>> {
354 tokio::spawn(async move {
355 tokio::pin!(shutdown_rx);
356 let loop_result = tokio::select! {
357 result = node.run_rx_loop() => result,
358 _ = &mut shutdown_rx => Ok(()),
359 };
360 let stop_result = if node.state().can_stop() {
361 node.stop().await
362 } else {
363 Ok(())
364 };
365 loop_result?;
366 stop_result
367 })
368}
369
370pub struct FipsEndpoint {
372 npub: String,
373 node_addr: NodeAddr,
374 address: FipsAddress,
375 discovery_scope: Option<String>,
376 outbound_packets: mpsc::Sender<Vec<u8>>,
377 delivered_packets: Arc<Mutex<mpsc::Receiver<NodeDeliveredPacket>>>,
378 endpoint_commands: mpsc::Sender<NodeEndpointCommand>,
379 inbound_endpoint_tx: mpsc::UnboundedSender<NodeEndpointEvent>,
385 inbound_endpoint_rx: Arc<Mutex<mpsc::UnboundedReceiver<NodeEndpointEvent>>>,
391 peer_identity_cache: std::sync::Mutex<std::collections::HashMap<String, PeerIdentity>>,
396 shutdown_tx: Option<oneshot::Sender<()>>,
397 task: JoinHandle<Result<(), NodeError>>,
398}
399
400impl FipsEndpoint {
401 pub fn builder() -> FipsEndpointBuilder {
403 FipsEndpointBuilder::default()
404 }
405
406 pub fn npub(&self) -> &str {
408 &self.npub
409 }
410
411 pub fn node_addr(&self) -> &NodeAddr {
413 &self.node_addr
414 }
415
416 pub fn address(&self) -> FipsAddress {
418 self.address
419 }
420
421 pub fn discovery_scope(&self) -> Option<&str> {
423 self.discovery_scope.as_deref()
424 }
425
426 pub async fn send(
439 &self,
440 remote_npub: impl Into<String>,
441 data: impl Into<Vec<u8>>,
442 ) -> Result<(), FipsEndpointError> {
443 let remote_npub = remote_npub.into();
444 let data = data.into();
445 if remote_npub == self.npub {
446 self.inbound_endpoint_tx
447 .send(NodeEndpointEvent::Data {
448 source_node_addr: self.node_addr,
449 source_npub: Some(self.npub.clone()),
450 payload: data,
451 queued_at: crate::perf_profile::stamp(),
452 })
453 .map_err(|_| FipsEndpointError::Closed)?;
454 return Ok(());
455 }
456
457 let remote = self.resolve_peer_identity(&remote_npub)?;
458
459 self.endpoint_commands
464 .send(NodeEndpointCommand::SendOneway {
465 remote,
466 payload: data,
467 queued_at: crate::perf_profile::stamp(),
468 })
469 .await
470 .map_err(|_| FipsEndpointError::Closed)?;
471 Ok(())
472 }
473
474 fn resolve_peer_identity(&self, remote_npub: &str) -> Result<PeerIdentity, FipsEndpointError> {
475 if let Ok(cache) = self.peer_identity_cache.lock()
478 && let Some(remote) = cache.get(remote_npub)
479 {
480 return Ok(*remote);
481 }
482
483 let remote = PeerIdentity::from_npub(remote_npub).map_err(|error| {
484 FipsEndpointError::InvalidRemoteNpub {
485 npub: remote_npub.to_string(),
486 reason: error.to_string(),
487 }
488 })?;
489
490 if let Ok(mut cache) = self.peer_identity_cache.lock() {
491 cache.entry(remote_npub.to_string()).or_insert(remote);
492 }
493 Ok(remote)
494 }
495
496 pub async fn recv(&self) -> Option<FipsEndpointMessage> {
503 let event = self.inbound_endpoint_rx.lock().await.recv().await?;
504 let NodeEndpointEvent::Data {
505 source_node_addr,
506 source_npub,
507 payload,
508 queued_at,
509 } = event;
510 crate::perf_profile::record_since(crate::perf_profile::Stage::EndpointEventWait, queued_at);
511 Some(FipsEndpointMessage {
512 source_node_addr,
513 source_npub,
514 data: payload,
515 })
516 }
517
518 pub fn blocking_send(
528 &self,
529 remote_npub: impl Into<String>,
530 data: impl Into<Vec<u8>>,
531 ) -> Result<(), FipsEndpointError> {
532 let remote_npub = remote_npub.into();
533 let data = data.into();
534 if remote_npub == self.npub {
535 self.inbound_endpoint_tx
536 .send(NodeEndpointEvent::Data {
537 source_node_addr: self.node_addr,
538 source_npub: Some(self.npub.clone()),
539 payload: data,
540 queued_at: crate::perf_profile::stamp(),
541 })
542 .map_err(|_| FipsEndpointError::Closed)?;
543 return Ok(());
544 }
545 let remote = self.resolve_peer_identity(&remote_npub)?;
546 let (response_tx, _response_rx) = oneshot::channel();
547 self.endpoint_commands
548 .blocking_send(NodeEndpointCommand::Send {
549 remote,
550 payload: data,
551 queued_at: crate::perf_profile::stamp(),
552 response_tx,
553 })
554 .map_err(|_| FipsEndpointError::Closed)?;
555 Ok(())
556 }
557
558 pub fn blocking_recv(&self) -> Option<FipsEndpointMessage> {
574 let mut rx = self.inbound_endpoint_rx.blocking_lock();
575 let event = rx.blocking_recv()?;
576 let NodeEndpointEvent::Data {
577 source_node_addr,
578 source_npub,
579 payload,
580 queued_at,
581 } = event;
582 crate::perf_profile::record_since(crate::perf_profile::Stage::EndpointEventWait, queued_at);
583 Some(FipsEndpointMessage {
584 source_node_addr,
585 source_npub,
586 data: payload,
587 })
588 }
589
590 pub fn try_recv(&self) -> Option<FipsEndpointMessage> {
610 let mut rx = self.inbound_endpoint_rx.try_lock().ok()?;
611 let event = rx.try_recv().ok()?;
612 let NodeEndpointEvent::Data {
613 source_node_addr,
614 source_npub,
615 payload,
616 queued_at,
617 } = event;
618 crate::perf_profile::record_since(crate::perf_profile::Stage::EndpointEventWait, queued_at);
619 Some(FipsEndpointMessage {
620 source_node_addr,
621 source_npub,
622 data: payload,
623 })
624 }
625
626 pub async fn update_peers(
636 &self,
637 peers: Vec<crate::config::PeerConfig>,
638 ) -> Result<UpdatePeersOutcome, FipsEndpointError> {
639 let (response_tx, response_rx) = oneshot::channel();
640 self.endpoint_commands
641 .send(NodeEndpointCommand::UpdatePeers { peers, response_tx })
642 .await
643 .map_err(|_| FipsEndpointError::Closed)?;
644
645 match response_rx.await.map_err(|_| FipsEndpointError::Closed)? {
646 Ok(outcome) => Ok(UpdatePeersOutcome::from(outcome)),
647 Err(error) => Err(FipsEndpointError::Node(error)),
648 }
649 }
650
651 pub async fn peers(&self) -> Result<Vec<FipsEndpointPeer>, FipsEndpointError> {
653 let (response_tx, response_rx) = oneshot::channel();
654 self.endpoint_commands
655 .send(NodeEndpointCommand::PeerSnapshot { response_tx })
656 .await
657 .map_err(|_| FipsEndpointError::Closed)?;
658
659 response_rx
660 .await
661 .map(|peers| peers.into_iter().map(FipsEndpointPeer::from).collect())
662 .map_err(|_| FipsEndpointError::Closed)
663 }
664
665 pub async fn relay_statuses(&self) -> Result<Vec<FipsEndpointRelayStatus>, FipsEndpointError> {
667 let (response_tx, response_rx) = oneshot::channel();
668 self.endpoint_commands
669 .send(NodeEndpointCommand::RelaySnapshot { response_tx })
670 .await
671 .map_err(|_| FipsEndpointError::Closed)?;
672
673 response_rx
674 .await
675 .map(|relays| {
676 relays
677 .into_iter()
678 .map(FipsEndpointRelayStatus::from)
679 .collect()
680 })
681 .map_err(|_| FipsEndpointError::Closed)
682 }
683
684 pub async fn update_relays(
686 &self,
687 advert_relays: Vec<String>,
688 dm_relays: Vec<String>,
689 ) -> Result<(), FipsEndpointError> {
690 let (response_tx, response_rx) = oneshot::channel();
691 self.endpoint_commands
692 .send(NodeEndpointCommand::UpdateRelays {
693 advert_relays,
694 dm_relays,
695 response_tx,
696 })
697 .await
698 .map_err(|_| FipsEndpointError::Closed)?;
699
700 response_rx
701 .await
702 .map_err(|_| FipsEndpointError::Closed)?
703 .map_err(FipsEndpointError::Node)
704 }
705
706 pub async fn send_ip_packet(
708 &self,
709 packet: impl Into<Vec<u8>>,
710 ) -> Result<(), FipsEndpointError> {
711 self.outbound_packets
712 .send(packet.into())
713 .await
714 .map_err(|_| FipsEndpointError::Closed)
715 }
716
717 pub async fn recv_ip_packet(&self) -> Option<NodeDeliveredPacket> {
719 self.delivered_packets.lock().await.recv().await
720 }
721
722 pub async fn shutdown(mut self) -> Result<(), FipsEndpointError> {
724 if let Some(shutdown_tx) = self.shutdown_tx.take() {
725 let _ = shutdown_tx.send(());
726 }
727 self.task.await??;
728 Ok(())
729 }
730}
731
732impl From<NodeEndpointPeer> for FipsEndpointPeer {
733 fn from(peer: NodeEndpointPeer) -> Self {
734 Self {
735 npub: peer.npub,
736 transport_addr: peer.transport_addr,
737 transport_type: peer.transport_type,
738 link_id: peer.link_id,
739 srtt_ms: peer.srtt_ms,
740 packets_sent: peer.packets_sent,
741 packets_recv: peer.packets_recv,
742 bytes_sent: peer.bytes_sent,
743 bytes_recv: peer.bytes_recv,
744 }
745 }
746}
747
748impl From<NodeEndpointRelayStatus> for FipsEndpointRelayStatus {
749 fn from(relay: NodeEndpointRelayStatus) -> Self {
750 Self {
751 url: relay.url,
752 status: relay.status,
753 }
754 }
755}
756
757#[cfg(test)]
758mod tests {
759 use super::*;
760 use std::time::Duration;
761
762 #[tokio::test]
763 async fn endpoint_starts_without_system_tun() {
764 let endpoint = FipsEndpoint::builder()
765 .without_system_tun()
766 .bind()
767 .await
768 .expect("endpoint should bind");
769
770 assert!(!endpoint.npub().is_empty());
771 assert!(endpoint.discovery_scope().is_none());
772 endpoint.shutdown().await.expect("shutdown should succeed");
773 }
774
775 #[tokio::test]
776 async fn loopback_endpoint_data_roundtrips() {
777 let endpoint = FipsEndpoint::builder()
778 .without_system_tun()
779 .bind()
780 .await
781 .expect("endpoint should bind");
782
783 endpoint
784 .send(endpoint.npub().to_string(), b"ping".to_vec())
785 .await
786 .expect("loopback send should succeed");
787 let message = tokio::time::timeout(Duration::from_secs(1), endpoint.recv())
788 .await
789 .expect("recv should not time out")
790 .expect("message should arrive");
791 assert_eq!(message.source_node_addr, *endpoint.node_addr());
792 assert_eq!(message.source_npub, Some(endpoint.npub().to_string()));
793 assert_eq!(message.data, b"ping");
794 assert!(endpoint.discovery_scope().is_none());
795
796 endpoint.shutdown().await.expect("shutdown should succeed");
797 }
798
799 #[test]
800 fn discovery_scope_enables_default_scoped_udp_discovery() {
801 let config = FipsEndpoint::builder()
802 .discovery_scope("nostr-vpn:test")
803 .prepared_config();
804
805 assert!(!config.tun.enabled);
806 assert!(!config.dns.enabled);
807 assert!(!config.node.system_files_enabled);
808 assert!(config.node.discovery.nostr.enabled);
809 assert!(config.node.discovery.nostr.advertise);
810 assert_eq!(
811 config.node.discovery.nostr.policy,
812 NostrDiscoveryPolicy::Open
813 );
814 assert!(config.node.discovery.nostr.share_local_candidates);
815 assert_eq!(config.node.discovery.nostr.app, "nostr-vpn:test");
816 assert_eq!(
817 config.node.discovery.lan.scope.as_deref(),
818 Some("nostr-vpn:test")
819 );
820
821 let udp = match config.transports.udp {
822 TransportInstances::Single(udp) => udp,
823 TransportInstances::Named(_) => panic!("expected a default UDP transport"),
824 };
825 assert_eq!(udp.bind_addr(), "0.0.0.0:0");
826 assert!(udp.advertise_on_nostr());
827 assert!(!udp.is_public());
828 assert!(!udp.outbound_only());
829 assert!(udp.accept_connections());
830 }
831
832 #[test]
833 fn local_ethernet_adds_scoped_discovery_transport() {
834 let config = FipsEndpoint::builder()
835 .discovery_scope("iris-chat:host")
836 .local_ethernet("fips-app0")
837 .prepared_config();
838
839 assert!(config.node.discovery.nostr.enabled);
840 assert_eq!(
841 config.node.discovery.lan.scope.as_deref(),
842 Some("iris-chat:host")
843 );
844
845 let eth = match config.transports.ethernet {
846 TransportInstances::Single(eth) => eth,
847 TransportInstances::Named(_) => panic!("expected a single Ethernet transport"),
848 };
849 assert_eq!(eth.interface, "fips-app0");
850 assert!(eth.discovery());
851 assert!(eth.announce());
852 assert!(eth.auto_connect());
853 assert!(eth.accept_connections());
854 assert_eq!(eth.discovery_scope(), Some("iris-chat:host"));
855 }
856
857 #[test]
858 fn local_ethernet_preserves_existing_ethernet_config() {
859 let mut explicit = Config::new();
860 explicit.transports.ethernet = TransportInstances::Single(EthernetConfig {
861 interface: "br-existing".to_string(),
862 announce: Some(false),
863 ..EthernetConfig::default()
864 });
865
866 let config = FipsEndpoint::builder()
867 .config(explicit)
868 .local_ethernet("fips-app0")
869 .prepared_config();
870
871 let TransportInstances::Named(map) = config.transports.ethernet else {
872 panic!("expected named Ethernet transports");
873 };
874 assert!(map.contains_key("default"));
875 let local = map
876 .get("local-ethernet-fips-app0")
877 .expect("local endpoint Ethernet transport");
878 assert_eq!(local.interface, "fips-app0");
879 assert!(local.announce());
880 assert!(local.auto_connect());
881 assert!(local.accept_connections());
882 }
883
884 #[test]
885 fn discovery_scope_preserves_explicit_connectivity_config() {
886 let mut explicit = Config::new();
887 explicit.node.discovery.nostr.enabled = true;
888 explicit.node.discovery.nostr.app = "custom-app".to_string();
889 explicit.node.discovery.nostr.policy = NostrDiscoveryPolicy::ConfiguredOnly;
890 explicit.node.discovery.nostr.share_local_candidates = false;
891 explicit.transports.udp = TransportInstances::Single(UdpConfig {
892 bind_addr: Some("127.0.0.1:34567".to_string()),
893 advertise_on_nostr: Some(false),
894 outbound_only: Some(true),
895 ..UdpConfig::default()
896 });
897
898 let config = FipsEndpoint::builder()
899 .config(explicit)
900 .discovery_scope("nostr-vpn:test")
901 .prepared_config();
902
903 assert_eq!(config.node.discovery.nostr.app, "custom-app");
904 assert_eq!(
905 config.node.discovery.nostr.policy,
906 NostrDiscoveryPolicy::ConfiguredOnly
907 );
908 assert!(!config.node.discovery.nostr.share_local_candidates);
909 assert_eq!(
910 config.node.discovery.lan.scope.as_deref(),
911 Some("nostr-vpn:test")
912 );
913 let udp = match config.transports.udp {
914 TransportInstances::Single(udp) => udp,
915 TransportInstances::Named(_) => panic!("expected explicit UDP transport"),
916 };
917 assert_eq!(udp.bind_addr.as_deref(), Some("127.0.0.1:34567"));
918 assert_eq!(udp.bind_addr(), "0.0.0.0:0");
919 assert!(!udp.advertise_on_nostr());
920 assert!(udp.outbound_only());
921 }
922
923 #[tokio::test]
924 async fn invalid_remote_npub_is_rejected() {
925 let endpoint = FipsEndpoint::builder()
926 .without_system_tun()
927 .bind()
928 .await
929 .expect("endpoint should bind");
930
931 let error = endpoint
932 .send("not-an-npub", b"hello".to_vec())
933 .await
934 .expect_err("invalid npub should fail");
935 assert!(matches!(error, FipsEndpointError::InvalidRemoteNpub { .. }));
936
937 endpoint.shutdown().await.expect("shutdown should succeed");
938 }
939
940 #[tokio::test]
941 async fn endpoint_peer_snapshot_starts_empty() {
942 let endpoint = FipsEndpoint::builder()
943 .without_system_tun()
944 .bind()
945 .await
946 .expect("endpoint should bind");
947
948 let peers = endpoint.peers().await.expect("peer snapshot");
949 assert!(peers.is_empty());
950
951 endpoint.shutdown().await.expect("shutdown should succeed");
952 }
953}