1#[cfg(target_os = "linux")]
22mod gateway;
23mod node;
24mod peer;
25mod transport;
26
27use crate::upper::config::{DnsConfig, TunConfig};
28use crate::{Identity, IdentityError};
29use serde::{Deserialize, Serialize};
30use std::path::{Path, PathBuf};
31use thiserror::Error;
32
33pub use crate::discovery::local::LocalInstanceDiscoveryConfig;
34#[cfg(target_os = "linux")]
35pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
36pub use node::{
37 BloomConfig, BuffersConfig, CacheConfig, ConnectedUdpConfig, ControlConfig, DiscoveryConfig,
38 LimitsConfig, NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig,
39 RekeyConfig, RetryConfig, RoutingConfig, RoutingMode, SessionConfig, SessionMmpConfig,
40 TreeConfig,
41};
42pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
43#[cfg(feature = "sim-transport")]
44pub use transport::SimTransportConfig;
45pub use transport::{
46 BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
47 TransportsConfig, UdpConfig, WebRtcConfig,
48};
49
50const CONFIG_FILENAME: &str = "fips.yaml";
52
53const KEY_FILENAME: &str = "fips.key";
55
56const PUB_FILENAME: &str = "fips.pub";
58
59fn is_loopback_addr_str(addr: &str) -> bool {
66 if let Some(rest) = addr.strip_prefix('[')
68 && let Some(end) = rest.find(']')
69 {
70 let host = &rest[..end];
71 return host == "::1";
72 }
73 let host = match addr.rsplit_once(':') {
75 Some((h, _)) => h,
76 None => addr,
77 };
78 host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" || host.starts_with("127.")
79}
80
81pub fn key_file_path(config_path: &Path) -> PathBuf {
83 config_path
84 .parent()
85 .unwrap_or(Path::new("."))
86 .join(KEY_FILENAME)
87}
88
89pub fn pub_file_path(config_path: &Path) -> PathBuf {
91 config_path
92 .parent()
93 .unwrap_or(Path::new("."))
94 .join(PUB_FILENAME)
95}
96
97#[cfg(unix)]
105pub(crate) fn resolve_default_socket(filename: &str) -> String {
106 if Path::new("/run/fips").is_dir() {
107 return format!("/run/fips/{filename}");
108 }
109
110 if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR")
111 && Path::new(&xdg).is_dir()
112 {
113 return format!("{xdg}/fips/{filename}");
114 }
115
116 format!("/tmp/fips-{filename}")
117}
118
119pub fn default_control_path() -> PathBuf {
125 #[cfg(unix)]
126 {
127 PathBuf::from(resolve_default_socket("control.sock"))
128 }
129 #[cfg(windows)]
130 {
131 PathBuf::from("21210")
132 }
133}
134
135pub fn default_gateway_path() -> PathBuf {
140 #[cfg(unix)]
141 {
142 PathBuf::from(resolve_default_socket("gateway.sock"))
143 }
144 #[cfg(windows)]
145 {
146 PathBuf::from("21211")
147 }
148}
149
150pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
152 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
153 path: path.to_path_buf(),
154 source: e,
155 })?;
156 let nsec = contents.trim().to_string();
157 if nsec.is_empty() {
158 return Err(ConfigError::EmptyKeyFile {
159 path: path.to_path_buf(),
160 });
161 }
162 Ok(nsec)
163}
164
165pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
170 use std::io::Write;
171
172 let mut opts = std::fs::OpenOptions::new();
173 opts.write(true).create(true).truncate(true);
174
175 #[cfg(unix)]
176 {
177 use std::os::unix::fs::OpenOptionsExt;
178 opts.mode(0o600);
179 }
180
181 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
182 path: path.to_path_buf(),
183 source: e,
184 })?;
185
186 #[cfg(unix)]
187 {
188 use std::os::unix::fs::PermissionsExt;
189 file.set_permissions(std::fs::Permissions::from_mode(0o600))
190 .map_err(|e| ConfigError::WriteKeyFile {
191 path: path.to_path_buf(),
192 source: e,
193 })?;
194 }
195
196 file.write_all(nsec.as_bytes())
197 .map_err(|e| ConfigError::WriteKeyFile {
198 path: path.to_path_buf(),
199 source: e,
200 })?;
201 file.write_all(b"\n")
202 .map_err(|e| ConfigError::WriteKeyFile {
203 path: path.to_path_buf(),
204 source: e,
205 })?;
206 Ok(())
207}
208
209pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
214 use std::io::Write;
215
216 let mut opts = std::fs::OpenOptions::new();
217 opts.write(true).create(true).truncate(true);
218
219 #[cfg(unix)]
220 {
221 use std::os::unix::fs::OpenOptionsExt;
222 opts.mode(0o644);
223 }
224
225 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
226 path: path.to_path_buf(),
227 source: e,
228 })?;
229
230 file.write_all(npub.as_bytes())
231 .map_err(|e| ConfigError::WriteKeyFile {
232 path: path.to_path_buf(),
233 source: e,
234 })?;
235 file.write_all(b"\n")
236 .map_err(|e| ConfigError::WriteKeyFile {
237 path: path.to_path_buf(),
238 source: e,
239 })?;
240 Ok(())
241}
242
243pub fn resolve_identity(
260 config: &Config,
261 loaded_paths: &[PathBuf],
262) -> Result<ResolvedIdentity, ConfigError> {
263 use crate::encode_nsec;
264
265 if let Some(nsec) = &config.node.identity.nsec {
267 return Ok(ResolvedIdentity {
268 nsec: nsec.clone(),
269 source: IdentitySource::Config,
270 });
271 }
272
273 let config_ref = if let Some(path) = loaded_paths.last() {
275 path.clone()
276 } else {
277 Config::search_paths()
278 .first()
279 .cloned()
280 .unwrap_or_else(|| PathBuf::from("./fips.yaml"))
281 };
282 let key_path = key_file_path(&config_ref);
283 let pub_path = pub_file_path(&config_ref);
284
285 if config.node.identity.persistent {
286 if key_path.exists() {
288 let nsec = read_key_file(&key_path)?;
289 let identity = Identity::from_secret_str(&nsec)?;
290 let _ = write_pub_file(&pub_path, &identity.npub());
291 return Ok(ResolvedIdentity {
292 nsec,
293 source: IdentitySource::KeyFile(key_path),
294 });
295 }
296
297 let identity = Identity::generate();
299 let nsec = encode_nsec(&identity.keypair().secret_key());
300 let npub = identity.npub();
301
302 if let Some(parent) = key_path.parent() {
303 let _ = std::fs::create_dir_all(parent);
304 }
305
306 match write_key_file(&key_path, &nsec) {
307 Ok(()) => {
308 let _ = write_pub_file(&pub_path, &npub);
309 Ok(ResolvedIdentity {
310 nsec,
311 source: IdentitySource::Generated(key_path),
312 })
313 }
314 Err(_) => Ok(ResolvedIdentity {
315 nsec,
316 source: IdentitySource::Ephemeral,
317 }),
318 }
319 } else {
320 let identity = Identity::generate();
323 let nsec = encode_nsec(&identity.keypair().secret_key());
324 let npub = identity.npub();
325
326 if let Some(parent) = key_path.parent() {
327 let _ = std::fs::create_dir_all(parent);
328 }
329
330 let _ = write_key_file(&key_path, &nsec);
331 let _ = write_pub_file(&pub_path, &npub);
332
333 Ok(ResolvedIdentity {
334 nsec,
335 source: IdentitySource::Ephemeral,
336 })
337 }
338}
339
340pub struct ResolvedIdentity {
342 pub nsec: String,
344 pub source: IdentitySource,
346}
347
348pub enum IdentitySource {
350 Config,
352 KeyFile(PathBuf),
354 Generated(PathBuf),
356 Ephemeral,
358}
359
360#[derive(Debug, Error)]
362pub enum ConfigError {
363 #[error("failed to read config file {path}: {source}")]
364 ReadFile {
365 path: PathBuf,
366 source: std::io::Error,
367 },
368
369 #[error("failed to parse config file {path}: {source}")]
370 ParseYaml {
371 path: PathBuf,
372 source: serde_yaml::Error,
373 },
374
375 #[error("key file is empty: {path}")]
376 EmptyKeyFile { path: PathBuf },
377
378 #[error("failed to write key file {path}: {source}")]
379 WriteKeyFile {
380 path: PathBuf,
381 source: std::io::Error,
382 },
383
384 #[error("identity error: {0}")]
385 Identity(#[from] IdentityError),
386
387 #[error("invalid configuration: {0}")]
388 Validation(String),
389}
390
391#[derive(Debug, Clone, Default, Serialize, Deserialize)]
393pub struct IdentityConfig {
394 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub nsec: Option<String>,
398
399 #[serde(default)]
403 pub persistent: bool,
404}
405
406#[derive(Debug, Clone, Default, Serialize, Deserialize)]
408pub struct Config {
409 #[serde(default)]
411 pub node: NodeConfig,
412
413 #[serde(default)]
415 pub tun: TunConfig,
416
417 #[serde(default)]
419 pub dns: DnsConfig,
420
421 #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
423 pub transports: TransportsConfig,
424
425 #[serde(default, skip_serializing_if = "Vec::is_empty")]
427 pub peers: Vec<PeerConfig>,
428
429 #[cfg(target_os = "linux")]
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub gateway: Option<GatewayConfig>,
433}
434
435impl Config {
436 pub fn new() -> Self {
438 Self::default()
439 }
440
441 pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
451 let search_paths = Self::search_paths();
452 Self::load_from_paths(&search_paths)
453 }
454
455 pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
459 let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
460 let mut loaded_paths = Vec::new();
461
462 for path in paths {
463 if path.exists() {
464 let contents =
465 std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
466 path: path.to_path_buf(),
467 source: e,
468 })?;
469 let mut file_config: serde_yaml::Value =
470 serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
471 path: path.to_path_buf(),
472 source: e,
473 })?;
474 if file_config.is_null() {
475 file_config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
476 }
477 merge_yaml_value(&mut merged, file_config);
478 loaded_paths.push(path.clone());
479 }
480 }
481
482 let config = serde_yaml::from_value(merged).map_err(|e| ConfigError::ParseYaml {
483 path: loaded_paths
484 .last()
485 .cloned()
486 .unwrap_or_else(|| PathBuf::from("<merged config>")),
487 source: e,
488 })?;
489
490 Ok((config, loaded_paths))
491 }
492
493 pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
495 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
496 path: path.to_path_buf(),
497 source: e,
498 })?;
499
500 serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
501 path: path.to_path_buf(),
502 source: e,
503 })
504 }
505
506 pub fn search_paths() -> Vec<PathBuf> {
508 let mut paths = Vec::new();
509
510 paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
512
513 if let Some(config_dir) = dirs::config_dir() {
515 paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
516 }
517
518 if let Some(home_dir) = dirs::home_dir() {
520 paths.push(home_dir.join(".fips.yaml"));
521 }
522
523 paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
525
526 paths
527 }
528
529 pub fn merge(&mut self, other: Config) {
533 if other.node.identity.nsec.is_some() {
535 self.node.identity.nsec = other.node.identity.nsec;
536 }
537 if other.node.identity.persistent {
538 self.node.identity.persistent = true;
539 }
540 if other.node.leaf_only {
542 self.node.leaf_only = true;
543 }
544 if other.tun.enabled {
546 self.tun.enabled = true;
547 }
548 if other.tun.name.is_some() {
549 self.tun.name = other.tun.name;
550 }
551 if other.tun.mtu.is_some() {
552 self.tun.mtu = other.tun.mtu;
553 }
554 self.dns.enabled = other.dns.enabled;
556 if other.dns.bind_addr.is_some() {
557 self.dns.bind_addr = other.dns.bind_addr;
558 }
559 if other.dns.port.is_some() {
560 self.dns.port = other.dns.port;
561 }
562 if other.dns.ttl.is_some() {
563 self.dns.ttl = other.dns.ttl;
564 }
565 self.transports.merge(other.transports);
567 if !other.peers.is_empty() {
569 self.peers = other.peers;
570 }
571 #[cfg(target_os = "linux")]
573 if other.gateway.is_some() {
574 self.gateway = other.gateway;
575 }
576 }
577
578 pub fn create_identity(&self) -> Result<Identity, ConfigError> {
583 match &self.node.identity.nsec {
584 Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
585 None => Ok(Identity::generate()),
586 }
587 }
588
589 pub fn has_identity(&self) -> bool {
591 self.node.identity.nsec.is_some()
592 }
593
594 pub fn is_leaf_only(&self) -> bool {
596 self.node.leaf_only
597 }
598
599 pub fn peers(&self) -> &[PeerConfig] {
601 &self.peers
602 }
603
604 pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
606 self.peers.iter().filter(|p| p.is_auto_connect())
607 }
608
609 pub fn validate(&self) -> Result<(), ConfigError> {
611 let nostr = &self.node.discovery.nostr;
612
613 let any_transport_advertises_on_nostr = self
614 .transports
615 .udp
616 .iter()
617 .any(|(_, cfg)| cfg.advertise_on_nostr())
618 || self
619 .transports
620 .tcp
621 .iter()
622 .any(|(_, cfg)| cfg.advertise_on_nostr())
623 || self
624 .transports
625 .tor
626 .iter()
627 .any(|(_, cfg)| cfg.advertise_on_nostr())
628 || self
629 .transports
630 .webrtc
631 .iter()
632 .any(|(_, cfg)| cfg.advertise_on_nostr());
633
634 if any_transport_advertises_on_nostr && !nostr.enabled {
635 return Err(ConfigError::Validation(
636 "at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
637 ));
638 }
639
640 for (i, peer) in self.peers.iter().enumerate() {
641 if peer.addresses.is_empty() && !nostr.enabled {
642 return Err(ConfigError::Validation(format!(
643 "peers[{i}] ({}): must specify at least one address, or enable `node.discovery.nostr` to resolve endpoints from Nostr adverts",
644 peer.npub
645 )));
646 }
647 }
648
649 let has_nat_udp_advert = self
650 .transports
651 .udp
652 .iter()
653 .any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
654
655 if nostr.enabled && has_nat_udp_advert {
656 if nostr.dm_relays.is_empty() {
657 return Err(ConfigError::Validation(
658 "NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
659 ));
660 }
661 if nostr.stun_servers.is_empty() {
662 return Err(ConfigError::Validation(
663 "NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
664 ));
665 }
666 }
667
668 let has_webrtc_advert_without_relays = self.transports.webrtc.iter().any(|(_, cfg)| {
669 cfg.advertise_on_nostr() && cfg.signal_relays(&nostr.dm_relays).is_empty()
670 });
671
672 if nostr.enabled && has_webrtc_advert_without_relays {
673 return Err(ConfigError::Validation(
674 "WebRTC advert publishing requires `node.discovery.nostr.dm_relays` or `transports.webrtc.signal_relays` to be non-empty".to_string(),
675 ));
676 }
677
678 for (name, cfg) in self.transports.udp.iter() {
685 if cfg.outbound_only() {
686 continue;
687 }
688 if is_loopback_addr_str(cfg.bind_addr()) {
689 let any_external_peer = self.peers.iter().any(|peer| {
690 peer.addresses
691 .iter()
692 .any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
693 });
694 if any_external_peer {
695 let label = name.unwrap_or("(unnamed)");
696 return Err(ConfigError::Validation(format!(
697 "transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
698 fips cannot reach external peers from a loopback-bound socket. \
699 Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
700 cfg.bind_addr()
701 )));
702 }
703 }
704 }
705
706 Ok(())
707 }
708
709 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
711 serde_yaml::to_string(self)
712 }
713}
714
715fn merge_yaml_value(base: &mut serde_yaml::Value, overlay: serde_yaml::Value) {
716 match (base, overlay) {
717 (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(overlay_map)) => {
718 for (key, value) in overlay_map {
719 match base_map.get_mut(&key) {
720 Some(existing) => merge_yaml_value(existing, value),
721 None => {
722 base_map.insert(key, value);
723 }
724 }
725 }
726 }
727 (base_slot, overlay) => *base_slot = overlay,
728 }
729}
730
731#[cfg(test)]
732mod tests {
733 use super::*;
734 use std::collections::HashMap;
735 use std::fs;
736 use tempfile::TempDir;
737
738 #[cfg(unix)]
739 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
740
741 #[test]
742 fn test_empty_config() {
743 let config = Config::new();
744 assert!(config.node.identity.nsec.is_none());
745 assert!(!config.has_identity());
746 }
747
748 #[test]
749 fn test_parse_yaml_with_nsec() {
750 let yaml = r#"
751node:
752 identity:
753 nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
754"#;
755 let config: Config = serde_yaml::from_str(yaml).unwrap();
756 assert!(config.node.identity.nsec.is_some());
757 assert!(config.has_identity());
758 }
759
760 #[test]
761 fn test_parse_yaml_with_hex() {
762 let yaml = r#"
763node:
764 identity:
765 nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
766"#;
767 let config: Config = serde_yaml::from_str(yaml).unwrap();
768 assert!(config.node.identity.nsec.is_some());
769
770 let identity = config.create_identity().unwrap();
771 assert!(!identity.npub().is_empty());
772 }
773
774 #[test]
775 fn test_parse_yaml_empty() {
776 let yaml = "";
777 let config: Config = serde_yaml::from_str(yaml).unwrap();
778 assert!(config.node.identity.nsec.is_none());
779 }
780
781 #[test]
782 fn test_parse_yaml_partial() {
783 let yaml = r#"
784node:
785 identity: {}
786"#;
787 let config: Config = serde_yaml::from_str(yaml).unwrap();
788 assert!(config.node.identity.nsec.is_none());
789 }
790
791 #[test]
792 fn test_merge_configs() {
793 let mut base = Config::new();
794 base.node.identity.nsec = Some("base_nsec".to_string());
795
796 let mut override_config = Config::new();
797 override_config.node.identity.nsec = Some("override_nsec".to_string());
798
799 base.merge(override_config);
800 assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
801 }
802
803 #[test]
804 fn test_merge_preserves_base_when_override_empty() {
805 let mut base = Config::new();
806 base.node.identity.nsec = Some("base_nsec".to_string());
807
808 let override_config = Config::new();
809
810 base.merge(override_config);
811 assert_eq!(base.node.identity.nsec, Some("base_nsec".to_string()));
812 }
813
814 #[test]
815 fn test_create_identity_from_nsec() {
816 let mut config = Config::new();
817 config.node.identity.nsec =
818 Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
819
820 let identity = config.create_identity().unwrap();
821 assert!(!identity.npub().is_empty());
822 }
823
824 #[test]
825 fn test_create_identity_generates_new() {
826 let config = Config::new();
827 let identity = config.create_identity().unwrap();
828 assert!(!identity.npub().is_empty());
829 }
830
831 #[test]
832 fn test_load_from_file() {
833 let temp_dir = TempDir::new().unwrap();
834 let config_path = temp_dir.path().join("fips.yaml");
835
836 let yaml = r#"
837node:
838 identity:
839 nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
840"#;
841 fs::write(&config_path, yaml).unwrap();
842
843 let config = Config::load_file(&config_path).unwrap();
844 assert!(config.node.identity.nsec.is_some());
845 }
846
847 #[test]
848 fn test_load_from_paths_merges() {
849 let temp_dir = TempDir::new().unwrap();
850
851 let low_priority = temp_dir.path().join("low.yaml");
853 let high_priority = temp_dir.path().join("high.yaml");
854
855 fs::write(
856 &low_priority,
857 r#"
858node:
859 identity:
860 nsec: "low_priority_nsec"
861"#,
862 )
863 .unwrap();
864
865 fs::write(
866 &high_priority,
867 r#"
868node:
869 identity:
870 nsec: "high_priority_nsec"
871"#,
872 )
873 .unwrap();
874
875 let paths = vec![low_priority.clone(), high_priority.clone()];
876 let (config, loaded) = Config::load_from_paths(&paths).unwrap();
877
878 assert_eq!(loaded.len(), 2);
879 assert_eq!(
880 config.node.identity.nsec,
881 Some("high_priority_nsec".to_string())
882 );
883 }
884
885 #[test]
886 fn test_load_from_paths_deep_merges_partial_node_sections() {
887 let temp_dir = TempDir::new().unwrap();
888 let low_priority = temp_dir.path().join("low.yaml");
889 let high_priority = temp_dir.path().join("high.yaml");
890
891 fs::write(
892 &low_priority,
893 r#"
894node:
895 limits:
896 max_peers: 4096
897 connected_udp:
898 fd_reserve: 2048
899"#,
900 )
901 .unwrap();
902
903 fs::write(
904 &high_priority,
905 r#"
906node:
907 identity:
908 nsec: "high_priority_nsec"
909 connected_udp:
910 enabled: false
911"#,
912 )
913 .unwrap();
914
915 let (config, loaded) =
916 Config::load_from_paths(&[low_priority.clone(), high_priority.clone()]).unwrap();
917
918 assert_eq!(loaded, vec![low_priority, high_priority]);
919 assert_eq!(config.node.limits.max_peers, 4096);
920 assert!(!config.node.connected_udp.enabled);
921 assert_eq!(config.node.connected_udp.fd_reserve, 2048);
922 assert_eq!(
923 config.node.identity.nsec,
924 Some("high_priority_nsec".to_string())
925 );
926 }
927
928 #[test]
929 fn test_load_skips_missing_files() {
930 let temp_dir = TempDir::new().unwrap();
931 let existing = temp_dir.path().join("exists.yaml");
932 let missing = temp_dir.path().join("missing.yaml");
933
934 fs::write(
935 &existing,
936 r#"
937node:
938 identity:
939 nsec: "existing_nsec"
940"#,
941 )
942 .unwrap();
943
944 let paths = vec![missing, existing.clone()];
945 let (config, loaded) = Config::load_from_paths(&paths).unwrap();
946
947 assert_eq!(loaded.len(), 1);
948 assert_eq!(loaded[0], existing);
949 assert_eq!(config.node.identity.nsec, Some("existing_nsec".to_string()));
950 }
951
952 #[test]
953 fn test_search_paths_includes_expected() {
954 let paths = Config::search_paths();
955
956 assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
958
959 #[cfg(unix)]
961 assert!(
962 paths
963 .iter()
964 .any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
965 );
966 }
967
968 #[test]
969 fn test_to_yaml() {
970 let mut config = Config::new();
971 config.node.identity.nsec = Some("test_nsec".to_string());
972
973 let yaml = config.to_yaml().unwrap();
974 assert!(yaml.contains("node:"));
975 assert!(yaml.contains("identity:"));
976 assert!(yaml.contains("nsec:"));
977 assert!(yaml.contains("test_nsec"));
978 }
979
980 #[test]
981 fn test_key_file_write_read_roundtrip() {
982 let temp_dir = TempDir::new().unwrap();
983 let key_path = temp_dir.path().join("fips.key");
984
985 let identity = crate::Identity::generate();
986 let nsec = crate::encode_nsec(&identity.keypair().secret_key());
987
988 write_key_file(&key_path, &nsec).unwrap();
989
990 let loaded_nsec = read_key_file(&key_path).unwrap();
991 assert_eq!(loaded_nsec, nsec);
992
993 let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
995 assert_eq!(loaded_identity.npub(), identity.npub());
996 }
997
998 #[cfg(unix)]
999 #[test]
1000 fn test_key_file_permissions() {
1001 use std::os::unix::fs::MetadataExt;
1002
1003 let temp_dir = TempDir::new().unwrap();
1004 let key_path = temp_dir.path().join("fips.key");
1005
1006 write_key_file(&key_path, "nsec1test").unwrap();
1007
1008 let metadata = fs::metadata(&key_path).unwrap();
1009 assert_eq!(metadata.mode() & 0o777, 0o600);
1010 }
1011
1012 #[cfg(unix)]
1013 #[test]
1014 fn test_key_file_permissions_are_tightened_on_overwrite() {
1015 use std::os::unix::fs::{MetadataExt, PermissionsExt};
1016
1017 let temp_dir = TempDir::new().unwrap();
1018 let key_path = temp_dir.path().join("fips.key");
1019 fs::write(&key_path, "old\n").unwrap();
1020 fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap();
1021
1022 write_key_file(&key_path, "nsec1test").unwrap();
1023
1024 let metadata = fs::metadata(&key_path).unwrap();
1025 assert_eq!(metadata.mode() & 0o777, 0o600);
1026 assert_eq!(read_key_file(&key_path).unwrap(), "nsec1test");
1027 }
1028
1029 #[cfg(unix)]
1030 #[test]
1031 fn test_pub_file_permissions() {
1032 use std::os::unix::fs::MetadataExt;
1033
1034 let temp_dir = TempDir::new().unwrap();
1035 let pub_path = temp_dir.path().join("fips.pub");
1036
1037 write_pub_file(&pub_path, "npub1test").unwrap();
1038
1039 let metadata = fs::metadata(&pub_path).unwrap();
1040 assert_eq!(metadata.mode() & 0o777, 0o644);
1041 }
1042
1043 #[test]
1044 fn test_key_file_empty_error() {
1045 let temp_dir = TempDir::new().unwrap();
1046 let key_path = temp_dir.path().join("fips.key");
1047
1048 fs::write(&key_path, "").unwrap();
1049
1050 let result = read_key_file(&key_path);
1051 assert!(result.is_err());
1052 assert!(result.unwrap_err().to_string().contains("empty"));
1053 }
1054
1055 #[test]
1056 fn test_key_file_whitespace_trimmed() {
1057 let temp_dir = TempDir::new().unwrap();
1058 let key_path = temp_dir.path().join("fips.key");
1059
1060 fs::write(&key_path, " nsec1test \n").unwrap();
1061
1062 let nsec = read_key_file(&key_path).unwrap();
1063 assert_eq!(nsec, "nsec1test");
1064 }
1065
1066 #[test]
1067 fn test_key_file_path_derivation() {
1068 let config_path = PathBuf::from("/etc/fips/fips.yaml");
1069 assert_eq!(
1070 key_file_path(&config_path),
1071 PathBuf::from("/etc/fips/fips.key")
1072 );
1073 assert_eq!(
1074 pub_file_path(&config_path),
1075 PathBuf::from("/etc/fips/fips.pub")
1076 );
1077 }
1078
1079 #[cfg(windows)]
1080 #[test]
1081 fn test_key_file_write_read_roundtrip_windows() {
1082 let temp_dir = TempDir::new().unwrap();
1083 let key_path = temp_dir.path().join("fips.key");
1084
1085 let identity = crate::Identity::generate();
1086 let nsec = crate::encode_nsec(&identity.keypair().secret_key());
1087
1088 write_key_file(&key_path, &nsec).unwrap();
1089
1090 let loaded_nsec = read_key_file(&key_path).unwrap();
1092 assert_eq!(loaded_nsec, nsec);
1093
1094 let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
1096 assert_eq!(loaded_identity.npub(), identity.npub());
1097 }
1098
1099 #[test]
1100 fn test_resolve_identity_from_config() {
1101 let mut config = Config::new();
1102 config.node.identity.nsec =
1103 Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
1104
1105 let resolved = resolve_identity(&config, &[]).unwrap();
1106 assert!(matches!(resolved.source, IdentitySource::Config));
1107 }
1108
1109 #[test]
1110 fn test_resolve_identity_ephemeral_by_default() {
1111 let temp_dir = TempDir::new().unwrap();
1112 let config_path = temp_dir.path().join("fips.yaml");
1113
1114 fs::write(&config_path, "node:\n identity: {}\n").unwrap();
1115
1116 let config = Config::load_file(&config_path).unwrap();
1117 assert!(!config.node.identity.persistent);
1118
1119 let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1120 assert!(matches!(resolved.source, IdentitySource::Ephemeral));
1121
1122 let key_path = temp_dir.path().join("fips.key");
1124 let pub_path = temp_dir.path().join("fips.pub");
1125 assert!(key_path.exists());
1126 assert!(pub_path.exists());
1127 }
1128
1129 #[test]
1130 fn test_resolve_identity_ephemeral_changes_each_call() {
1131 let temp_dir = TempDir::new().unwrap();
1132 let config_path = temp_dir.path().join("fips.yaml");
1133
1134 fs::write(&config_path, "node:\n identity: {}\n").unwrap();
1135
1136 let config = Config::load_file(&config_path).unwrap();
1137 let first = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1138 let second = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1139
1140 assert_ne!(first.nsec, second.nsec);
1142 }
1143
1144 #[test]
1145 fn test_resolve_identity_persistent_from_key_file() {
1146 let temp_dir = TempDir::new().unwrap();
1147 let config_path = temp_dir.path().join("fips.yaml");
1148 let key_path = temp_dir.path().join("fips.key");
1149
1150 fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
1151
1152 let identity = crate::Identity::generate();
1154 let nsec = crate::encode_nsec(&identity.keypair().secret_key());
1155 write_key_file(&key_path, &nsec).unwrap();
1156
1157 let config = Config::load_file(&config_path).unwrap();
1158 assert!(config.node.identity.persistent);
1159
1160 let resolved = resolve_identity(&config, &[config_path]).unwrap();
1161 assert!(matches!(resolved.source, IdentitySource::KeyFile(_)));
1162 assert_eq!(resolved.nsec, nsec);
1163 }
1164
1165 #[test]
1166 fn test_resolve_identity_persistent_generates_and_persists() {
1167 let temp_dir = TempDir::new().unwrap();
1168 let config_path = temp_dir.path().join("fips.yaml");
1169
1170 fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
1171
1172 let config = Config::load_file(&config_path).unwrap();
1173 let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1174
1175 assert!(matches!(resolved.source, IdentitySource::Generated(_)));
1176
1177 let key_path = temp_dir.path().join("fips.key");
1179 let pub_path = temp_dir.path().join("fips.pub");
1180 assert!(key_path.exists());
1181 assert!(pub_path.exists());
1182
1183 let resolved2 = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1185 assert!(matches!(resolved2.source, IdentitySource::KeyFile(_)));
1186 assert_eq!(resolved.nsec, resolved2.nsec);
1187 }
1188
1189 #[test]
1190 fn test_to_yaml_empty_nsec_omitted() {
1191 let config = Config::new();
1192 let yaml = config.to_yaml().unwrap();
1193
1194 assert!(!yaml.contains("nsec:"));
1196 }
1197
1198 #[test]
1199 fn test_parse_transport_single_instance() {
1200 let yaml = r#"
1201transports:
1202 udp:
1203 bind_addr: "0.0.0.0:2121"
1204 mtu: 1400
1205"#;
1206 let config: Config = serde_yaml::from_str(yaml).unwrap();
1207
1208 assert_eq!(config.transports.udp.len(), 1);
1209 let instances: Vec<_> = config.transports.udp.iter().collect();
1210 assert_eq!(instances.len(), 1);
1211 assert_eq!(instances[0].0, None); assert_eq!(instances[0].1.bind_addr(), "0.0.0.0:2121");
1213 assert_eq!(instances[0].1.mtu(), 1400);
1214 }
1215
1216 #[test]
1217 fn test_parse_transport_named_instances() {
1218 let yaml = r#"
1219transports:
1220 udp:
1221 main:
1222 bind_addr: "0.0.0.0:2121"
1223 backup:
1224 bind_addr: "192.168.1.100:2122"
1225 mtu: 1280
1226"#;
1227 let config: Config = serde_yaml::from_str(yaml).unwrap();
1228
1229 assert_eq!(config.transports.udp.len(), 2);
1230
1231 let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
1232
1233 assert!(instances.contains_key(&Some("main")));
1235 assert!(instances.contains_key(&Some("backup")));
1236 assert_eq!(instances[&Some("main")].bind_addr(), "0.0.0.0:2121");
1237 assert_eq!(instances[&Some("backup")].bind_addr(), "192.168.1.100:2122");
1238 assert_eq!(instances[&Some("backup")].mtu(), 1280);
1239 }
1240
1241 #[test]
1242 fn test_parse_transport_empty() {
1243 let yaml = r#"
1244transports: {}
1245"#;
1246 let config: Config = serde_yaml::from_str(yaml).unwrap();
1247 assert!(config.transports.udp.is_empty());
1248 assert!(config.transports.is_empty());
1249 }
1250
1251 #[test]
1252 fn test_transport_instances_iter() {
1253 let single = TransportInstances::Single(UdpConfig {
1255 bind_addr: Some("0.0.0.0:2121".to_string()),
1256 mtu: None,
1257 ..Default::default()
1258 });
1259 let items: Vec<_> = single.iter().collect();
1260 assert_eq!(items.len(), 1);
1261 assert_eq!(items[0].0, None);
1262
1263 let mut map = HashMap::new();
1265 map.insert("a".to_string(), UdpConfig::default());
1266 map.insert("b".to_string(), UdpConfig::default());
1267 let named = TransportInstances::Named(map);
1268 let items: Vec<_> = named.iter().collect();
1269 assert_eq!(items.len(), 2);
1270 assert!(items.iter().all(|(name, _)| name.is_some()));
1272 }
1273
1274 #[test]
1275 fn test_parse_peer_config() {
1276 let yaml = r#"
1277peers:
1278 - npub: "npub1abc123"
1279 alias: "gateway"
1280 addresses:
1281 - transport: udp
1282 addr: "192.168.1.1:2121"
1283 priority: 1
1284 - transport: tor
1285 addr: "xyz.onion:2121"
1286 priority: 2
1287 connect_policy: auto_connect
1288"#;
1289 let config: Config = serde_yaml::from_str(yaml).unwrap();
1290
1291 assert_eq!(config.peers.len(), 1);
1292 let peer = &config.peers[0];
1293 assert_eq!(peer.npub, "npub1abc123");
1294 assert_eq!(peer.alias, Some("gateway".to_string()));
1295 assert_eq!(peer.addresses.len(), 2);
1296 assert!(peer.is_auto_connect());
1297
1298 let sorted = peer.addresses_by_priority();
1300 assert_eq!(sorted[0].transport, "udp");
1301 assert_eq!(sorted[0].priority, 1);
1302 assert_eq!(sorted[1].transport, "tor");
1303 assert_eq!(sorted[1].priority, 2);
1304 }
1305
1306 #[test]
1307 fn test_parse_peer_minimal() {
1308 let yaml = r#"
1309peers:
1310 - npub: "npub1xyz"
1311 addresses:
1312 - transport: udp
1313 addr: "10.0.0.1:2121"
1314"#;
1315 let config: Config = serde_yaml::from_str(yaml).unwrap();
1316
1317 assert_eq!(config.peers.len(), 1);
1318 let peer = &config.peers[0];
1319 assert_eq!(peer.npub, "npub1xyz");
1320 assert!(peer.alias.is_none());
1321 assert!(peer.is_auto_connect());
1323 assert_eq!(peer.addresses[0].priority, 100);
1325 }
1326
1327 #[test]
1328 fn test_parse_multiple_peers() {
1329 let yaml = r#"
1330peers:
1331 - npub: "npub1peer1"
1332 addresses:
1333 - transport: udp
1334 addr: "10.0.0.1:2121"
1335 - npub: "npub1peer2"
1336 addresses:
1337 - transport: udp
1338 addr: "10.0.0.2:2121"
1339 connect_policy: on_demand
1340"#;
1341 let config: Config = serde_yaml::from_str(yaml).unwrap();
1342
1343 assert_eq!(config.peers.len(), 2);
1344 assert_eq!(config.auto_connect_peers().count(), 1);
1345 }
1346
1347 #[test]
1348 fn test_peer_config_builder() {
1349 let peer = PeerConfig::new("npub1test", "udp", "192.168.1.1:2121")
1350 .with_alias("test-peer")
1351 .with_address(PeerAddress::with_priority("tor", "xyz.onion:2121", 50));
1352
1353 assert_eq!(peer.npub, "npub1test");
1354 assert_eq!(peer.alias, Some("test-peer".to_string()));
1355 assert_eq!(peer.addresses.len(), 2);
1356 assert!(peer.is_auto_connect());
1357 }
1358
1359 #[test]
1360 fn test_parse_nostr_discovery_config() {
1361 let yaml = r#"
1362node:
1363 discovery:
1364 nostr:
1365 enabled: true
1366 advertise: false
1367 policy: configured_only
1368 open_discovery_max_pending: 12
1369 app: "fips.nat.test.v1"
1370 signal_ttl_secs: 45
1371 advert_relays:
1372 - "wss://relay-a.example"
1373 dm_relays:
1374 - "wss://relay-b.example"
1375 stun_servers:
1376 - "stun:stun.example.org:3478"
1377peers:
1378 - npub: "npub1peer"
1379 addresses:
1380 - transport: udp
1381 addr: "nat"
1382"#;
1383 let config: Config = serde_yaml::from_str(yaml).unwrap();
1384 assert!(config.node.discovery.nostr.enabled);
1385 assert!(!config.node.discovery.nostr.advertise);
1386 assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
1387 assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
1388 assert_eq!(
1389 config.node.discovery.nostr.policy,
1390 NostrDiscoveryPolicy::ConfiguredOnly
1391 );
1392 assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
1393 assert_eq!(
1394 config.node.discovery.nostr.advert_relays,
1395 vec!["wss://relay-a.example".to_string()]
1396 );
1397 assert_eq!(
1398 config.node.discovery.nostr.dm_relays,
1399 vec!["wss://relay-b.example".to_string()]
1400 );
1401 assert_eq!(
1402 config.node.discovery.nostr.stun_servers,
1403 vec!["stun:stun.example.org:3478".to_string()]
1404 );
1405 assert_eq!(
1406 config.peers[0].addresses[0].addr, "nat",
1407 "udp:nat address should parse without special-casing in YAML"
1408 );
1409 }
1410
1411 #[test]
1412 fn test_validate_transport_advert_requires_nostr_enabled() {
1413 let mut config = Config::default();
1414 config.transports.udp = TransportInstances::Single(UdpConfig {
1415 advertise_on_nostr: Some(true),
1416 ..Default::default()
1417 });
1418 config.node.discovery.nostr.enabled = false;
1419
1420 let err = config.validate().expect_err("validation should fail");
1421 assert!(err.to_string().contains("advertise_on_nostr"));
1422
1423 config.transports.udp = TransportInstances::default();
1424 config.transports.webrtc = TransportInstances::Single(WebRtcConfig {
1425 advertise_on_nostr: Some(true),
1426 ..Default::default()
1427 });
1428
1429 let err = config.validate().expect_err("validation should fail");
1430 assert!(err.to_string().contains("advertise_on_nostr"));
1431 }
1432
1433 #[test]
1434 fn test_validate_empty_peer_addresses_require_nostr_enabled() {
1435 let mut config = Config {
1436 peers: vec![PeerConfig {
1437 npub: "npub1peer".to_string(),
1438 ..Default::default()
1439 }],
1440 ..Default::default()
1441 };
1442 config.node.discovery.nostr.enabled = false;
1443
1444 let err = config.validate().expect_err("validation should fail");
1445 assert!(err.to_string().contains("node.discovery.nostr"));
1446 }
1447
1448 #[test]
1449 fn test_validate_peer_addresses_optional_with_nostr_enabled() {
1450 let mut config = Config {
1452 peers: vec![PeerConfig {
1453 npub: "npub1peer".to_string(),
1454 ..Default::default()
1455 }],
1456 ..Default::default()
1457 };
1458 let err = config.validate().expect_err("validation should fail");
1459 assert!(err.to_string().contains("at least one address"));
1460
1461 config.node.discovery.nostr.enabled = true;
1463 config
1464 .validate()
1465 .expect("Nostr discovery should allow empty addresses");
1466 }
1467
1468 #[test]
1469 fn test_validate_nat_udp_advert_requires_relays_and_stun() {
1470 let mut config = Config::default();
1471 config.node.discovery.nostr.enabled = true;
1472 config.node.discovery.nostr.dm_relays.clear();
1473 config.transports.udp = TransportInstances::Single(UdpConfig {
1474 advertise_on_nostr: Some(true),
1475 public: Some(false),
1476 ..Default::default()
1477 });
1478
1479 let err = config.validate().expect_err("validation should fail");
1480 assert!(err.to_string().contains("dm_relays"));
1481
1482 config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
1483 config.node.discovery.nostr.stun_servers.clear();
1484 let err = config.validate().expect_err("validation should fail");
1485 assert!(err.to_string().contains("stun_servers"));
1486 }
1487
1488 #[test]
1489 fn test_validate_webrtc_advert_requires_relays() {
1490 let mut config = Config::default();
1491 config.node.discovery.nostr.enabled = true;
1492 config.node.discovery.nostr.dm_relays.clear();
1493 config.transports.webrtc = TransportInstances::Single(WebRtcConfig {
1494 advertise_on_nostr: Some(true),
1495 ..Default::default()
1496 });
1497
1498 let err = config.validate().expect_err("validation should fail");
1499 assert!(err.to_string().contains("dm_relays"));
1500
1501 if let TransportInstances::Single(cfg) = &mut config.transports.webrtc {
1502 cfg.signal_relays = Some(vec!["wss://relay.example".to_string()]);
1503 }
1504 config
1505 .validate()
1506 .expect("WebRTC transport-specific relays should satisfy validation");
1507 }
1508
1509 #[test]
1510 fn test_is_loopback_addr_str() {
1511 assert!(is_loopback_addr_str("127.0.0.1:2121"));
1512 assert!(is_loopback_addr_str("127.0.0.5:9999"));
1513 assert!(is_loopback_addr_str("[::1]:2121"));
1514 assert!(is_loopback_addr_str("::1:2121"));
1515 assert!(is_loopback_addr_str("localhost:80"));
1516 assert!(!is_loopback_addr_str("0.0.0.0:2121"));
1517 assert!(!is_loopback_addr_str("192.168.1.1:2121"));
1518 assert!(!is_loopback_addr_str("[fd00::1]:2121"));
1519 assert!(!is_loopback_addr_str("core-vm.tail65015.ts.net:2121"));
1520 assert!(!is_loopback_addr_str("example.com:443"));
1521 }
1522
1523 #[cfg(unix)]
1524 #[test]
1525 fn test_resolve_default_socket_call_sites_agree() {
1526 let _guard = ENV_MUTEX.lock().unwrap();
1527
1528 let control_client = default_control_path().to_string_lossy().into_owned();
1529 let gateway_client = default_gateway_path().to_string_lossy().into_owned();
1530 let control_daemon = ControlConfig::default().socket_path;
1531
1532 assert_eq!(control_daemon, control_client);
1533
1534 let control_dir = Path::new(&control_client)
1535 .parent()
1536 .map(|p| p.to_string_lossy().into_owned())
1537 .unwrap_or_default();
1538 let gateway_dir = Path::new(&gateway_client)
1539 .parent()
1540 .map(|p| p.to_string_lossy().into_owned())
1541 .unwrap_or_default();
1542 assert_eq!(control_dir, gateway_dir);
1543 }
1544
1545 #[cfg(unix)]
1546 #[test]
1547 fn test_resolve_default_socket_xdg_when_no_run_fips() {
1548 let _guard = ENV_MUTEX.lock().unwrap();
1549
1550 let temp_dir = TempDir::new().unwrap();
1551 let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
1552
1553 unsafe {
1556 std::env::set_var("XDG_RUNTIME_DIR", temp_dir.path());
1557 }
1558
1559 let path = resolve_default_socket("control.sock");
1560
1561 unsafe {
1563 match prev_xdg {
1564 Some(value) => std::env::set_var("XDG_RUNTIME_DIR", value),
1565 None => std::env::remove_var("XDG_RUNTIME_DIR"),
1566 }
1567 }
1568
1569 assert!(
1570 path.starts_with("/run/fips/")
1571 || path.starts_with(&format!("{}/fips/", temp_dir.path().display())),
1572 "expected /run/fips or XDG path, got: {path}"
1573 );
1574 }
1575
1576 #[cfg(unix)]
1577 #[test]
1578 fn test_resolve_default_socket_tmp_when_xdg_invalid() {
1579 let _guard = ENV_MUTEX.lock().unwrap();
1580
1581 let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
1582 let bogus = "/nonexistent-xdg-runtime-dir-for-fips-test-zzz";
1583
1584 unsafe {
1586 std::env::set_var("XDG_RUNTIME_DIR", bogus);
1587 }
1588
1589 let path = resolve_default_socket("gateway.sock");
1590
1591 unsafe {
1593 match prev_xdg {
1594 Some(value) => std::env::set_var("XDG_RUNTIME_DIR", value),
1595 None => std::env::remove_var("XDG_RUNTIME_DIR"),
1596 }
1597 }
1598
1599 assert!(
1600 path.starts_with("/run/fips/") || path == "/tmp/fips-gateway.sock",
1601 "expected /run/fips or /tmp fallback, got: {path}"
1602 );
1603 assert!(
1604 !path.starts_with(bogus),
1605 "stale XDG_RUNTIME_DIR leaked into resolver: {path}"
1606 );
1607 }
1608
1609 #[test]
1610 fn test_validate_loopback_bind_with_external_peer_rejected() {
1611 use crate::config::PeerAddress;
1612 let mut config = Config::default();
1613 config.transports.udp = TransportInstances::Single(UdpConfig {
1614 bind_addr: Some("127.0.0.1:2121".to_string()),
1615 ..Default::default()
1616 });
1617 config.peers = vec![PeerConfig {
1618 npub: "npub1peer".to_string(),
1619 addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1620 ..Default::default()
1621 }];
1622
1623 let err = config.validate().expect_err("validation should fail");
1624 let msg = err.to_string();
1625 assert!(msg.contains("loopback"), "got: {msg}");
1626 assert!(msg.contains("non-loopback"), "got: {msg}");
1627 }
1628
1629 #[test]
1630 fn test_validate_loopback_bind_with_loopback_peer_ok() {
1631 use crate::config::PeerAddress;
1632 let mut config = Config::default();
1633 config.transports.udp = TransportInstances::Single(UdpConfig {
1634 bind_addr: Some("127.0.0.1:2121".to_string()),
1635 ..Default::default()
1636 });
1637 config.peers = vec![PeerConfig {
1638 npub: "npub1peer".to_string(),
1639 addresses: vec![PeerAddress::new("udp", "127.0.0.2:2121")],
1640 ..Default::default()
1641 }];
1642
1643 config
1644 .validate()
1645 .expect("loopback peer with loopback bind should validate");
1646 }
1647
1648 #[test]
1649 fn test_validate_outbound_only_exempt_from_loopback_check() {
1650 use crate::config::PeerAddress;
1651 let mut config = Config::default();
1652 config.transports.udp = TransportInstances::Single(UdpConfig {
1655 bind_addr: Some("127.0.0.1:2121".to_string()),
1656 outbound_only: Some(true),
1657 ..Default::default()
1658 });
1659 config.peers = vec![PeerConfig {
1660 npub: "npub1peer".to_string(),
1661 addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1662 ..Default::default()
1663 }];
1664
1665 config
1666 .validate()
1667 .expect("outbound_only should be exempt from the loopback check");
1668 }
1669
1670 #[test]
1671 fn test_outbound_only_forces_ephemeral_bind() {
1672 let cfg = UdpConfig {
1673 bind_addr: Some("127.0.0.1:2121".to_string()),
1674 outbound_only: Some(true),
1675 ..Default::default()
1676 };
1677 assert_eq!(cfg.bind_addr(), "0.0.0.0:0");
1678 assert!(cfg.outbound_only());
1679 }
1680
1681 #[test]
1682 fn test_outbound_only_forces_advertise_off() {
1683 let cfg = UdpConfig {
1684 advertise_on_nostr: Some(true),
1685 outbound_only: Some(true),
1686 ..Default::default()
1687 };
1688 assert!(!cfg.advertise_on_nostr());
1689 }
1690
1691 #[test]
1692 fn test_udp_accept_connections_default_true() {
1693 let cfg = UdpConfig::default();
1694 assert!(cfg.accept_connections());
1695 }
1696}