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
95#[cfg(unix)]
103pub(crate) fn resolve_default_socket(filename: &str) -> String {
104 if Path::new("/run/fips").is_dir() {
105 return format!("/run/fips/{filename}");
106 }
107
108 if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR")
109 && Path::new(&xdg).is_dir()
110 {
111 return format!("{xdg}/fips/{filename}");
112 }
113
114 format!("/tmp/fips-{filename}")
115}
116
117pub fn default_control_path() -> PathBuf {
123 #[cfg(unix)]
124 {
125 PathBuf::from(resolve_default_socket("control.sock"))
126 }
127 #[cfg(windows)]
128 {
129 PathBuf::from("21210")
130 }
131}
132
133pub fn default_gateway_path() -> PathBuf {
138 #[cfg(unix)]
139 {
140 PathBuf::from(resolve_default_socket("gateway.sock"))
141 }
142 #[cfg(windows)]
143 {
144 PathBuf::from("21211")
145 }
146}
147
148pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
150 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
151 path: path.to_path_buf(),
152 source: e,
153 })?;
154 let nsec = contents.trim().to_string();
155 if nsec.is_empty() {
156 return Err(ConfigError::EmptyKeyFile {
157 path: path.to_path_buf(),
158 });
159 }
160 Ok(nsec)
161}
162
163pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
168 use std::io::Write;
169
170 let mut opts = std::fs::OpenOptions::new();
171 opts.write(true).create(true).truncate(true);
172
173 #[cfg(unix)]
174 {
175 use std::os::unix::fs::OpenOptionsExt;
176 opts.mode(0o600);
177 }
178
179 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
180 path: path.to_path_buf(),
181 source: e,
182 })?;
183
184 file.write_all(nsec.as_bytes())
185 .map_err(|e| ConfigError::WriteKeyFile {
186 path: path.to_path_buf(),
187 source: e,
188 })?;
189 file.write_all(b"\n")
190 .map_err(|e| ConfigError::WriteKeyFile {
191 path: path.to_path_buf(),
192 source: e,
193 })?;
194 Ok(())
195}
196
197pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
202 use std::io::Write;
203
204 let mut opts = std::fs::OpenOptions::new();
205 opts.write(true).create(true).truncate(true);
206
207 #[cfg(unix)]
208 {
209 use std::os::unix::fs::OpenOptionsExt;
210 opts.mode(0o644);
211 }
212
213 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
214 path: path.to_path_buf(),
215 source: e,
216 })?;
217
218 file.write_all(npub.as_bytes())
219 .map_err(|e| ConfigError::WriteKeyFile {
220 path: path.to_path_buf(),
221 source: e,
222 })?;
223 file.write_all(b"\n")
224 .map_err(|e| ConfigError::WriteKeyFile {
225 path: path.to_path_buf(),
226 source: e,
227 })?;
228 Ok(())
229}
230
231pub fn resolve_identity(
248 config: &Config,
249 loaded_paths: &[PathBuf],
250) -> Result<ResolvedIdentity, ConfigError> {
251 use crate::encode_nsec;
252
253 if let Some(nsec) = &config.node.identity.nsec {
255 return Ok(ResolvedIdentity {
256 nsec: nsec.clone(),
257 source: IdentitySource::Config,
258 });
259 }
260
261 let config_ref = if let Some(path) = loaded_paths.last() {
263 path.clone()
264 } else {
265 Config::search_paths()
266 .first()
267 .cloned()
268 .unwrap_or_else(|| PathBuf::from("./fips.yaml"))
269 };
270 let key_path = key_file_path(&config_ref);
271 let pub_path = pub_file_path(&config_ref);
272
273 if config.node.identity.persistent {
274 if key_path.exists() {
276 let nsec = read_key_file(&key_path)?;
277 let identity = Identity::from_secret_str(&nsec)?;
278 let _ = write_pub_file(&pub_path, &identity.npub());
279 return Ok(ResolvedIdentity {
280 nsec,
281 source: IdentitySource::KeyFile(key_path),
282 });
283 }
284
285 let identity = Identity::generate();
287 let nsec = encode_nsec(&identity.keypair().secret_key());
288 let npub = identity.npub();
289
290 if let Some(parent) = key_path.parent() {
291 let _ = std::fs::create_dir_all(parent);
292 }
293
294 match write_key_file(&key_path, &nsec) {
295 Ok(()) => {
296 let _ = write_pub_file(&pub_path, &npub);
297 Ok(ResolvedIdentity {
298 nsec,
299 source: IdentitySource::Generated(key_path),
300 })
301 }
302 Err(_) => Ok(ResolvedIdentity {
303 nsec,
304 source: IdentitySource::Ephemeral,
305 }),
306 }
307 } else {
308 let identity = Identity::generate();
311 let nsec = encode_nsec(&identity.keypair().secret_key());
312 let npub = identity.npub();
313
314 if let Some(parent) = key_path.parent() {
315 let _ = std::fs::create_dir_all(parent);
316 }
317
318 let _ = write_key_file(&key_path, &nsec);
319 let _ = write_pub_file(&pub_path, &npub);
320
321 Ok(ResolvedIdentity {
322 nsec,
323 source: IdentitySource::Ephemeral,
324 })
325 }
326}
327
328pub struct ResolvedIdentity {
330 pub nsec: String,
332 pub source: IdentitySource,
334}
335
336pub enum IdentitySource {
338 Config,
340 KeyFile(PathBuf),
342 Generated(PathBuf),
344 Ephemeral,
346}
347
348#[derive(Debug, Error)]
350pub enum ConfigError {
351 #[error("failed to read config file {path}: {source}")]
352 ReadFile {
353 path: PathBuf,
354 source: std::io::Error,
355 },
356
357 #[error("failed to parse config file {path}: {source}")]
358 ParseYaml {
359 path: PathBuf,
360 source: serde_yaml::Error,
361 },
362
363 #[error("key file is empty: {path}")]
364 EmptyKeyFile { path: PathBuf },
365
366 #[error("failed to write key file {path}: {source}")]
367 WriteKeyFile {
368 path: PathBuf,
369 source: std::io::Error,
370 },
371
372 #[error("identity error: {0}")]
373 Identity(#[from] IdentityError),
374
375 #[error("invalid configuration: {0}")]
376 Validation(String),
377}
378
379#[derive(Debug, Clone, Default, Serialize, Deserialize)]
381pub struct IdentityConfig {
382 #[serde(default, skip_serializing_if = "Option::is_none")]
385 pub nsec: Option<String>,
386
387 #[serde(default)]
391 pub persistent: bool,
392}
393
394#[derive(Debug, Clone, Default, Serialize, Deserialize)]
396pub struct Config {
397 #[serde(default)]
399 pub node: NodeConfig,
400
401 #[serde(default)]
403 pub tun: TunConfig,
404
405 #[serde(default)]
407 pub dns: DnsConfig,
408
409 #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
411 pub transports: TransportsConfig,
412
413 #[serde(default, skip_serializing_if = "Vec::is_empty")]
415 pub peers: Vec<PeerConfig>,
416
417 #[cfg(target_os = "linux")]
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub gateway: Option<GatewayConfig>,
421}
422
423impl Config {
424 pub fn new() -> Self {
426 Self::default()
427 }
428
429 pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
439 let search_paths = Self::search_paths();
440 Self::load_from_paths(&search_paths)
441 }
442
443 pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
447 let mut config = Config::default();
448 let mut loaded_paths = Vec::new();
449
450 for path in paths {
451 if path.exists() {
452 let file_config = Self::load_file(path)?;
453 config.merge(file_config);
454 loaded_paths.push(path.clone());
455 }
456 }
457
458 Ok((config, loaded_paths))
459 }
460
461 pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
463 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
464 path: path.to_path_buf(),
465 source: e,
466 })?;
467
468 serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
469 path: path.to_path_buf(),
470 source: e,
471 })
472 }
473
474 pub fn search_paths() -> Vec<PathBuf> {
476 let mut paths = Vec::new();
477
478 paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
480
481 if let Some(config_dir) = dirs::config_dir() {
483 paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
484 }
485
486 if let Some(home_dir) = dirs::home_dir() {
488 paths.push(home_dir.join(".fips.yaml"));
489 }
490
491 paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
493
494 paths
495 }
496
497 pub fn merge(&mut self, other: Config) {
501 if other.node.identity.nsec.is_some() {
503 self.node.identity.nsec = other.node.identity.nsec;
504 }
505 if other.node.identity.persistent {
506 self.node.identity.persistent = true;
507 }
508 if other.node.leaf_only {
510 self.node.leaf_only = true;
511 }
512 if other.tun.enabled {
514 self.tun.enabled = true;
515 }
516 if other.tun.name.is_some() {
517 self.tun.name = other.tun.name;
518 }
519 if other.tun.mtu.is_some() {
520 self.tun.mtu = other.tun.mtu;
521 }
522 self.dns.enabled = other.dns.enabled;
524 if other.dns.bind_addr.is_some() {
525 self.dns.bind_addr = other.dns.bind_addr;
526 }
527 if other.dns.port.is_some() {
528 self.dns.port = other.dns.port;
529 }
530 if other.dns.ttl.is_some() {
531 self.dns.ttl = other.dns.ttl;
532 }
533 self.transports.merge(other.transports);
535 if !other.peers.is_empty() {
537 self.peers = other.peers;
538 }
539 #[cfg(target_os = "linux")]
541 if other.gateway.is_some() {
542 self.gateway = other.gateway;
543 }
544 }
545
546 pub fn create_identity(&self) -> Result<Identity, ConfigError> {
551 match &self.node.identity.nsec {
552 Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
553 None => Ok(Identity::generate()),
554 }
555 }
556
557 pub fn has_identity(&self) -> bool {
559 self.node.identity.nsec.is_some()
560 }
561
562 pub fn is_leaf_only(&self) -> bool {
564 self.node.leaf_only
565 }
566
567 pub fn peers(&self) -> &[PeerConfig] {
569 &self.peers
570 }
571
572 pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
574 self.peers.iter().filter(|p| p.is_auto_connect())
575 }
576
577 pub fn validate(&self) -> Result<(), ConfigError> {
579 let nostr = &self.node.discovery.nostr;
580
581 let any_transport_advertises_on_nostr = self
582 .transports
583 .udp
584 .iter()
585 .any(|(_, cfg)| cfg.advertise_on_nostr())
586 || self
587 .transports
588 .tcp
589 .iter()
590 .any(|(_, cfg)| cfg.advertise_on_nostr())
591 || self
592 .transports
593 .tor
594 .iter()
595 .any(|(_, cfg)| cfg.advertise_on_nostr());
596
597 if any_transport_advertises_on_nostr && !nostr.enabled {
598 return Err(ConfigError::Validation(
599 "at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
600 ));
601 }
602
603 for (i, peer) in self.peers.iter().enumerate() {
604 if peer.addresses.is_empty() && !nostr.enabled {
605 return Err(ConfigError::Validation(format!(
606 "peers[{i}] ({}): must specify at least one address, or enable `node.discovery.nostr` to resolve endpoints from Nostr adverts",
607 peer.npub
608 )));
609 }
610 }
611
612 let has_nat_udp_advert = self
613 .transports
614 .udp
615 .iter()
616 .any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
617
618 if nostr.enabled && has_nat_udp_advert {
619 if nostr.dm_relays.is_empty() {
620 return Err(ConfigError::Validation(
621 "NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
622 ));
623 }
624 if nostr.stun_servers.is_empty() {
625 return Err(ConfigError::Validation(
626 "NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
627 ));
628 }
629 }
630
631 for (name, cfg) in self.transports.udp.iter() {
638 if cfg.outbound_only() {
639 continue;
640 }
641 if is_loopback_addr_str(cfg.bind_addr()) {
642 let any_external_peer = self.peers.iter().any(|peer| {
643 peer.addresses
644 .iter()
645 .any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
646 });
647 if any_external_peer {
648 let label = name.unwrap_or("(unnamed)");
649 return Err(ConfigError::Validation(format!(
650 "transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
651 fips cannot reach external peers from a loopback-bound socket. \
652 Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
653 cfg.bind_addr()
654 )));
655 }
656 }
657 }
658
659 Ok(())
660 }
661
662 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
664 serde_yaml::to_string(self)
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use super::*;
671 use std::collections::HashMap;
672 use std::fs;
673 use tempfile::TempDir;
674
675 #[cfg(unix)]
676 static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
677
678 #[test]
679 fn test_empty_config() {
680 let config = Config::new();
681 assert!(config.node.identity.nsec.is_none());
682 assert!(!config.has_identity());
683 }
684
685 #[test]
686 fn test_parse_yaml_with_nsec() {
687 let yaml = r#"
688node:
689 identity:
690 nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
691"#;
692 let config: Config = serde_yaml::from_str(yaml).unwrap();
693 assert!(config.node.identity.nsec.is_some());
694 assert!(config.has_identity());
695 }
696
697 #[test]
698 fn test_parse_yaml_with_hex() {
699 let yaml = r#"
700node:
701 identity:
702 nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
703"#;
704 let config: Config = serde_yaml::from_str(yaml).unwrap();
705 assert!(config.node.identity.nsec.is_some());
706
707 let identity = config.create_identity().unwrap();
708 assert!(!identity.npub().is_empty());
709 }
710
711 #[test]
712 fn test_parse_yaml_empty() {
713 let yaml = "";
714 let config: Config = serde_yaml::from_str(yaml).unwrap();
715 assert!(config.node.identity.nsec.is_none());
716 }
717
718 #[test]
719 fn test_parse_yaml_partial() {
720 let yaml = r#"
721node:
722 identity: {}
723"#;
724 let config: Config = serde_yaml::from_str(yaml).unwrap();
725 assert!(config.node.identity.nsec.is_none());
726 }
727
728 #[test]
729 fn test_merge_configs() {
730 let mut base = Config::new();
731 base.node.identity.nsec = Some("base_nsec".to_string());
732
733 let mut override_config = Config::new();
734 override_config.node.identity.nsec = Some("override_nsec".to_string());
735
736 base.merge(override_config);
737 assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
738 }
739
740 #[test]
741 fn test_merge_preserves_base_when_override_empty() {
742 let mut base = Config::new();
743 base.node.identity.nsec = Some("base_nsec".to_string());
744
745 let override_config = Config::new();
746
747 base.merge(override_config);
748 assert_eq!(base.node.identity.nsec, Some("base_nsec".to_string()));
749 }
750
751 #[test]
752 fn test_create_identity_from_nsec() {
753 let mut config = Config::new();
754 config.node.identity.nsec =
755 Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
756
757 let identity = config.create_identity().unwrap();
758 assert!(!identity.npub().is_empty());
759 }
760
761 #[test]
762 fn test_create_identity_generates_new() {
763 let config = Config::new();
764 let identity = config.create_identity().unwrap();
765 assert!(!identity.npub().is_empty());
766 }
767
768 #[test]
769 fn test_load_from_file() {
770 let temp_dir = TempDir::new().unwrap();
771 let config_path = temp_dir.path().join("fips.yaml");
772
773 let yaml = r#"
774node:
775 identity:
776 nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
777"#;
778 fs::write(&config_path, yaml).unwrap();
779
780 let config = Config::load_file(&config_path).unwrap();
781 assert!(config.node.identity.nsec.is_some());
782 }
783
784 #[test]
785 fn test_load_from_paths_merges() {
786 let temp_dir = TempDir::new().unwrap();
787
788 let low_priority = temp_dir.path().join("low.yaml");
790 let high_priority = temp_dir.path().join("high.yaml");
791
792 fs::write(
793 &low_priority,
794 r#"
795node:
796 identity:
797 nsec: "low_priority_nsec"
798"#,
799 )
800 .unwrap();
801
802 fs::write(
803 &high_priority,
804 r#"
805node:
806 identity:
807 nsec: "high_priority_nsec"
808"#,
809 )
810 .unwrap();
811
812 let paths = vec![low_priority.clone(), high_priority.clone()];
813 let (config, loaded) = Config::load_from_paths(&paths).unwrap();
814
815 assert_eq!(loaded.len(), 2);
816 assert_eq!(
817 config.node.identity.nsec,
818 Some("high_priority_nsec".to_string())
819 );
820 }
821
822 #[test]
823 fn test_load_skips_missing_files() {
824 let temp_dir = TempDir::new().unwrap();
825 let existing = temp_dir.path().join("exists.yaml");
826 let missing = temp_dir.path().join("missing.yaml");
827
828 fs::write(
829 &existing,
830 r#"
831node:
832 identity:
833 nsec: "existing_nsec"
834"#,
835 )
836 .unwrap();
837
838 let paths = vec![missing, existing.clone()];
839 let (config, loaded) = Config::load_from_paths(&paths).unwrap();
840
841 assert_eq!(loaded.len(), 1);
842 assert_eq!(loaded[0], existing);
843 assert_eq!(config.node.identity.nsec, Some("existing_nsec".to_string()));
844 }
845
846 #[test]
847 fn test_search_paths_includes_expected() {
848 let paths = Config::search_paths();
849
850 assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
852
853 #[cfg(unix)]
855 assert!(
856 paths
857 .iter()
858 .any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
859 );
860 }
861
862 #[test]
863 fn test_to_yaml() {
864 let mut config = Config::new();
865 config.node.identity.nsec = Some("test_nsec".to_string());
866
867 let yaml = config.to_yaml().unwrap();
868 assert!(yaml.contains("node:"));
869 assert!(yaml.contains("identity:"));
870 assert!(yaml.contains("nsec:"));
871 assert!(yaml.contains("test_nsec"));
872 }
873
874 #[test]
875 fn test_key_file_write_read_roundtrip() {
876 let temp_dir = TempDir::new().unwrap();
877 let key_path = temp_dir.path().join("fips.key");
878
879 let identity = crate::Identity::generate();
880 let nsec = crate::encode_nsec(&identity.keypair().secret_key());
881
882 write_key_file(&key_path, &nsec).unwrap();
883
884 let loaded_nsec = read_key_file(&key_path).unwrap();
885 assert_eq!(loaded_nsec, nsec);
886
887 let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
889 assert_eq!(loaded_identity.npub(), identity.npub());
890 }
891
892 #[cfg(unix)]
893 #[test]
894 fn test_key_file_permissions() {
895 use std::os::unix::fs::MetadataExt;
896
897 let temp_dir = TempDir::new().unwrap();
898 let key_path = temp_dir.path().join("fips.key");
899
900 write_key_file(&key_path, "nsec1test").unwrap();
901
902 let metadata = fs::metadata(&key_path).unwrap();
903 assert_eq!(metadata.mode() & 0o777, 0o600);
904 }
905
906 #[cfg(unix)]
907 #[test]
908 fn test_pub_file_permissions() {
909 use std::os::unix::fs::MetadataExt;
910
911 let temp_dir = TempDir::new().unwrap();
912 let pub_path = temp_dir.path().join("fips.pub");
913
914 write_pub_file(&pub_path, "npub1test").unwrap();
915
916 let metadata = fs::metadata(&pub_path).unwrap();
917 assert_eq!(metadata.mode() & 0o777, 0o644);
918 }
919
920 #[test]
921 fn test_key_file_empty_error() {
922 let temp_dir = TempDir::new().unwrap();
923 let key_path = temp_dir.path().join("fips.key");
924
925 fs::write(&key_path, "").unwrap();
926
927 let result = read_key_file(&key_path);
928 assert!(result.is_err());
929 assert!(result.unwrap_err().to_string().contains("empty"));
930 }
931
932 #[test]
933 fn test_key_file_whitespace_trimmed() {
934 let temp_dir = TempDir::new().unwrap();
935 let key_path = temp_dir.path().join("fips.key");
936
937 fs::write(&key_path, " nsec1test \n").unwrap();
938
939 let nsec = read_key_file(&key_path).unwrap();
940 assert_eq!(nsec, "nsec1test");
941 }
942
943 #[test]
944 fn test_key_file_path_derivation() {
945 let config_path = PathBuf::from("/etc/fips/fips.yaml");
946 assert_eq!(
947 key_file_path(&config_path),
948 PathBuf::from("/etc/fips/fips.key")
949 );
950 assert_eq!(
951 pub_file_path(&config_path),
952 PathBuf::from("/etc/fips/fips.pub")
953 );
954 }
955
956 #[cfg(windows)]
957 #[test]
958 fn test_key_file_write_read_roundtrip_windows() {
959 let temp_dir = TempDir::new().unwrap();
960 let key_path = temp_dir.path().join("fips.key");
961
962 let identity = crate::Identity::generate();
963 let nsec = crate::encode_nsec(&identity.keypair().secret_key());
964
965 write_key_file(&key_path, &nsec).unwrap();
966
967 let loaded_nsec = read_key_file(&key_path).unwrap();
969 assert_eq!(loaded_nsec, nsec);
970
971 let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
973 assert_eq!(loaded_identity.npub(), identity.npub());
974 }
975
976 #[test]
977 fn test_resolve_identity_from_config() {
978 let mut config = Config::new();
979 config.node.identity.nsec =
980 Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
981
982 let resolved = resolve_identity(&config, &[]).unwrap();
983 assert!(matches!(resolved.source, IdentitySource::Config));
984 }
985
986 #[test]
987 fn test_resolve_identity_ephemeral_by_default() {
988 let temp_dir = TempDir::new().unwrap();
989 let config_path = temp_dir.path().join("fips.yaml");
990
991 fs::write(&config_path, "node:\n identity: {}\n").unwrap();
992
993 let config = Config::load_file(&config_path).unwrap();
994 assert!(!config.node.identity.persistent);
995
996 let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
997 assert!(matches!(resolved.source, IdentitySource::Ephemeral));
998
999 let key_path = temp_dir.path().join("fips.key");
1001 let pub_path = temp_dir.path().join("fips.pub");
1002 assert!(key_path.exists());
1003 assert!(pub_path.exists());
1004 }
1005
1006 #[test]
1007 fn test_resolve_identity_ephemeral_changes_each_call() {
1008 let temp_dir = TempDir::new().unwrap();
1009 let config_path = temp_dir.path().join("fips.yaml");
1010
1011 fs::write(&config_path, "node:\n identity: {}\n").unwrap();
1012
1013 let config = Config::load_file(&config_path).unwrap();
1014 let first = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1015 let second = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1016
1017 assert_ne!(first.nsec, second.nsec);
1019 }
1020
1021 #[test]
1022 fn test_resolve_identity_persistent_from_key_file() {
1023 let temp_dir = TempDir::new().unwrap();
1024 let config_path = temp_dir.path().join("fips.yaml");
1025 let key_path = temp_dir.path().join("fips.key");
1026
1027 fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
1028
1029 let identity = crate::Identity::generate();
1031 let nsec = crate::encode_nsec(&identity.keypair().secret_key());
1032 write_key_file(&key_path, &nsec).unwrap();
1033
1034 let config = Config::load_file(&config_path).unwrap();
1035 assert!(config.node.identity.persistent);
1036
1037 let resolved = resolve_identity(&config, &[config_path]).unwrap();
1038 assert!(matches!(resolved.source, IdentitySource::KeyFile(_)));
1039 assert_eq!(resolved.nsec, nsec);
1040 }
1041
1042 #[test]
1043 fn test_resolve_identity_persistent_generates_and_persists() {
1044 let temp_dir = TempDir::new().unwrap();
1045 let config_path = temp_dir.path().join("fips.yaml");
1046
1047 fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
1048
1049 let config = Config::load_file(&config_path).unwrap();
1050 let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1051
1052 assert!(matches!(resolved.source, IdentitySource::Generated(_)));
1053
1054 let key_path = temp_dir.path().join("fips.key");
1056 let pub_path = temp_dir.path().join("fips.pub");
1057 assert!(key_path.exists());
1058 assert!(pub_path.exists());
1059
1060 let resolved2 = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1062 assert!(matches!(resolved2.source, IdentitySource::KeyFile(_)));
1063 assert_eq!(resolved.nsec, resolved2.nsec);
1064 }
1065
1066 #[test]
1067 fn test_to_yaml_empty_nsec_omitted() {
1068 let config = Config::new();
1069 let yaml = config.to_yaml().unwrap();
1070
1071 assert!(!yaml.contains("nsec:"));
1073 }
1074
1075 #[test]
1076 fn test_parse_transport_single_instance() {
1077 let yaml = r#"
1078transports:
1079 udp:
1080 bind_addr: "0.0.0.0:2121"
1081 mtu: 1400
1082"#;
1083 let config: Config = serde_yaml::from_str(yaml).unwrap();
1084
1085 assert_eq!(config.transports.udp.len(), 1);
1086 let instances: Vec<_> = config.transports.udp.iter().collect();
1087 assert_eq!(instances.len(), 1);
1088 assert_eq!(instances[0].0, None); assert_eq!(instances[0].1.bind_addr(), "0.0.0.0:2121");
1090 assert_eq!(instances[0].1.mtu(), 1400);
1091 }
1092
1093 #[test]
1094 fn test_parse_transport_named_instances() {
1095 let yaml = r#"
1096transports:
1097 udp:
1098 main:
1099 bind_addr: "0.0.0.0:2121"
1100 backup:
1101 bind_addr: "192.168.1.100:2122"
1102 mtu: 1280
1103"#;
1104 let config: Config = serde_yaml::from_str(yaml).unwrap();
1105
1106 assert_eq!(config.transports.udp.len(), 2);
1107
1108 let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
1109
1110 assert!(instances.contains_key(&Some("main")));
1112 assert!(instances.contains_key(&Some("backup")));
1113 assert_eq!(instances[&Some("main")].bind_addr(), "0.0.0.0:2121");
1114 assert_eq!(instances[&Some("backup")].bind_addr(), "192.168.1.100:2122");
1115 assert_eq!(instances[&Some("backup")].mtu(), 1280);
1116 }
1117
1118 #[test]
1119 fn test_parse_transport_empty() {
1120 let yaml = r#"
1121transports: {}
1122"#;
1123 let config: Config = serde_yaml::from_str(yaml).unwrap();
1124 assert!(config.transports.udp.is_empty());
1125 assert!(config.transports.is_empty());
1126 }
1127
1128 #[test]
1129 fn test_transport_instances_iter() {
1130 let single = TransportInstances::Single(UdpConfig {
1132 bind_addr: Some("0.0.0.0:2121".to_string()),
1133 mtu: None,
1134 ..Default::default()
1135 });
1136 let items: Vec<_> = single.iter().collect();
1137 assert_eq!(items.len(), 1);
1138 assert_eq!(items[0].0, None);
1139
1140 let mut map = HashMap::new();
1142 map.insert("a".to_string(), UdpConfig::default());
1143 map.insert("b".to_string(), UdpConfig::default());
1144 let named = TransportInstances::Named(map);
1145 let items: Vec<_> = named.iter().collect();
1146 assert_eq!(items.len(), 2);
1147 assert!(items.iter().all(|(name, _)| name.is_some()));
1149 }
1150
1151 #[test]
1152 fn test_parse_peer_config() {
1153 let yaml = r#"
1154peers:
1155 - npub: "npub1abc123"
1156 alias: "gateway"
1157 addresses:
1158 - transport: udp
1159 addr: "192.168.1.1:2121"
1160 priority: 1
1161 - transport: tor
1162 addr: "xyz.onion:2121"
1163 priority: 2
1164 connect_policy: auto_connect
1165"#;
1166 let config: Config = serde_yaml::from_str(yaml).unwrap();
1167
1168 assert_eq!(config.peers.len(), 1);
1169 let peer = &config.peers[0];
1170 assert_eq!(peer.npub, "npub1abc123");
1171 assert_eq!(peer.alias, Some("gateway".to_string()));
1172 assert_eq!(peer.addresses.len(), 2);
1173 assert!(peer.is_auto_connect());
1174
1175 let sorted = peer.addresses_by_priority();
1177 assert_eq!(sorted[0].transport, "udp");
1178 assert_eq!(sorted[0].priority, 1);
1179 assert_eq!(sorted[1].transport, "tor");
1180 assert_eq!(sorted[1].priority, 2);
1181 }
1182
1183 #[test]
1184 fn test_parse_peer_minimal() {
1185 let yaml = r#"
1186peers:
1187 - npub: "npub1xyz"
1188 addresses:
1189 - transport: udp
1190 addr: "10.0.0.1:2121"
1191"#;
1192 let config: Config = serde_yaml::from_str(yaml).unwrap();
1193
1194 assert_eq!(config.peers.len(), 1);
1195 let peer = &config.peers[0];
1196 assert_eq!(peer.npub, "npub1xyz");
1197 assert!(peer.alias.is_none());
1198 assert!(peer.is_auto_connect());
1200 assert_eq!(peer.addresses[0].priority, 100);
1202 }
1203
1204 #[test]
1205 fn test_parse_multiple_peers() {
1206 let yaml = r#"
1207peers:
1208 - npub: "npub1peer1"
1209 addresses:
1210 - transport: udp
1211 addr: "10.0.0.1:2121"
1212 - npub: "npub1peer2"
1213 addresses:
1214 - transport: udp
1215 addr: "10.0.0.2:2121"
1216 connect_policy: on_demand
1217"#;
1218 let config: Config = serde_yaml::from_str(yaml).unwrap();
1219
1220 assert_eq!(config.peers.len(), 2);
1221 assert_eq!(config.auto_connect_peers().count(), 1);
1222 }
1223
1224 #[test]
1225 fn test_peer_config_builder() {
1226 let peer = PeerConfig::new("npub1test", "udp", "192.168.1.1:2121")
1227 .with_alias("test-peer")
1228 .with_address(PeerAddress::with_priority("tor", "xyz.onion:2121", 50));
1229
1230 assert_eq!(peer.npub, "npub1test");
1231 assert_eq!(peer.alias, Some("test-peer".to_string()));
1232 assert_eq!(peer.addresses.len(), 2);
1233 assert!(peer.is_auto_connect());
1234 }
1235
1236 #[test]
1237 fn test_parse_nostr_discovery_config() {
1238 let yaml = r#"
1239node:
1240 discovery:
1241 nostr:
1242 enabled: true
1243 advertise: false
1244 policy: configured_only
1245 open_discovery_max_pending: 12
1246 app: "fips.nat.test.v1"
1247 signal_ttl_secs: 45
1248 advert_relays:
1249 - "wss://relay-a.example"
1250 dm_relays:
1251 - "wss://relay-b.example"
1252 stun_servers:
1253 - "stun:stun.example.org:3478"
1254peers:
1255 - npub: "npub1peer"
1256 addresses:
1257 - transport: udp
1258 addr: "nat"
1259"#;
1260 let config: Config = serde_yaml::from_str(yaml).unwrap();
1261 assert!(config.node.discovery.nostr.enabled);
1262 assert!(!config.node.discovery.nostr.advertise);
1263 assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
1264 assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
1265 assert_eq!(
1266 config.node.discovery.nostr.policy,
1267 NostrDiscoveryPolicy::ConfiguredOnly
1268 );
1269 assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
1270 assert_eq!(
1271 config.node.discovery.nostr.advert_relays,
1272 vec!["wss://relay-a.example".to_string()]
1273 );
1274 assert_eq!(
1275 config.node.discovery.nostr.dm_relays,
1276 vec!["wss://relay-b.example".to_string()]
1277 );
1278 assert_eq!(
1279 config.node.discovery.nostr.stun_servers,
1280 vec!["stun:stun.example.org:3478".to_string()]
1281 );
1282 assert_eq!(
1283 config.peers[0].addresses[0].addr, "nat",
1284 "udp:nat address should parse without special-casing in YAML"
1285 );
1286 }
1287
1288 #[test]
1289 fn test_validate_transport_advert_requires_nostr_enabled() {
1290 let mut config = Config::default();
1291 config.transports.udp = TransportInstances::Single(UdpConfig {
1292 advertise_on_nostr: Some(true),
1293 ..Default::default()
1294 });
1295 config.node.discovery.nostr.enabled = false;
1296
1297 let err = config.validate().expect_err("validation should fail");
1298 assert!(err.to_string().contains("advertise_on_nostr"));
1299 }
1300
1301 #[test]
1302 fn test_validate_empty_peer_addresses_require_nostr_enabled() {
1303 let mut config = Config {
1304 peers: vec![PeerConfig {
1305 npub: "npub1peer".to_string(),
1306 ..Default::default()
1307 }],
1308 ..Default::default()
1309 };
1310 config.node.discovery.nostr.enabled = false;
1311
1312 let err = config.validate().expect_err("validation should fail");
1313 assert!(err.to_string().contains("node.discovery.nostr"));
1314 }
1315
1316 #[test]
1317 fn test_validate_peer_addresses_optional_with_nostr_enabled() {
1318 let mut config = Config {
1320 peers: vec![PeerConfig {
1321 npub: "npub1peer".to_string(),
1322 ..Default::default()
1323 }],
1324 ..Default::default()
1325 };
1326 let err = config.validate().expect_err("validation should fail");
1327 assert!(err.to_string().contains("at least one address"));
1328
1329 config.node.discovery.nostr.enabled = true;
1331 config
1332 .validate()
1333 .expect("Nostr discovery should allow empty addresses");
1334 }
1335
1336 #[test]
1337 fn test_validate_nat_udp_advert_requires_relays_and_stun() {
1338 let mut config = Config::default();
1339 config.node.discovery.nostr.enabled = true;
1340 config.node.discovery.nostr.dm_relays.clear();
1341 config.transports.udp = TransportInstances::Single(UdpConfig {
1342 advertise_on_nostr: Some(true),
1343 public: Some(false),
1344 ..Default::default()
1345 });
1346
1347 let err = config.validate().expect_err("validation should fail");
1348 assert!(err.to_string().contains("dm_relays"));
1349
1350 config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
1351 config.node.discovery.nostr.stun_servers.clear();
1352 let err = config.validate().expect_err("validation should fail");
1353 assert!(err.to_string().contains("stun_servers"));
1354 }
1355
1356 #[test]
1357 fn test_is_loopback_addr_str() {
1358 assert!(is_loopback_addr_str("127.0.0.1:2121"));
1359 assert!(is_loopback_addr_str("127.0.0.5:9999"));
1360 assert!(is_loopback_addr_str("[::1]:2121"));
1361 assert!(is_loopback_addr_str("::1:2121"));
1362 assert!(is_loopback_addr_str("localhost:80"));
1363 assert!(!is_loopback_addr_str("0.0.0.0:2121"));
1364 assert!(!is_loopback_addr_str("192.168.1.1:2121"));
1365 assert!(!is_loopback_addr_str("[fd00::1]:2121"));
1366 assert!(!is_loopback_addr_str("core-vm.tail65015.ts.net:2121"));
1367 assert!(!is_loopback_addr_str("example.com:443"));
1368 }
1369
1370 #[cfg(unix)]
1371 #[test]
1372 fn test_resolve_default_socket_call_sites_agree() {
1373 let _guard = ENV_MUTEX.lock().unwrap();
1374
1375 let control_client = default_control_path().to_string_lossy().into_owned();
1376 let gateway_client = default_gateway_path().to_string_lossy().into_owned();
1377 let control_daemon = ControlConfig::default().socket_path;
1378
1379 assert_eq!(control_daemon, control_client);
1380
1381 let control_dir = Path::new(&control_client)
1382 .parent()
1383 .map(|p| p.to_string_lossy().into_owned())
1384 .unwrap_or_default();
1385 let gateway_dir = Path::new(&gateway_client)
1386 .parent()
1387 .map(|p| p.to_string_lossy().into_owned())
1388 .unwrap_or_default();
1389 assert_eq!(control_dir, gateway_dir);
1390 }
1391
1392 #[cfg(unix)]
1393 #[test]
1394 fn test_resolve_default_socket_xdg_when_no_run_fips() {
1395 let _guard = ENV_MUTEX.lock().unwrap();
1396
1397 let temp_dir = TempDir::new().unwrap();
1398 let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
1399
1400 unsafe {
1403 std::env::set_var("XDG_RUNTIME_DIR", temp_dir.path());
1404 }
1405
1406 let path = resolve_default_socket("control.sock");
1407
1408 unsafe {
1410 match prev_xdg {
1411 Some(value) => std::env::set_var("XDG_RUNTIME_DIR", value),
1412 None => std::env::remove_var("XDG_RUNTIME_DIR"),
1413 }
1414 }
1415
1416 assert!(
1417 path.starts_with("/run/fips/")
1418 || path.starts_with(&format!("{}/fips/", temp_dir.path().display())),
1419 "expected /run/fips or XDG path, got: {path}"
1420 );
1421 }
1422
1423 #[cfg(unix)]
1424 #[test]
1425 fn test_resolve_default_socket_tmp_when_xdg_invalid() {
1426 let _guard = ENV_MUTEX.lock().unwrap();
1427
1428 let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
1429 let bogus = "/nonexistent-xdg-runtime-dir-for-fips-test-zzz";
1430
1431 unsafe {
1433 std::env::set_var("XDG_RUNTIME_DIR", bogus);
1434 }
1435
1436 let path = resolve_default_socket("gateway.sock");
1437
1438 unsafe {
1440 match prev_xdg {
1441 Some(value) => std::env::set_var("XDG_RUNTIME_DIR", value),
1442 None => std::env::remove_var("XDG_RUNTIME_DIR"),
1443 }
1444 }
1445
1446 assert!(
1447 path.starts_with("/run/fips/") || path == "/tmp/fips-gateway.sock",
1448 "expected /run/fips or /tmp fallback, got: {path}"
1449 );
1450 assert!(
1451 !path.starts_with(bogus),
1452 "stale XDG_RUNTIME_DIR leaked into resolver: {path}"
1453 );
1454 }
1455
1456 #[test]
1457 fn test_validate_loopback_bind_with_external_peer_rejected() {
1458 use crate::config::PeerAddress;
1459 let mut config = Config::default();
1460 config.transports.udp = TransportInstances::Single(UdpConfig {
1461 bind_addr: Some("127.0.0.1:2121".to_string()),
1462 ..Default::default()
1463 });
1464 config.peers = vec![PeerConfig {
1465 npub: "npub1peer".to_string(),
1466 addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1467 ..Default::default()
1468 }];
1469
1470 let err = config.validate().expect_err("validation should fail");
1471 let msg = err.to_string();
1472 assert!(msg.contains("loopback"), "got: {msg}");
1473 assert!(msg.contains("non-loopback"), "got: {msg}");
1474 }
1475
1476 #[test]
1477 fn test_validate_loopback_bind_with_loopback_peer_ok() {
1478 use crate::config::PeerAddress;
1479 let mut config = Config::default();
1480 config.transports.udp = TransportInstances::Single(UdpConfig {
1481 bind_addr: Some("127.0.0.1:2121".to_string()),
1482 ..Default::default()
1483 });
1484 config.peers = vec![PeerConfig {
1485 npub: "npub1peer".to_string(),
1486 addresses: vec![PeerAddress::new("udp", "127.0.0.2:2121")],
1487 ..Default::default()
1488 }];
1489
1490 config
1491 .validate()
1492 .expect("loopback peer with loopback bind should validate");
1493 }
1494
1495 #[test]
1496 fn test_validate_outbound_only_exempt_from_loopback_check() {
1497 use crate::config::PeerAddress;
1498 let mut config = Config::default();
1499 config.transports.udp = TransportInstances::Single(UdpConfig {
1502 bind_addr: Some("127.0.0.1:2121".to_string()),
1503 outbound_only: Some(true),
1504 ..Default::default()
1505 });
1506 config.peers = vec![PeerConfig {
1507 npub: "npub1peer".to_string(),
1508 addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1509 ..Default::default()
1510 }];
1511
1512 config
1513 .validate()
1514 .expect("outbound_only should be exempt from the loopback check");
1515 }
1516
1517 #[test]
1518 fn test_outbound_only_forces_ephemeral_bind() {
1519 let cfg = UdpConfig {
1520 bind_addr: Some("127.0.0.1:2121".to_string()),
1521 outbound_only: Some(true),
1522 ..Default::default()
1523 };
1524 assert_eq!(cfg.bind_addr(), "0.0.0.0:0");
1525 assert!(cfg.outbound_only());
1526 }
1527
1528 #[test]
1529 fn test_outbound_only_forces_advertise_off() {
1530 let cfg = UdpConfig {
1531 advertise_on_nostr: Some(true),
1532 outbound_only: Some(true),
1533 ..Default::default()
1534 };
1535 assert!(!cfg.advertise_on_nostr());
1536 }
1537
1538 #[test]
1539 fn test_udp_accept_connections_default_true() {
1540 let cfg = UdpConfig::default();
1541 assert!(cfg.accept_connections());
1542 }
1543}