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
33#[cfg(target_os = "linux")]
34pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
35pub use node::{
36 BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
37 NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
38 RetryConfig, RoutingConfig, RoutingMode, SessionConfig, SessionMmpConfig, TreeConfig,
39};
40pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
41#[cfg(feature = "sim-transport")]
42pub use transport::SimTransportConfig;
43pub use transport::{
44 BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
45 TransportsConfig, UdpConfig,
46};
47
48const CONFIG_FILENAME: &str = "fips.yaml";
50
51const KEY_FILENAME: &str = "fips.key";
53
54const PUB_FILENAME: &str = "fips.pub";
56
57fn is_loopback_addr_str(addr: &str) -> bool {
64 if let Some(rest) = addr.strip_prefix('[')
66 && let Some(end) = rest.find(']')
67 {
68 let host = &rest[..end];
69 return host == "::1";
70 }
71 let host = match addr.rsplit_once(':') {
73 Some((h, _)) => h,
74 None => addr,
75 };
76 host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" || host.starts_with("127.")
77}
78
79pub fn key_file_path(config_path: &Path) -> PathBuf {
81 config_path
82 .parent()
83 .unwrap_or(Path::new("."))
84 .join(KEY_FILENAME)
85}
86
87pub fn pub_file_path(config_path: &Path) -> PathBuf {
89 config_path
90 .parent()
91 .unwrap_or(Path::new("."))
92 .join(PUB_FILENAME)
93}
94
95pub fn default_control_path() -> PathBuf {
101 #[cfg(unix)]
102 {
103 if Path::new("/run/fips").exists() {
104 PathBuf::from("/run/fips/control.sock")
105 } else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
106 PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
107 } else {
108 PathBuf::from("/tmp/fips-control.sock")
109 }
110 }
111 #[cfg(windows)]
112 {
113 PathBuf::from("21210")
114 }
115}
116
117pub fn default_gateway_path() -> PathBuf {
122 #[cfg(unix)]
123 {
124 if Path::new("/run/fips").exists() {
125 PathBuf::from("/run/fips/gateway.sock")
126 } else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
127 PathBuf::from(format!("{runtime_dir}/fips/gateway.sock"))
128 } else {
129 PathBuf::from("/tmp/fips-gateway.sock")
130 }
131 }
132 #[cfg(windows)]
133 {
134 PathBuf::from("21211")
135 }
136}
137
138pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
140 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
141 path: path.to_path_buf(),
142 source: e,
143 })?;
144 let nsec = contents.trim().to_string();
145 if nsec.is_empty() {
146 return Err(ConfigError::EmptyKeyFile {
147 path: path.to_path_buf(),
148 });
149 }
150 Ok(nsec)
151}
152
153pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
158 use std::io::Write;
159
160 let mut opts = std::fs::OpenOptions::new();
161 opts.write(true).create(true).truncate(true);
162
163 #[cfg(unix)]
164 {
165 use std::os::unix::fs::OpenOptionsExt;
166 opts.mode(0o600);
167 }
168
169 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
170 path: path.to_path_buf(),
171 source: e,
172 })?;
173
174 file.write_all(nsec.as_bytes())
175 .map_err(|e| ConfigError::WriteKeyFile {
176 path: path.to_path_buf(),
177 source: e,
178 })?;
179 file.write_all(b"\n")
180 .map_err(|e| ConfigError::WriteKeyFile {
181 path: path.to_path_buf(),
182 source: e,
183 })?;
184 Ok(())
185}
186
187pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
192 use std::io::Write;
193
194 let mut opts = std::fs::OpenOptions::new();
195 opts.write(true).create(true).truncate(true);
196
197 #[cfg(unix)]
198 {
199 use std::os::unix::fs::OpenOptionsExt;
200 opts.mode(0o644);
201 }
202
203 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
204 path: path.to_path_buf(),
205 source: e,
206 })?;
207
208 file.write_all(npub.as_bytes())
209 .map_err(|e| ConfigError::WriteKeyFile {
210 path: path.to_path_buf(),
211 source: e,
212 })?;
213 file.write_all(b"\n")
214 .map_err(|e| ConfigError::WriteKeyFile {
215 path: path.to_path_buf(),
216 source: e,
217 })?;
218 Ok(())
219}
220
221pub fn resolve_identity(
238 config: &Config,
239 loaded_paths: &[PathBuf],
240) -> Result<ResolvedIdentity, ConfigError> {
241 use crate::encode_nsec;
242
243 if let Some(nsec) = &config.node.identity.nsec {
245 return Ok(ResolvedIdentity {
246 nsec: nsec.clone(),
247 source: IdentitySource::Config,
248 });
249 }
250
251 let config_ref = if let Some(path) = loaded_paths.last() {
253 path.clone()
254 } else {
255 Config::search_paths()
256 .first()
257 .cloned()
258 .unwrap_or_else(|| PathBuf::from("./fips.yaml"))
259 };
260 let key_path = key_file_path(&config_ref);
261 let pub_path = pub_file_path(&config_ref);
262
263 if config.node.identity.persistent {
264 if key_path.exists() {
266 let nsec = read_key_file(&key_path)?;
267 let identity = Identity::from_secret_str(&nsec)?;
268 let _ = write_pub_file(&pub_path, &identity.npub());
269 return Ok(ResolvedIdentity {
270 nsec,
271 source: IdentitySource::KeyFile(key_path),
272 });
273 }
274
275 let identity = Identity::generate();
277 let nsec = encode_nsec(&identity.keypair().secret_key());
278 let npub = identity.npub();
279
280 if let Some(parent) = key_path.parent() {
281 let _ = std::fs::create_dir_all(parent);
282 }
283
284 match write_key_file(&key_path, &nsec) {
285 Ok(()) => {
286 let _ = write_pub_file(&pub_path, &npub);
287 Ok(ResolvedIdentity {
288 nsec,
289 source: IdentitySource::Generated(key_path),
290 })
291 }
292 Err(_) => Ok(ResolvedIdentity {
293 nsec,
294 source: IdentitySource::Ephemeral,
295 }),
296 }
297 } else {
298 let identity = Identity::generate();
301 let nsec = encode_nsec(&identity.keypair().secret_key());
302 let npub = identity.npub();
303
304 if let Some(parent) = key_path.parent() {
305 let _ = std::fs::create_dir_all(parent);
306 }
307
308 let _ = write_key_file(&key_path, &nsec);
309 let _ = write_pub_file(&pub_path, &npub);
310
311 Ok(ResolvedIdentity {
312 nsec,
313 source: IdentitySource::Ephemeral,
314 })
315 }
316}
317
318pub struct ResolvedIdentity {
320 pub nsec: String,
322 pub source: IdentitySource,
324}
325
326pub enum IdentitySource {
328 Config,
330 KeyFile(PathBuf),
332 Generated(PathBuf),
334 Ephemeral,
336}
337
338#[derive(Debug, Error)]
340pub enum ConfigError {
341 #[error("failed to read config file {path}: {source}")]
342 ReadFile {
343 path: PathBuf,
344 source: std::io::Error,
345 },
346
347 #[error("failed to parse config file {path}: {source}")]
348 ParseYaml {
349 path: PathBuf,
350 source: serde_yaml::Error,
351 },
352
353 #[error("key file is empty: {path}")]
354 EmptyKeyFile { path: PathBuf },
355
356 #[error("failed to write key file {path}: {source}")]
357 WriteKeyFile {
358 path: PathBuf,
359 source: std::io::Error,
360 },
361
362 #[error("identity error: {0}")]
363 Identity(#[from] IdentityError),
364
365 #[error("invalid configuration: {0}")]
366 Validation(String),
367}
368
369#[derive(Debug, Clone, Default, Serialize, Deserialize)]
371pub struct IdentityConfig {
372 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub nsec: Option<String>,
376
377 #[serde(default)]
381 pub persistent: bool,
382}
383
384#[derive(Debug, Clone, Default, Serialize, Deserialize)]
386pub struct Config {
387 #[serde(default)]
389 pub node: NodeConfig,
390
391 #[serde(default)]
393 pub tun: TunConfig,
394
395 #[serde(default)]
397 pub dns: DnsConfig,
398
399 #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
401 pub transports: TransportsConfig,
402
403 #[serde(default, skip_serializing_if = "Vec::is_empty")]
405 pub peers: Vec<PeerConfig>,
406
407 #[cfg(target_os = "linux")]
409 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub gateway: Option<GatewayConfig>,
411}
412
413impl Config {
414 pub fn new() -> Self {
416 Self::default()
417 }
418
419 pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
429 let search_paths = Self::search_paths();
430 Self::load_from_paths(&search_paths)
431 }
432
433 pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
437 let mut config = Config::default();
438 let mut loaded_paths = Vec::new();
439
440 for path in paths {
441 if path.exists() {
442 let file_config = Self::load_file(path)?;
443 config.merge(file_config);
444 loaded_paths.push(path.clone());
445 }
446 }
447
448 Ok((config, loaded_paths))
449 }
450
451 pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
453 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
454 path: path.to_path_buf(),
455 source: e,
456 })?;
457
458 serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
459 path: path.to_path_buf(),
460 source: e,
461 })
462 }
463
464 pub fn search_paths() -> Vec<PathBuf> {
466 let mut paths = Vec::new();
467
468 paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
470
471 if let Some(config_dir) = dirs::config_dir() {
473 paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
474 }
475
476 if let Some(home_dir) = dirs::home_dir() {
478 paths.push(home_dir.join(".fips.yaml"));
479 }
480
481 paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
483
484 paths
485 }
486
487 pub fn merge(&mut self, other: Config) {
491 if other.node.identity.nsec.is_some() {
493 self.node.identity.nsec = other.node.identity.nsec;
494 }
495 if other.node.identity.persistent {
496 self.node.identity.persistent = true;
497 }
498 if other.node.leaf_only {
500 self.node.leaf_only = true;
501 }
502 if other.tun.enabled {
504 self.tun.enabled = true;
505 }
506 if other.tun.name.is_some() {
507 self.tun.name = other.tun.name;
508 }
509 if other.tun.mtu.is_some() {
510 self.tun.mtu = other.tun.mtu;
511 }
512 self.dns.enabled = other.dns.enabled;
514 if other.dns.bind_addr.is_some() {
515 self.dns.bind_addr = other.dns.bind_addr;
516 }
517 if other.dns.port.is_some() {
518 self.dns.port = other.dns.port;
519 }
520 if other.dns.ttl.is_some() {
521 self.dns.ttl = other.dns.ttl;
522 }
523 self.transports.merge(other.transports);
525 if !other.peers.is_empty() {
527 self.peers = other.peers;
528 }
529 #[cfg(target_os = "linux")]
531 if other.gateway.is_some() {
532 self.gateway = other.gateway;
533 }
534 }
535
536 pub fn create_identity(&self) -> Result<Identity, ConfigError> {
541 match &self.node.identity.nsec {
542 Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
543 None => Ok(Identity::generate()),
544 }
545 }
546
547 pub fn has_identity(&self) -> bool {
549 self.node.identity.nsec.is_some()
550 }
551
552 pub fn is_leaf_only(&self) -> bool {
554 self.node.leaf_only
555 }
556
557 pub fn peers(&self) -> &[PeerConfig] {
559 &self.peers
560 }
561
562 pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
564 self.peers.iter().filter(|p| p.is_auto_connect())
565 }
566
567 pub fn validate(&self) -> Result<(), ConfigError> {
569 let nostr = &self.node.discovery.nostr;
570
571 let any_transport_advertises_on_nostr = self
572 .transports
573 .udp
574 .iter()
575 .any(|(_, cfg)| cfg.advertise_on_nostr())
576 || self
577 .transports
578 .tcp
579 .iter()
580 .any(|(_, cfg)| cfg.advertise_on_nostr())
581 || self
582 .transports
583 .tor
584 .iter()
585 .any(|(_, cfg)| cfg.advertise_on_nostr());
586
587 if any_transport_advertises_on_nostr && !nostr.enabled {
588 return Err(ConfigError::Validation(
589 "at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
590 ));
591 }
592
593 for (i, peer) in self.peers.iter().enumerate() {
594 if peer.addresses.is_empty() && !nostr.enabled {
595 return Err(ConfigError::Validation(format!(
596 "peers[{i}] ({}): must specify at least one address, or enable `node.discovery.nostr` to resolve endpoints from Nostr adverts",
597 peer.npub
598 )));
599 }
600 }
601
602 let has_nat_udp_advert = self
603 .transports
604 .udp
605 .iter()
606 .any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
607
608 if nostr.enabled && has_nat_udp_advert {
609 if nostr.dm_relays.is_empty() {
610 return Err(ConfigError::Validation(
611 "NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
612 ));
613 }
614 if nostr.stun_servers.is_empty() {
615 return Err(ConfigError::Validation(
616 "NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
617 ));
618 }
619 }
620
621 for (name, cfg) in self.transports.udp.iter() {
628 if cfg.outbound_only() {
629 continue;
630 }
631 if is_loopback_addr_str(cfg.bind_addr()) {
632 let any_external_peer = self.peers.iter().any(|peer| {
633 peer.addresses
634 .iter()
635 .any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
636 });
637 if any_external_peer {
638 let label = name.unwrap_or("(unnamed)");
639 return Err(ConfigError::Validation(format!(
640 "transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
641 fips cannot reach external peers from a loopback-bound socket. \
642 Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
643 cfg.bind_addr()
644 )));
645 }
646 }
647 }
648
649 Ok(())
650 }
651
652 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
654 serde_yaml::to_string(self)
655 }
656}
657
658#[cfg(test)]
659mod tests {
660 use super::*;
661 use std::collections::HashMap;
662 use std::fs;
663 use tempfile::TempDir;
664
665 #[test]
666 fn test_empty_config() {
667 let config = Config::new();
668 assert!(config.node.identity.nsec.is_none());
669 assert!(!config.has_identity());
670 }
671
672 #[test]
673 fn test_parse_yaml_with_nsec() {
674 let yaml = r#"
675node:
676 identity:
677 nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
678"#;
679 let config: Config = serde_yaml::from_str(yaml).unwrap();
680 assert!(config.node.identity.nsec.is_some());
681 assert!(config.has_identity());
682 }
683
684 #[test]
685 fn test_parse_yaml_with_hex() {
686 let yaml = r#"
687node:
688 identity:
689 nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
690"#;
691 let config: Config = serde_yaml::from_str(yaml).unwrap();
692 assert!(config.node.identity.nsec.is_some());
693
694 let identity = config.create_identity().unwrap();
695 assert!(!identity.npub().is_empty());
696 }
697
698 #[test]
699 fn test_parse_yaml_empty() {
700 let yaml = "";
701 let config: Config = serde_yaml::from_str(yaml).unwrap();
702 assert!(config.node.identity.nsec.is_none());
703 }
704
705 #[test]
706 fn test_parse_yaml_partial() {
707 let yaml = r#"
708node:
709 identity: {}
710"#;
711 let config: Config = serde_yaml::from_str(yaml).unwrap();
712 assert!(config.node.identity.nsec.is_none());
713 }
714
715 #[test]
716 fn test_merge_configs() {
717 let mut base = Config::new();
718 base.node.identity.nsec = Some("base_nsec".to_string());
719
720 let mut override_config = Config::new();
721 override_config.node.identity.nsec = Some("override_nsec".to_string());
722
723 base.merge(override_config);
724 assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
725 }
726
727 #[test]
728 fn test_merge_preserves_base_when_override_empty() {
729 let mut base = Config::new();
730 base.node.identity.nsec = Some("base_nsec".to_string());
731
732 let override_config = Config::new();
733
734 base.merge(override_config);
735 assert_eq!(base.node.identity.nsec, Some("base_nsec".to_string()));
736 }
737
738 #[test]
739 fn test_create_identity_from_nsec() {
740 let mut config = Config::new();
741 config.node.identity.nsec =
742 Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
743
744 let identity = config.create_identity().unwrap();
745 assert!(!identity.npub().is_empty());
746 }
747
748 #[test]
749 fn test_create_identity_generates_new() {
750 let config = Config::new();
751 let identity = config.create_identity().unwrap();
752 assert!(!identity.npub().is_empty());
753 }
754
755 #[test]
756 fn test_load_from_file() {
757 let temp_dir = TempDir::new().unwrap();
758 let config_path = temp_dir.path().join("fips.yaml");
759
760 let yaml = r#"
761node:
762 identity:
763 nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
764"#;
765 fs::write(&config_path, yaml).unwrap();
766
767 let config = Config::load_file(&config_path).unwrap();
768 assert!(config.node.identity.nsec.is_some());
769 }
770
771 #[test]
772 fn test_load_from_paths_merges() {
773 let temp_dir = TempDir::new().unwrap();
774
775 let low_priority = temp_dir.path().join("low.yaml");
777 let high_priority = temp_dir.path().join("high.yaml");
778
779 fs::write(
780 &low_priority,
781 r#"
782node:
783 identity:
784 nsec: "low_priority_nsec"
785"#,
786 )
787 .unwrap();
788
789 fs::write(
790 &high_priority,
791 r#"
792node:
793 identity:
794 nsec: "high_priority_nsec"
795"#,
796 )
797 .unwrap();
798
799 let paths = vec![low_priority.clone(), high_priority.clone()];
800 let (config, loaded) = Config::load_from_paths(&paths).unwrap();
801
802 assert_eq!(loaded.len(), 2);
803 assert_eq!(
804 config.node.identity.nsec,
805 Some("high_priority_nsec".to_string())
806 );
807 }
808
809 #[test]
810 fn test_load_skips_missing_files() {
811 let temp_dir = TempDir::new().unwrap();
812 let existing = temp_dir.path().join("exists.yaml");
813 let missing = temp_dir.path().join("missing.yaml");
814
815 fs::write(
816 &existing,
817 r#"
818node:
819 identity:
820 nsec: "existing_nsec"
821"#,
822 )
823 .unwrap();
824
825 let paths = vec![missing, existing.clone()];
826 let (config, loaded) = Config::load_from_paths(&paths).unwrap();
827
828 assert_eq!(loaded.len(), 1);
829 assert_eq!(loaded[0], existing);
830 assert_eq!(config.node.identity.nsec, Some("existing_nsec".to_string()));
831 }
832
833 #[test]
834 fn test_search_paths_includes_expected() {
835 let paths = Config::search_paths();
836
837 assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
839
840 #[cfg(unix)]
842 assert!(
843 paths
844 .iter()
845 .any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
846 );
847 }
848
849 #[test]
850 fn test_to_yaml() {
851 let mut config = Config::new();
852 config.node.identity.nsec = Some("test_nsec".to_string());
853
854 let yaml = config.to_yaml().unwrap();
855 assert!(yaml.contains("node:"));
856 assert!(yaml.contains("identity:"));
857 assert!(yaml.contains("nsec:"));
858 assert!(yaml.contains("test_nsec"));
859 }
860
861 #[test]
862 fn test_key_file_write_read_roundtrip() {
863 let temp_dir = TempDir::new().unwrap();
864 let key_path = temp_dir.path().join("fips.key");
865
866 let identity = crate::Identity::generate();
867 let nsec = crate::encode_nsec(&identity.keypair().secret_key());
868
869 write_key_file(&key_path, &nsec).unwrap();
870
871 let loaded_nsec = read_key_file(&key_path).unwrap();
872 assert_eq!(loaded_nsec, nsec);
873
874 let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
876 assert_eq!(loaded_identity.npub(), identity.npub());
877 }
878
879 #[cfg(unix)]
880 #[test]
881 fn test_key_file_permissions() {
882 use std::os::unix::fs::MetadataExt;
883
884 let temp_dir = TempDir::new().unwrap();
885 let key_path = temp_dir.path().join("fips.key");
886
887 write_key_file(&key_path, "nsec1test").unwrap();
888
889 let metadata = fs::metadata(&key_path).unwrap();
890 assert_eq!(metadata.mode() & 0o777, 0o600);
891 }
892
893 #[cfg(unix)]
894 #[test]
895 fn test_pub_file_permissions() {
896 use std::os::unix::fs::MetadataExt;
897
898 let temp_dir = TempDir::new().unwrap();
899 let pub_path = temp_dir.path().join("fips.pub");
900
901 write_pub_file(&pub_path, "npub1test").unwrap();
902
903 let metadata = fs::metadata(&pub_path).unwrap();
904 assert_eq!(metadata.mode() & 0o777, 0o644);
905 }
906
907 #[test]
908 fn test_key_file_empty_error() {
909 let temp_dir = TempDir::new().unwrap();
910 let key_path = temp_dir.path().join("fips.key");
911
912 fs::write(&key_path, "").unwrap();
913
914 let result = read_key_file(&key_path);
915 assert!(result.is_err());
916 assert!(result.unwrap_err().to_string().contains("empty"));
917 }
918
919 #[test]
920 fn test_key_file_whitespace_trimmed() {
921 let temp_dir = TempDir::new().unwrap();
922 let key_path = temp_dir.path().join("fips.key");
923
924 fs::write(&key_path, " nsec1test \n").unwrap();
925
926 let nsec = read_key_file(&key_path).unwrap();
927 assert_eq!(nsec, "nsec1test");
928 }
929
930 #[test]
931 fn test_key_file_path_derivation() {
932 let config_path = PathBuf::from("/etc/fips/fips.yaml");
933 assert_eq!(
934 key_file_path(&config_path),
935 PathBuf::from("/etc/fips/fips.key")
936 );
937 assert_eq!(
938 pub_file_path(&config_path),
939 PathBuf::from("/etc/fips/fips.pub")
940 );
941 }
942
943 #[cfg(windows)]
944 #[test]
945 fn test_key_file_write_read_roundtrip_windows() {
946 let temp_dir = TempDir::new().unwrap();
947 let key_path = temp_dir.path().join("fips.key");
948
949 let identity = crate::Identity::generate();
950 let nsec = crate::encode_nsec(&identity.keypair().secret_key());
951
952 write_key_file(&key_path, &nsec).unwrap();
953
954 let loaded_nsec = read_key_file(&key_path).unwrap();
956 assert_eq!(loaded_nsec, nsec);
957
958 let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
960 assert_eq!(loaded_identity.npub(), identity.npub());
961 }
962
963 #[test]
964 fn test_resolve_identity_from_config() {
965 let mut config = Config::new();
966 config.node.identity.nsec =
967 Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
968
969 let resolved = resolve_identity(&config, &[]).unwrap();
970 assert!(matches!(resolved.source, IdentitySource::Config));
971 }
972
973 #[test]
974 fn test_resolve_identity_ephemeral_by_default() {
975 let temp_dir = TempDir::new().unwrap();
976 let config_path = temp_dir.path().join("fips.yaml");
977
978 fs::write(&config_path, "node:\n identity: {}\n").unwrap();
979
980 let config = Config::load_file(&config_path).unwrap();
981 assert!(!config.node.identity.persistent);
982
983 let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
984 assert!(matches!(resolved.source, IdentitySource::Ephemeral));
985
986 let key_path = temp_dir.path().join("fips.key");
988 let pub_path = temp_dir.path().join("fips.pub");
989 assert!(key_path.exists());
990 assert!(pub_path.exists());
991 }
992
993 #[test]
994 fn test_resolve_identity_ephemeral_changes_each_call() {
995 let temp_dir = TempDir::new().unwrap();
996 let config_path = temp_dir.path().join("fips.yaml");
997
998 fs::write(&config_path, "node:\n identity: {}\n").unwrap();
999
1000 let config = Config::load_file(&config_path).unwrap();
1001 let first = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1002 let second = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1003
1004 assert_ne!(first.nsec, second.nsec);
1006 }
1007
1008 #[test]
1009 fn test_resolve_identity_persistent_from_key_file() {
1010 let temp_dir = TempDir::new().unwrap();
1011 let config_path = temp_dir.path().join("fips.yaml");
1012 let key_path = temp_dir.path().join("fips.key");
1013
1014 fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
1015
1016 let identity = crate::Identity::generate();
1018 let nsec = crate::encode_nsec(&identity.keypair().secret_key());
1019 write_key_file(&key_path, &nsec).unwrap();
1020
1021 let config = Config::load_file(&config_path).unwrap();
1022 assert!(config.node.identity.persistent);
1023
1024 let resolved = resolve_identity(&config, &[config_path]).unwrap();
1025 assert!(matches!(resolved.source, IdentitySource::KeyFile(_)));
1026 assert_eq!(resolved.nsec, nsec);
1027 }
1028
1029 #[test]
1030 fn test_resolve_identity_persistent_generates_and_persists() {
1031 let temp_dir = TempDir::new().unwrap();
1032 let config_path = temp_dir.path().join("fips.yaml");
1033
1034 fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
1035
1036 let config = Config::load_file(&config_path).unwrap();
1037 let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1038
1039 assert!(matches!(resolved.source, IdentitySource::Generated(_)));
1040
1041 let key_path = temp_dir.path().join("fips.key");
1043 let pub_path = temp_dir.path().join("fips.pub");
1044 assert!(key_path.exists());
1045 assert!(pub_path.exists());
1046
1047 let resolved2 = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1049 assert!(matches!(resolved2.source, IdentitySource::KeyFile(_)));
1050 assert_eq!(resolved.nsec, resolved2.nsec);
1051 }
1052
1053 #[test]
1054 fn test_to_yaml_empty_nsec_omitted() {
1055 let config = Config::new();
1056 let yaml = config.to_yaml().unwrap();
1057
1058 assert!(!yaml.contains("nsec:"));
1060 }
1061
1062 #[test]
1063 fn test_parse_transport_single_instance() {
1064 let yaml = r#"
1065transports:
1066 udp:
1067 bind_addr: "0.0.0.0:2121"
1068 mtu: 1400
1069"#;
1070 let config: Config = serde_yaml::from_str(yaml).unwrap();
1071
1072 assert_eq!(config.transports.udp.len(), 1);
1073 let instances: Vec<_> = config.transports.udp.iter().collect();
1074 assert_eq!(instances.len(), 1);
1075 assert_eq!(instances[0].0, None); assert_eq!(instances[0].1.bind_addr(), "0.0.0.0:2121");
1077 assert_eq!(instances[0].1.mtu(), 1400);
1078 }
1079
1080 #[test]
1081 fn test_parse_transport_named_instances() {
1082 let yaml = r#"
1083transports:
1084 udp:
1085 main:
1086 bind_addr: "0.0.0.0:2121"
1087 backup:
1088 bind_addr: "192.168.1.100:2122"
1089 mtu: 1280
1090"#;
1091 let config: Config = serde_yaml::from_str(yaml).unwrap();
1092
1093 assert_eq!(config.transports.udp.len(), 2);
1094
1095 let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
1096
1097 assert!(instances.contains_key(&Some("main")));
1099 assert!(instances.contains_key(&Some("backup")));
1100 assert_eq!(instances[&Some("main")].bind_addr(), "0.0.0.0:2121");
1101 assert_eq!(instances[&Some("backup")].bind_addr(), "192.168.1.100:2122");
1102 assert_eq!(instances[&Some("backup")].mtu(), 1280);
1103 }
1104
1105 #[test]
1106 fn test_parse_transport_empty() {
1107 let yaml = r#"
1108transports: {}
1109"#;
1110 let config: Config = serde_yaml::from_str(yaml).unwrap();
1111 assert!(config.transports.udp.is_empty());
1112 assert!(config.transports.is_empty());
1113 }
1114
1115 #[test]
1116 fn test_transport_instances_iter() {
1117 let single = TransportInstances::Single(UdpConfig {
1119 bind_addr: Some("0.0.0.0:2121".to_string()),
1120 mtu: None,
1121 ..Default::default()
1122 });
1123 let items: Vec<_> = single.iter().collect();
1124 assert_eq!(items.len(), 1);
1125 assert_eq!(items[0].0, None);
1126
1127 let mut map = HashMap::new();
1129 map.insert("a".to_string(), UdpConfig::default());
1130 map.insert("b".to_string(), UdpConfig::default());
1131 let named = TransportInstances::Named(map);
1132 let items: Vec<_> = named.iter().collect();
1133 assert_eq!(items.len(), 2);
1134 assert!(items.iter().all(|(name, _)| name.is_some()));
1136 }
1137
1138 #[test]
1139 fn test_parse_peer_config() {
1140 let yaml = r#"
1141peers:
1142 - npub: "npub1abc123"
1143 alias: "gateway"
1144 addresses:
1145 - transport: udp
1146 addr: "192.168.1.1:2121"
1147 priority: 1
1148 - transport: tor
1149 addr: "xyz.onion:2121"
1150 priority: 2
1151 connect_policy: auto_connect
1152"#;
1153 let config: Config = serde_yaml::from_str(yaml).unwrap();
1154
1155 assert_eq!(config.peers.len(), 1);
1156 let peer = &config.peers[0];
1157 assert_eq!(peer.npub, "npub1abc123");
1158 assert_eq!(peer.alias, Some("gateway".to_string()));
1159 assert_eq!(peer.addresses.len(), 2);
1160 assert!(peer.is_auto_connect());
1161
1162 let sorted = peer.addresses_by_priority();
1164 assert_eq!(sorted[0].transport, "udp");
1165 assert_eq!(sorted[0].priority, 1);
1166 assert_eq!(sorted[1].transport, "tor");
1167 assert_eq!(sorted[1].priority, 2);
1168 }
1169
1170 #[test]
1171 fn test_parse_peer_minimal() {
1172 let yaml = r#"
1173peers:
1174 - npub: "npub1xyz"
1175 addresses:
1176 - transport: udp
1177 addr: "10.0.0.1:2121"
1178"#;
1179 let config: Config = serde_yaml::from_str(yaml).unwrap();
1180
1181 assert_eq!(config.peers.len(), 1);
1182 let peer = &config.peers[0];
1183 assert_eq!(peer.npub, "npub1xyz");
1184 assert!(peer.alias.is_none());
1185 assert!(peer.is_auto_connect());
1187 assert_eq!(peer.addresses[0].priority, 100);
1189 }
1190
1191 #[test]
1192 fn test_parse_multiple_peers() {
1193 let yaml = r#"
1194peers:
1195 - npub: "npub1peer1"
1196 addresses:
1197 - transport: udp
1198 addr: "10.0.0.1:2121"
1199 - npub: "npub1peer2"
1200 addresses:
1201 - transport: udp
1202 addr: "10.0.0.2:2121"
1203 connect_policy: on_demand
1204"#;
1205 let config: Config = serde_yaml::from_str(yaml).unwrap();
1206
1207 assert_eq!(config.peers.len(), 2);
1208 assert_eq!(config.auto_connect_peers().count(), 1);
1209 }
1210
1211 #[test]
1212 fn test_peer_config_builder() {
1213 let peer = PeerConfig::new("npub1test", "udp", "192.168.1.1:2121")
1214 .with_alias("test-peer")
1215 .with_address(PeerAddress::with_priority("tor", "xyz.onion:2121", 50));
1216
1217 assert_eq!(peer.npub, "npub1test");
1218 assert_eq!(peer.alias, Some("test-peer".to_string()));
1219 assert_eq!(peer.addresses.len(), 2);
1220 assert!(peer.is_auto_connect());
1221 }
1222
1223 #[test]
1224 fn test_parse_nostr_discovery_config() {
1225 let yaml = r#"
1226node:
1227 discovery:
1228 nostr:
1229 enabled: true
1230 advertise: false
1231 policy: configured_only
1232 open_discovery_max_pending: 12
1233 app: "fips.nat.test.v1"
1234 signal_ttl_secs: 45
1235 advert_relays:
1236 - "wss://relay-a.example"
1237 dm_relays:
1238 - "wss://relay-b.example"
1239 stun_servers:
1240 - "stun:stun.example.org:3478"
1241peers:
1242 - npub: "npub1peer"
1243 addresses:
1244 - transport: udp
1245 addr: "nat"
1246"#;
1247 let config: Config = serde_yaml::from_str(yaml).unwrap();
1248 assert!(config.node.discovery.nostr.enabled);
1249 assert!(!config.node.discovery.nostr.advertise);
1250 assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
1251 assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
1252 assert_eq!(
1253 config.node.discovery.nostr.policy,
1254 NostrDiscoveryPolicy::ConfiguredOnly
1255 );
1256 assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
1257 assert_eq!(
1258 config.node.discovery.nostr.advert_relays,
1259 vec!["wss://relay-a.example".to_string()]
1260 );
1261 assert_eq!(
1262 config.node.discovery.nostr.dm_relays,
1263 vec!["wss://relay-b.example".to_string()]
1264 );
1265 assert_eq!(
1266 config.node.discovery.nostr.stun_servers,
1267 vec!["stun:stun.example.org:3478".to_string()]
1268 );
1269 assert_eq!(
1270 config.peers[0].addresses[0].addr, "nat",
1271 "udp:nat address should parse without special-casing in YAML"
1272 );
1273 }
1274
1275 #[test]
1276 fn test_validate_transport_advert_requires_nostr_enabled() {
1277 let mut config = Config::default();
1278 config.transports.udp = TransportInstances::Single(UdpConfig {
1279 advertise_on_nostr: Some(true),
1280 ..Default::default()
1281 });
1282 config.node.discovery.nostr.enabled = false;
1283
1284 let err = config.validate().expect_err("validation should fail");
1285 assert!(err.to_string().contains("advertise_on_nostr"));
1286 }
1287
1288 #[test]
1289 fn test_validate_empty_peer_addresses_require_nostr_enabled() {
1290 let mut config = Config {
1291 peers: vec![PeerConfig {
1292 npub: "npub1peer".to_string(),
1293 ..Default::default()
1294 }],
1295 ..Default::default()
1296 };
1297 config.node.discovery.nostr.enabled = false;
1298
1299 let err = config.validate().expect_err("validation should fail");
1300 assert!(err.to_string().contains("node.discovery.nostr"));
1301 }
1302
1303 #[test]
1304 fn test_validate_peer_addresses_optional_with_nostr_enabled() {
1305 let mut config = Config {
1307 peers: vec![PeerConfig {
1308 npub: "npub1peer".to_string(),
1309 ..Default::default()
1310 }],
1311 ..Default::default()
1312 };
1313 let err = config.validate().expect_err("validation should fail");
1314 assert!(err.to_string().contains("at least one address"));
1315
1316 config.node.discovery.nostr.enabled = true;
1318 config
1319 .validate()
1320 .expect("Nostr discovery should allow empty addresses");
1321 }
1322
1323 #[test]
1324 fn test_validate_nat_udp_advert_requires_relays_and_stun() {
1325 let mut config = Config::default();
1326 config.node.discovery.nostr.enabled = true;
1327 config.node.discovery.nostr.dm_relays.clear();
1328 config.transports.udp = TransportInstances::Single(UdpConfig {
1329 advertise_on_nostr: Some(true),
1330 public: Some(false),
1331 ..Default::default()
1332 });
1333
1334 let err = config.validate().expect_err("validation should fail");
1335 assert!(err.to_string().contains("dm_relays"));
1336
1337 config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
1338 config.node.discovery.nostr.stun_servers.clear();
1339 let err = config.validate().expect_err("validation should fail");
1340 assert!(err.to_string().contains("stun_servers"));
1341 }
1342
1343 #[test]
1344 fn test_is_loopback_addr_str() {
1345 assert!(is_loopback_addr_str("127.0.0.1:2121"));
1346 assert!(is_loopback_addr_str("127.0.0.5:9999"));
1347 assert!(is_loopback_addr_str("[::1]:2121"));
1348 assert!(is_loopback_addr_str("::1:2121"));
1349 assert!(is_loopback_addr_str("localhost:80"));
1350 assert!(!is_loopback_addr_str("0.0.0.0:2121"));
1351 assert!(!is_loopback_addr_str("192.168.1.1:2121"));
1352 assert!(!is_loopback_addr_str("[fd00::1]:2121"));
1353 assert!(!is_loopback_addr_str("core-vm.tail65015.ts.net:2121"));
1354 assert!(!is_loopback_addr_str("example.com:443"));
1355 }
1356
1357 #[test]
1358 fn test_validate_loopback_bind_with_external_peer_rejected() {
1359 use crate::config::PeerAddress;
1360 let mut config = Config::default();
1361 config.transports.udp = TransportInstances::Single(UdpConfig {
1362 bind_addr: Some("127.0.0.1:2121".to_string()),
1363 ..Default::default()
1364 });
1365 config.peers = vec![PeerConfig {
1366 npub: "npub1peer".to_string(),
1367 addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1368 ..Default::default()
1369 }];
1370
1371 let err = config.validate().expect_err("validation should fail");
1372 let msg = err.to_string();
1373 assert!(msg.contains("loopback"), "got: {msg}");
1374 assert!(msg.contains("non-loopback"), "got: {msg}");
1375 }
1376
1377 #[test]
1378 fn test_validate_loopback_bind_with_loopback_peer_ok() {
1379 use crate::config::PeerAddress;
1380 let mut config = Config::default();
1381 config.transports.udp = TransportInstances::Single(UdpConfig {
1382 bind_addr: Some("127.0.0.1:2121".to_string()),
1383 ..Default::default()
1384 });
1385 config.peers = vec![PeerConfig {
1386 npub: "npub1peer".to_string(),
1387 addresses: vec![PeerAddress::new("udp", "127.0.0.2:2121")],
1388 ..Default::default()
1389 }];
1390
1391 config
1392 .validate()
1393 .expect("loopback peer with loopback bind should validate");
1394 }
1395
1396 #[test]
1397 fn test_validate_outbound_only_exempt_from_loopback_check() {
1398 use crate::config::PeerAddress;
1399 let mut config = Config::default();
1400 config.transports.udp = TransportInstances::Single(UdpConfig {
1403 bind_addr: Some("127.0.0.1:2121".to_string()),
1404 outbound_only: Some(true),
1405 ..Default::default()
1406 });
1407 config.peers = vec![PeerConfig {
1408 npub: "npub1peer".to_string(),
1409 addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1410 ..Default::default()
1411 }];
1412
1413 config
1414 .validate()
1415 .expect("outbound_only should be exempt from the loopback check");
1416 }
1417
1418 #[test]
1419 fn test_outbound_only_forces_ephemeral_bind() {
1420 let cfg = UdpConfig {
1421 bind_addr: Some("127.0.0.1:2121".to_string()),
1422 outbound_only: Some(true),
1423 ..Default::default()
1424 };
1425 assert_eq!(cfg.bind_addr(), "0.0.0.0:0");
1426 assert!(cfg.outbound_only());
1427 }
1428
1429 #[test]
1430 fn test_outbound_only_forces_advertise_off() {
1431 let cfg = UdpConfig {
1432 advertise_on_nostr: Some(true),
1433 outbound_only: Some(true),
1434 ..Default::default()
1435 };
1436 assert!(!cfg.advertise_on_nostr());
1437 }
1438
1439 #[test]
1440 fn test_udp_accept_connections_default_true() {
1441 let cfg = UdpConfig::default();
1442 assert!(cfg.accept_connections());
1443 }
1444}