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 connected: bool,
104 pub transport_addr: Option<String>,
106 pub transport_type: Option<String>,
108 pub link_id: u64,
110 pub srtt_ms: Option<u64>,
112 pub packets_sent: u64,
114 pub packets_recv: u64,
116 pub bytes_sent: u64,
118 pub bytes_recv: u64,
120 pub direct_probe_pending: bool,
123 pub direct_probe_after_ms: Option<u64>,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq)]
129pub struct FipsEndpointRelayStatus {
130 pub url: String,
131 pub status: String,
132}
133
134#[derive(Debug, Clone)]
136pub struct FipsEndpointBuilder {
137 config: Config,
138 identity_nsec: Option<String>,
139 discovery_scope: Option<String>,
140 local_ethernet_interfaces: Vec<String>,
141 disable_system_networking: bool,
142 packet_channel_capacity: usize,
143}
144
145impl Default for FipsEndpointBuilder {
146 fn default() -> Self {
147 Self {
148 config: Config::new(),
149 identity_nsec: None,
150 discovery_scope: None,
151 local_ethernet_interfaces: Vec::new(),
152 disable_system_networking: true,
153 packet_channel_capacity: 1024,
154 }
155 }
156}
157
158impl FipsEndpointBuilder {
159 pub fn config(mut self, config: Config) -> Self {
161 self.config = config;
162 self
163 }
164
165 pub fn identity_nsec(mut self, nsec: impl Into<String>) -> Self {
167 self.identity_nsec = Some(nsec.into());
168 self
169 }
170
171 pub fn discovery_scope(mut self, scope: impl Into<String>) -> Self {
179 self.discovery_scope = Some(scope.into());
180 self
181 }
182
183 pub fn local_ethernet(mut self, interface: impl Into<String>) -> Self {
190 self.local_ethernet_interfaces.push(interface.into());
191 self
192 }
193
194 pub fn without_system_tun(mut self) -> Self {
196 self.disable_system_networking = true;
197 self
198 }
199
200 pub fn packet_channel_capacity(mut self, capacity: usize) -> Self {
202 self.packet_channel_capacity = capacity.max(1);
203 self
204 }
205
206 fn prepared_config(&self) -> Config {
207 let mut config = self.config.clone();
208 if let Some(nsec) = &self.identity_nsec {
209 config.node.identity = IdentityConfig {
210 nsec: Some(nsec.clone()),
211 persistent: false,
212 };
213 }
214 if self.disable_system_networking {
215 config.tun.enabled = false;
216 config.dns.enabled = false;
217 config.node.system_files_enabled = false;
218 }
219 if let Some(scope) = self.discovery_scope.as_deref() {
220 config.node.discovery.lan.scope = Some(scope.to_string());
221 config.node.discovery.local.enabled = true;
222 apply_default_scoped_discovery(&mut config, scope);
223 }
224 for interface in &self.local_ethernet_interfaces {
225 add_endpoint_ethernet_transport(
226 &mut config,
227 interface,
228 self.discovery_scope.as_deref(),
229 );
230 }
231 config
232 }
233
234 pub async fn bind(self) -> Result<FipsEndpoint, FipsEndpointError> {
236 endpoint_debug_log("FipsEndpointBuilder::bind begin");
237 let config = self.prepared_config();
238 endpoint_debug_log("FipsEndpointBuilder::bind config prepared");
239
240 let mut node = Node::new(config)?;
241 endpoint_debug_log("FipsEndpointBuilder::bind node created");
242 let npub = node.npub();
243 let node_addr = *node.node_addr();
244 let address = *node.identity().address();
245 let packet_io = node.attach_external_packet_io(self.packet_channel_capacity)?;
246 endpoint_debug_log("FipsEndpointBuilder::bind packet io attached");
247 let endpoint_data_io = node.attach_endpoint_data_io(self.packet_channel_capacity)?;
248 endpoint_debug_log("FipsEndpointBuilder::bind endpoint data io attached");
249 endpoint_debug_log("FipsEndpointBuilder::bind node.start begin");
250 node.start().await?;
251 endpoint_debug_log("FipsEndpointBuilder::bind node.start complete");
252
253 let (shutdown_tx, shutdown_rx) = oneshot::channel();
254 let task = spawn_node_task(node, shutdown_rx);
255 endpoint_debug_log("FipsEndpointBuilder::bind node task spawned");
256 let endpoint_commands = endpoint_data_io.command_tx;
257
258 Ok(FipsEndpoint {
259 npub,
260 node_addr,
261 address,
262 discovery_scope: self.discovery_scope,
263 outbound_packets: packet_io.outbound_tx,
264 delivered_packets: Arc::new(Mutex::new(packet_io.inbound_rx)),
265 endpoint_commands,
266 inbound_endpoint_tx: endpoint_data_io.event_tx,
267 inbound_endpoint_rx: Arc::new(Mutex::new(endpoint_data_io.event_rx)),
268 peer_identity_cache: std::sync::Mutex::new(std::collections::HashMap::new()),
269 shutdown_tx: Some(shutdown_tx),
270 task,
271 })
272 }
273}
274
275fn apply_default_scoped_discovery(config: &mut Config, scope: &str) {
276 if config.node.discovery.nostr.enabled || !config.transports.is_empty() {
277 return;
278 }
279
280 config.node.discovery.nostr.enabled = true;
281 config.node.discovery.nostr.advertise = true;
282 config.node.discovery.nostr.policy = NostrDiscoveryPolicy::Open;
283 config.node.discovery.nostr.share_local_candidates = true;
284 config.node.discovery.nostr.app = scope.to_string();
285 config.node.discovery.lan.scope = Some(scope.to_string());
286 config.node.discovery.local.enabled = true;
287 config.transports.udp = TransportInstances::Single(UdpConfig {
288 bind_addr: Some("0.0.0.0:0".to_string()),
289 advertise_on_nostr: Some(true),
290 public: Some(false),
291 outbound_only: Some(false),
292 accept_connections: Some(true),
293 ..UdpConfig::default()
294 });
295}
296
297fn endpoint_ethernet_config(interface: &str, scope: Option<&str>) -> EthernetConfig {
298 EthernetConfig {
299 interface: interface.to_string(),
300 discovery: Some(true),
301 announce: Some(true),
302 auto_connect: Some(true),
303 accept_connections: Some(true),
304 discovery_scope: scope
305 .map(str::trim)
306 .filter(|s| !s.is_empty())
307 .map(str::to_string),
308 ..EthernetConfig::default()
309 }
310}
311
312fn add_endpoint_ethernet_transport(config: &mut Config, interface: &str, scope: Option<&str>) {
313 let eth = endpoint_ethernet_config(interface, scope);
314 if config.transports.ethernet.is_empty() {
315 config.transports.ethernet = TransportInstances::Single(eth);
316 return;
317 }
318
319 let existing = std::mem::take(&mut config.transports.ethernet);
320 let mut named = match existing {
321 TransportInstances::Single(config) => {
322 let mut map = std::collections::HashMap::new();
323 map.insert("default".to_string(), config);
324 map
325 }
326 TransportInstances::Named(map) => map,
327 };
328
329 let base_name = endpoint_ethernet_instance_name(interface);
330 let mut name = base_name.clone();
331 let mut suffix = 2usize;
332 while named.contains_key(&name) {
333 name = format!("{base_name}-{suffix}");
334 suffix += 1;
335 }
336 named.insert(name, eth);
337 config.transports.ethernet = TransportInstances::Named(named);
338}
339
340fn endpoint_ethernet_instance_name(interface: &str) -> String {
341 let suffix: String = interface
342 .chars()
343 .map(|c| {
344 if c.is_ascii_alphanumeric() {
345 c.to_ascii_lowercase()
346 } else {
347 '-'
348 }
349 })
350 .collect();
351 let suffix = suffix.trim_matches('-');
352 if suffix.is_empty() {
353 "local-ethernet".to_string()
354 } else {
355 format!("local-ethernet-{suffix}")
356 }
357}
358
359fn spawn_node_task(
360 mut node: Node,
361 shutdown_rx: oneshot::Receiver<()>,
362) -> JoinHandle<Result<(), NodeError>> {
363 tokio::spawn(async move {
364 tokio::pin!(shutdown_rx);
365 let loop_result = tokio::select! {
366 result = node.run_rx_loop() => result,
367 _ = &mut shutdown_rx => Ok(()),
368 };
369 let stop_result = if node.state().can_stop() {
370 node.stop().await
371 } else {
372 Ok(())
373 };
374 loop_result?;
375 stop_result
376 })
377}
378
379pub struct FipsEndpoint {
381 npub: String,
382 node_addr: NodeAddr,
383 address: FipsAddress,
384 discovery_scope: Option<String>,
385 outbound_packets: mpsc::Sender<Vec<u8>>,
386 delivered_packets: Arc<Mutex<mpsc::Receiver<NodeDeliveredPacket>>>,
387 endpoint_commands: mpsc::Sender<NodeEndpointCommand>,
388 inbound_endpoint_tx: mpsc::UnboundedSender<NodeEndpointEvent>,
394 inbound_endpoint_rx: Arc<Mutex<mpsc::UnboundedReceiver<NodeEndpointEvent>>>,
400 peer_identity_cache: std::sync::Mutex<std::collections::HashMap<String, PeerIdentity>>,
405 shutdown_tx: Option<oneshot::Sender<()>>,
406 task: JoinHandle<Result<(), NodeError>>,
407}
408
409impl FipsEndpoint {
410 pub fn builder() -> FipsEndpointBuilder {
412 FipsEndpointBuilder::default()
413 }
414
415 pub fn npub(&self) -> &str {
417 &self.npub
418 }
419
420 pub fn node_addr(&self) -> &NodeAddr {
422 &self.node_addr
423 }
424
425 pub fn address(&self) -> FipsAddress {
427 self.address
428 }
429
430 pub fn discovery_scope(&self) -> Option<&str> {
432 self.discovery_scope.as_deref()
433 }
434
435 pub async fn send(
448 &self,
449 remote_npub: impl Into<String>,
450 data: impl Into<Vec<u8>>,
451 ) -> Result<(), FipsEndpointError> {
452 let remote_npub = remote_npub.into();
453 let data = data.into();
454 if remote_npub == self.npub {
455 self.inbound_endpoint_tx
456 .send(NodeEndpointEvent::Data {
457 source_node_addr: self.node_addr,
458 source_npub: Some(self.npub.clone()),
459 payload: data,
460 queued_at: crate::perf_profile::stamp(),
461 })
462 .map_err(|_| FipsEndpointError::Closed)?;
463 return Ok(());
464 }
465
466 let remote = self.resolve_peer_identity(&remote_npub)?;
467
468 self.endpoint_commands
473 .send(NodeEndpointCommand::SendOneway {
474 remote,
475 payload: data,
476 queued_at: crate::perf_profile::stamp(),
477 })
478 .await
479 .map_err(|_| FipsEndpointError::Closed)?;
480 Ok(())
481 }
482
483 fn resolve_peer_identity(&self, remote_npub: &str) -> Result<PeerIdentity, FipsEndpointError> {
484 if let Ok(cache) = self.peer_identity_cache.lock()
487 && let Some(remote) = cache.get(remote_npub)
488 {
489 return Ok(*remote);
490 }
491
492 let remote = PeerIdentity::from_npub(remote_npub).map_err(|error| {
493 FipsEndpointError::InvalidRemoteNpub {
494 npub: remote_npub.to_string(),
495 reason: error.to_string(),
496 }
497 })?;
498
499 if let Ok(mut cache) = self.peer_identity_cache.lock() {
500 cache.entry(remote_npub.to_string()).or_insert(remote);
501 }
502 Ok(remote)
503 }
504
505 pub async fn recv(&self) -> Option<FipsEndpointMessage> {
512 let event = self.inbound_endpoint_rx.lock().await.recv().await?;
513 let NodeEndpointEvent::Data {
514 source_node_addr,
515 source_npub,
516 payload,
517 queued_at,
518 } = event;
519 crate::perf_profile::record_since(crate::perf_profile::Stage::EndpointEventWait, queued_at);
520 Some(FipsEndpointMessage {
521 source_node_addr,
522 source_npub,
523 data: payload,
524 })
525 }
526
527 pub fn blocking_send(
537 &self,
538 remote_npub: impl Into<String>,
539 data: impl Into<Vec<u8>>,
540 ) -> Result<(), FipsEndpointError> {
541 let remote_npub = remote_npub.into();
542 let data = data.into();
543 if remote_npub == self.npub {
544 self.inbound_endpoint_tx
545 .send(NodeEndpointEvent::Data {
546 source_node_addr: self.node_addr,
547 source_npub: Some(self.npub.clone()),
548 payload: data,
549 queued_at: crate::perf_profile::stamp(),
550 })
551 .map_err(|_| FipsEndpointError::Closed)?;
552 return Ok(());
553 }
554 let remote = self.resolve_peer_identity(&remote_npub)?;
555 let (response_tx, _response_rx) = oneshot::channel();
556 self.endpoint_commands
557 .blocking_send(NodeEndpointCommand::Send {
558 remote,
559 payload: data,
560 queued_at: crate::perf_profile::stamp(),
561 response_tx,
562 })
563 .map_err(|_| FipsEndpointError::Closed)?;
564 Ok(())
565 }
566
567 pub fn blocking_recv(&self) -> Option<FipsEndpointMessage> {
583 let mut rx = self.inbound_endpoint_rx.blocking_lock();
584 let event = rx.blocking_recv()?;
585 let NodeEndpointEvent::Data {
586 source_node_addr,
587 source_npub,
588 payload,
589 queued_at,
590 } = event;
591 crate::perf_profile::record_since(crate::perf_profile::Stage::EndpointEventWait, queued_at);
592 Some(FipsEndpointMessage {
593 source_node_addr,
594 source_npub,
595 data: payload,
596 })
597 }
598
599 pub fn try_recv(&self) -> Option<FipsEndpointMessage> {
619 let mut rx = self.inbound_endpoint_rx.try_lock().ok()?;
620 let event = rx.try_recv().ok()?;
621 let NodeEndpointEvent::Data {
622 source_node_addr,
623 source_npub,
624 payload,
625 queued_at,
626 } = event;
627 crate::perf_profile::record_since(crate::perf_profile::Stage::EndpointEventWait, queued_at);
628 Some(FipsEndpointMessage {
629 source_node_addr,
630 source_npub,
631 data: payload,
632 })
633 }
634
635 pub async fn update_peers(
645 &self,
646 peers: Vec<crate::config::PeerConfig>,
647 ) -> Result<UpdatePeersOutcome, FipsEndpointError> {
648 let (response_tx, response_rx) = oneshot::channel();
649 self.endpoint_commands
650 .send(NodeEndpointCommand::UpdatePeers { peers, response_tx })
651 .await
652 .map_err(|_| FipsEndpointError::Closed)?;
653
654 match response_rx.await.map_err(|_| FipsEndpointError::Closed)? {
655 Ok(outcome) => Ok(UpdatePeersOutcome::from(outcome)),
656 Err(error) => Err(FipsEndpointError::Node(error)),
657 }
658 }
659
660 pub async fn peers(&self) -> Result<Vec<FipsEndpointPeer>, FipsEndpointError> {
662 let (response_tx, response_rx) = oneshot::channel();
663 self.endpoint_commands
664 .send(NodeEndpointCommand::PeerSnapshot { response_tx })
665 .await
666 .map_err(|_| FipsEndpointError::Closed)?;
667
668 response_rx
669 .await
670 .map(|peers| peers.into_iter().map(FipsEndpointPeer::from).collect())
671 .map_err(|_| FipsEndpointError::Closed)
672 }
673
674 pub async fn relay_statuses(&self) -> Result<Vec<FipsEndpointRelayStatus>, FipsEndpointError> {
676 let (response_tx, response_rx) = oneshot::channel();
677 self.endpoint_commands
678 .send(NodeEndpointCommand::RelaySnapshot { response_tx })
679 .await
680 .map_err(|_| FipsEndpointError::Closed)?;
681
682 response_rx
683 .await
684 .map(|relays| {
685 relays
686 .into_iter()
687 .map(FipsEndpointRelayStatus::from)
688 .collect()
689 })
690 .map_err(|_| FipsEndpointError::Closed)
691 }
692
693 pub async fn update_relays(
695 &self,
696 advert_relays: Vec<String>,
697 dm_relays: Vec<String>,
698 ) -> Result<(), FipsEndpointError> {
699 let (response_tx, response_rx) = oneshot::channel();
700 self.endpoint_commands
701 .send(NodeEndpointCommand::UpdateRelays {
702 advert_relays,
703 dm_relays,
704 response_tx,
705 })
706 .await
707 .map_err(|_| FipsEndpointError::Closed)?;
708
709 response_rx
710 .await
711 .map_err(|_| FipsEndpointError::Closed)?
712 .map_err(FipsEndpointError::Node)
713 }
714
715 pub async fn send_ip_packet(
717 &self,
718 packet: impl Into<Vec<u8>>,
719 ) -> Result<(), FipsEndpointError> {
720 self.outbound_packets
721 .send(packet.into())
722 .await
723 .map_err(|_| FipsEndpointError::Closed)
724 }
725
726 pub async fn recv_ip_packet(&self) -> Option<NodeDeliveredPacket> {
728 self.delivered_packets.lock().await.recv().await
729 }
730
731 pub async fn shutdown(mut self) -> Result<(), FipsEndpointError> {
733 if let Some(shutdown_tx) = self.shutdown_tx.take() {
734 let _ = shutdown_tx.send(());
735 }
736 self.task.await??;
737 Ok(())
738 }
739}
740
741impl From<NodeEndpointPeer> for FipsEndpointPeer {
742 fn from(peer: NodeEndpointPeer) -> Self {
743 Self {
744 npub: peer.npub,
745 connected: peer.connected,
746 transport_addr: peer.transport_addr,
747 transport_type: peer.transport_type,
748 link_id: peer.link_id,
749 srtt_ms: peer.srtt_ms,
750 packets_sent: peer.packets_sent,
751 packets_recv: peer.packets_recv,
752 bytes_sent: peer.bytes_sent,
753 bytes_recv: peer.bytes_recv,
754 direct_probe_pending: peer.direct_probe_pending,
755 direct_probe_after_ms: peer.direct_probe_after_ms,
756 }
757 }
758}
759
760impl From<NodeEndpointRelayStatus> for FipsEndpointRelayStatus {
761 fn from(relay: NodeEndpointRelayStatus) -> Self {
762 Self {
763 url: relay.url,
764 status: relay.status,
765 }
766 }
767}
768
769#[cfg(test)]
770mod tests {
771 use super::*;
772 use std::time::Duration;
773
774 #[tokio::test]
775 async fn endpoint_starts_without_system_tun() {
776 let endpoint = FipsEndpoint::builder()
777 .without_system_tun()
778 .bind()
779 .await
780 .expect("endpoint should bind");
781
782 assert!(!endpoint.npub().is_empty());
783 assert!(endpoint.discovery_scope().is_none());
784 endpoint.shutdown().await.expect("shutdown should succeed");
785 }
786
787 #[tokio::test]
788 async fn loopback_endpoint_data_roundtrips() {
789 let endpoint = FipsEndpoint::builder()
790 .without_system_tun()
791 .bind()
792 .await
793 .expect("endpoint should bind");
794
795 endpoint
796 .send(endpoint.npub().to_string(), b"ping".to_vec())
797 .await
798 .expect("loopback send should succeed");
799 let message = tokio::time::timeout(Duration::from_secs(1), endpoint.recv())
800 .await
801 .expect("recv should not time out")
802 .expect("message should arrive");
803 assert_eq!(message.source_node_addr, *endpoint.node_addr());
804 assert_eq!(message.source_npub, Some(endpoint.npub().to_string()));
805 assert_eq!(message.data, b"ping");
806 assert!(endpoint.discovery_scope().is_none());
807
808 endpoint.shutdown().await.expect("shutdown should succeed");
809 }
810
811 #[test]
812 fn discovery_scope_enables_default_scoped_udp_discovery() {
813 let config = FipsEndpoint::builder()
814 .discovery_scope("nostr-vpn:test")
815 .prepared_config();
816
817 assert!(!config.tun.enabled);
818 assert!(!config.dns.enabled);
819 assert!(!config.node.system_files_enabled);
820 assert!(config.node.discovery.nostr.enabled);
821 assert!(config.node.discovery.nostr.advertise);
822 assert_eq!(
823 config.node.discovery.nostr.policy,
824 NostrDiscoveryPolicy::Open
825 );
826 assert!(config.node.discovery.nostr.share_local_candidates);
827 assert_eq!(config.node.discovery.nostr.app, "nostr-vpn:test");
828 assert_eq!(
829 config.node.discovery.lan.scope.as_deref(),
830 Some("nostr-vpn:test")
831 );
832 assert!(config.node.discovery.local.enabled);
833
834 let udp = match config.transports.udp {
835 TransportInstances::Single(udp) => udp,
836 TransportInstances::Named(_) => panic!("expected a default UDP transport"),
837 };
838 assert_eq!(udp.bind_addr(), "0.0.0.0:0");
839 assert!(udp.advertise_on_nostr());
840 assert!(!udp.is_public());
841 assert!(!udp.outbound_only());
842 assert!(udp.accept_connections());
843 }
844
845 #[test]
846 fn local_ethernet_adds_scoped_discovery_transport() {
847 let config = FipsEndpoint::builder()
848 .discovery_scope("iris-chat:host")
849 .local_ethernet("fips-app0")
850 .prepared_config();
851
852 assert!(config.node.discovery.nostr.enabled);
853 assert_eq!(
854 config.node.discovery.lan.scope.as_deref(),
855 Some("iris-chat:host")
856 );
857
858 let eth = match config.transports.ethernet {
859 TransportInstances::Single(eth) => eth,
860 TransportInstances::Named(_) => panic!("expected a single Ethernet transport"),
861 };
862 assert_eq!(eth.interface, "fips-app0");
863 assert!(eth.discovery());
864 assert!(eth.announce());
865 assert!(eth.auto_connect());
866 assert!(eth.accept_connections());
867 assert_eq!(eth.discovery_scope(), Some("iris-chat:host"));
868 }
869
870 #[test]
871 fn local_ethernet_preserves_existing_ethernet_config() {
872 let mut explicit = Config::new();
873 explicit.transports.ethernet = TransportInstances::Single(EthernetConfig {
874 interface: "br-existing".to_string(),
875 announce: Some(false),
876 ..EthernetConfig::default()
877 });
878
879 let config = FipsEndpoint::builder()
880 .config(explicit)
881 .local_ethernet("fips-app0")
882 .prepared_config();
883
884 let TransportInstances::Named(map) = config.transports.ethernet else {
885 panic!("expected named Ethernet transports");
886 };
887 assert!(map.contains_key("default"));
888 let local = map
889 .get("local-ethernet-fips-app0")
890 .expect("local endpoint Ethernet transport");
891 assert_eq!(local.interface, "fips-app0");
892 assert!(local.announce());
893 assert!(local.auto_connect());
894 assert!(local.accept_connections());
895 }
896
897 #[test]
898 fn discovery_scope_preserves_explicit_connectivity_config() {
899 let mut explicit = Config::new();
900 explicit.node.discovery.nostr.enabled = true;
901 explicit.node.discovery.nostr.app = "custom-app".to_string();
902 explicit.node.discovery.nostr.policy = NostrDiscoveryPolicy::ConfiguredOnly;
903 explicit.node.discovery.nostr.share_local_candidates = false;
904 explicit.transports.udp = TransportInstances::Single(UdpConfig {
905 bind_addr: Some("127.0.0.1:34567".to_string()),
906 advertise_on_nostr: Some(false),
907 outbound_only: Some(true),
908 ..UdpConfig::default()
909 });
910
911 let config = FipsEndpoint::builder()
912 .config(explicit)
913 .discovery_scope("nostr-vpn:test")
914 .prepared_config();
915
916 assert_eq!(config.node.discovery.nostr.app, "custom-app");
917 assert_eq!(
918 config.node.discovery.nostr.policy,
919 NostrDiscoveryPolicy::ConfiguredOnly
920 );
921 assert!(!config.node.discovery.nostr.share_local_candidates);
922 assert_eq!(
923 config.node.discovery.lan.scope.as_deref(),
924 Some("nostr-vpn:test")
925 );
926 assert!(config.node.discovery.local.enabled);
927 let udp = match config.transports.udp {
928 TransportInstances::Single(udp) => udp,
929 TransportInstances::Named(_) => panic!("expected explicit UDP transport"),
930 };
931 assert_eq!(udp.bind_addr.as_deref(), Some("127.0.0.1:34567"));
932 assert_eq!(udp.bind_addr(), "0.0.0.0:0");
933 assert!(!udp.advertise_on_nostr());
934 assert!(udp.outbound_only());
935 }
936
937 #[tokio::test]
938 async fn invalid_remote_npub_is_rejected() {
939 let endpoint = FipsEndpoint::builder()
940 .without_system_tun()
941 .bind()
942 .await
943 .expect("endpoint should bind");
944
945 let error = endpoint
946 .send("not-an-npub", b"hello".to_vec())
947 .await
948 .expect_err("invalid npub should fail");
949 assert!(matches!(error, FipsEndpointError::InvalidRemoteNpub { .. }));
950
951 endpoint.shutdown().await.expect("shutdown should succeed");
952 }
953
954 #[tokio::test]
955 async fn endpoint_peer_snapshot_starts_empty() {
956 let endpoint = FipsEndpoint::builder()
957 .without_system_tun()
958 .bind()
959 .await
960 .expect("endpoint should bind");
961
962 let peers = endpoint.peers().await.expect("peer snapshot");
963 assert!(peers.is_empty());
964
965 endpoint.shutdown().await.expect("shutdown should succeed");
966 }
967}