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 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 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
372pub 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 inbound_endpoint_tx: mpsc::UnboundedSender<NodeEndpointEvent>,
387 inbound_endpoint_rx: Arc<Mutex<mpsc::UnboundedReceiver<NodeEndpointEvent>>>,
393 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 pub fn builder() -> FipsEndpointBuilder {
405 FipsEndpointBuilder::default()
406 }
407
408 pub fn npub(&self) -> &str {
410 &self.npub
411 }
412
413 pub fn node_addr(&self) -> &NodeAddr {
415 &self.node_addr
416 }
417
418 pub fn address(&self) -> FipsAddress {
420 self.address
421 }
422
423 pub fn discovery_scope(&self) -> Option<&str> {
425 self.discovery_scope.as_deref()
426 }
427
428 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn recv_ip_packet(&self) -> Option<NodeDeliveredPacket> {
721 self.delivered_packets.lock().await.recv().await
722 }
723
724 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}