1#[cfg(target_os = "linux")]
22mod gateway;
23mod node;
24mod peer;
25mod transport;
26mod webrtc_budget;
27
28use crate::upper::config::{DnsConfig, TunConfig};
29use crate::{Identity, IdentityError};
30use serde::{Deserialize, Serialize};
31use std::path::{Path, PathBuf};
32use thiserror::Error;
33
34pub use crate::discovery::local::LocalInstanceDiscoveryConfig;
35#[cfg(target_os = "linux")]
36pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
37pub use node::{
38 BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
39 NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, NostrPeerfindingSource,
40 RateLimitConfig, RekeyConfig, RetryConfig, RoutingConfig, RoutingMode, SessionConfig,
41 SessionMmpConfig, TreeConfig,
42};
43pub use peer::{ConnectPolicy, PeerAddress, PeerAddressProvenance, PeerConfig};
44#[cfg(feature = "sim-transport")]
45pub use transport::SimTransportConfig;
46pub use transport::{
47 BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
48 TransportsConfig, UdpConfig, WebRtcConfig, WebSocketConfig,
49};
50pub(crate) use webrtc_budget::{
51 MAX_WEBRTC_CONFIG_CANDIDATE_SOCKETS, validate_webrtc_candidate_socket_budget,
52};
53#[cfg(feature = "webrtc-transport")]
54pub(crate) use webrtc_budget::{
55 MAX_WEBRTC_HOST_CANDIDATE_SOCKETS, MAX_WEBRTC_LOCAL_CANDIDATE_LINES,
56 MAX_WEBRTC_LOCAL_CANDIDATE_ROUTES, MAX_WEBRTC_REMOTE_CANDIDATE_LINES,
57 MAX_WEBRTC_REMOTE_CANDIDATE_ROUTES,
58};
59#[cfg(all(test, feature = "webrtc-transport"))]
60pub(crate) use webrtc_budget::{MAX_WEBRTC_SOCKETS_PER_STUN_SERVER, MAX_WEBRTC_STUN_SERVERS};
61
62const CONFIG_FILENAME: &str = "fips.yaml";
64
65const KEY_FILENAME: &str = "fips.key";
67
68const PUB_FILENAME: &str = "fips.pub";
70
71fn is_loopback_addr_str(addr: &str) -> bool {
78 if let Some(rest) = addr.strip_prefix('[')
80 && let Some(end) = rest.find(']')
81 {
82 let host = &rest[..end];
83 return host == "::1";
84 }
85 let host = match addr.rsplit_once(':') {
87 Some((h, _)) => h,
88 None => addr,
89 };
90 host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" || host.starts_with("127.")
91}
92
93pub fn key_file_path(config_path: &Path) -> PathBuf {
95 config_path
96 .parent()
97 .unwrap_or(Path::new("."))
98 .join(KEY_FILENAME)
99}
100
101pub fn pub_file_path(config_path: &Path) -> PathBuf {
103 config_path
104 .parent()
105 .unwrap_or(Path::new("."))
106 .join(PUB_FILENAME)
107}
108
109#[cfg(unix)]
117pub(crate) fn resolve_default_socket(filename: &str) -> String {
118 if Path::new("/run/fips").is_dir() {
119 return format!("/run/fips/{filename}");
120 }
121
122 if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR")
123 && Path::new(&xdg).is_dir()
124 {
125 return format!("{xdg}/fips/{filename}");
126 }
127
128 format!("/tmp/fips-{filename}")
129}
130
131pub fn default_control_path() -> PathBuf {
137 #[cfg(unix)]
138 {
139 PathBuf::from(resolve_default_socket("control.sock"))
140 }
141 #[cfg(windows)]
142 {
143 PathBuf::from("21210")
144 }
145}
146
147pub fn default_gateway_path() -> PathBuf {
152 #[cfg(unix)]
153 {
154 PathBuf::from(resolve_default_socket("gateway.sock"))
155 }
156 #[cfg(windows)]
157 {
158 PathBuf::from("21211")
159 }
160}
161
162pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
164 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
165 path: path.to_path_buf(),
166 source: e,
167 })?;
168 let nsec = contents.trim().to_string();
169 if nsec.is_empty() {
170 return Err(ConfigError::EmptyKeyFile {
171 path: path.to_path_buf(),
172 });
173 }
174 Ok(nsec)
175}
176
177pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
182 use std::io::Write;
183
184 let mut opts = std::fs::OpenOptions::new();
185 opts.write(true).create(true).truncate(true);
186
187 #[cfg(unix)]
188 {
189 use std::os::unix::fs::OpenOptionsExt;
190 opts.mode(0o600);
191 }
192
193 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
194 path: path.to_path_buf(),
195 source: e,
196 })?;
197
198 #[cfg(unix)]
199 {
200 use std::os::unix::fs::PermissionsExt;
201 file.set_permissions(std::fs::Permissions::from_mode(0o600))
202 .map_err(|e| ConfigError::WriteKeyFile {
203 path: path.to_path_buf(),
204 source: e,
205 })?;
206 }
207
208 file.write_all(nsec.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 write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
226 use std::io::Write;
227
228 let mut opts = std::fs::OpenOptions::new();
229 opts.write(true).create(true).truncate(true);
230
231 #[cfg(unix)]
232 {
233 use std::os::unix::fs::OpenOptionsExt;
234 opts.mode(0o644);
235 }
236
237 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
238 path: path.to_path_buf(),
239 source: e,
240 })?;
241
242 file.write_all(npub.as_bytes())
243 .map_err(|e| ConfigError::WriteKeyFile {
244 path: path.to_path_buf(),
245 source: e,
246 })?;
247 file.write_all(b"\n")
248 .map_err(|e| ConfigError::WriteKeyFile {
249 path: path.to_path_buf(),
250 source: e,
251 })?;
252 Ok(())
253}
254
255pub fn resolve_identity(
272 config: &Config,
273 loaded_paths: &[PathBuf],
274) -> Result<ResolvedIdentity, ConfigError> {
275 use crate::encode_nsec;
276
277 if let Some(nsec) = &config.node.identity.nsec {
279 return Ok(ResolvedIdentity {
280 nsec: nsec.clone(),
281 source: IdentitySource::Config,
282 });
283 }
284
285 let config_ref = if let Some(path) = loaded_paths.last() {
287 path.clone()
288 } else {
289 Config::search_paths()
290 .first()
291 .cloned()
292 .unwrap_or_else(|| PathBuf::from("./fips.yaml"))
293 };
294 let key_path = key_file_path(&config_ref);
295 let pub_path = pub_file_path(&config_ref);
296
297 if config.node.identity.persistent {
298 if key_path.exists() {
300 let nsec = read_key_file(&key_path)?;
301 let identity = Identity::from_secret_str(&nsec)?;
302 let _ = write_pub_file(&pub_path, &identity.npub());
303 return Ok(ResolvedIdentity {
304 nsec,
305 source: IdentitySource::KeyFile(key_path),
306 });
307 }
308
309 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 match write_key_file(&key_path, &nsec) {
319 Ok(()) => {
320 let _ = write_pub_file(&pub_path, &npub);
321 Ok(ResolvedIdentity {
322 nsec,
323 source: IdentitySource::Generated(key_path),
324 })
325 }
326 Err(_) => Ok(ResolvedIdentity {
327 nsec,
328 source: IdentitySource::Ephemeral,
329 }),
330 }
331 } else {
332 let identity = Identity::generate();
335 let nsec = encode_nsec(&identity.keypair().secret_key());
336 let npub = identity.npub();
337
338 if let Some(parent) = key_path.parent() {
339 let _ = std::fs::create_dir_all(parent);
340 }
341
342 let _ = write_key_file(&key_path, &nsec);
343 let _ = write_pub_file(&pub_path, &npub);
344
345 Ok(ResolvedIdentity {
346 nsec,
347 source: IdentitySource::Ephemeral,
348 })
349 }
350}
351
352pub struct ResolvedIdentity {
354 pub nsec: String,
356 pub source: IdentitySource,
358}
359
360pub enum IdentitySource {
362 Config,
364 KeyFile(PathBuf),
366 Generated(PathBuf),
368 Ephemeral,
370}
371
372#[derive(Debug, Error)]
374pub enum ConfigError {
375 #[error("failed to read config file {path}: {source}")]
376 ReadFile {
377 path: PathBuf,
378 source: std::io::Error,
379 },
380
381 #[error("failed to parse config file {path}: {source}")]
382 ParseYaml {
383 path: PathBuf,
384 source: serde_yaml::Error,
385 },
386
387 #[error("key file is empty: {path}")]
388 EmptyKeyFile { path: PathBuf },
389
390 #[error("failed to write key file {path}: {source}")]
391 WriteKeyFile {
392 path: PathBuf,
393 source: std::io::Error,
394 },
395
396 #[error("identity error: {0}")]
397 Identity(#[from] IdentityError),
398
399 #[error("invalid configuration: {0}")]
400 Validation(String),
401}
402
403#[derive(Debug, Clone, Default, Serialize, Deserialize)]
405pub struct IdentityConfig {
406 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub nsec: Option<String>,
410
411 #[serde(default)]
415 pub persistent: bool,
416}
417
418#[derive(Debug, Clone, Default, Serialize, Deserialize)]
420pub struct Config {
421 #[serde(default)]
423 pub node: NodeConfig,
424
425 #[serde(default)]
427 pub tun: TunConfig,
428
429 #[serde(default)]
431 pub dns: DnsConfig,
432
433 #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
435 pub transports: TransportsConfig,
436
437 #[serde(default, skip_serializing_if = "Vec::is_empty")]
439 pub peers: Vec<PeerConfig>,
440
441 #[cfg(target_os = "linux")]
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub gateway: Option<GatewayConfig>,
445}
446
447impl Config {
448 pub fn new() -> Self {
450 Self::default()
451 }
452
453 pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
463 let search_paths = Self::search_paths();
464 Self::load_from_paths(&search_paths)
465 }
466
467 pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
471 let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
472 let mut loaded_paths = Vec::new();
473
474 for path in paths {
475 if path.exists() {
476 let contents =
477 std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
478 path: path.to_path_buf(),
479 source: e,
480 })?;
481 let mut file_config: serde_yaml::Value =
482 serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
483 path: path.to_path_buf(),
484 source: e,
485 })?;
486 if file_config.is_null() {
487 file_config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
488 }
489 merge_yaml_value(&mut merged, file_config);
490 loaded_paths.push(path.clone());
491 }
492 }
493
494 let config = serde_yaml::from_value(merged).map_err(|e| ConfigError::ParseYaml {
495 path: loaded_paths
496 .last()
497 .cloned()
498 .unwrap_or_else(|| PathBuf::from("<merged config>")),
499 source: e,
500 })?;
501
502 Ok((config, loaded_paths))
503 }
504
505 pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
507 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
508 path: path.to_path_buf(),
509 source: e,
510 })?;
511
512 serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
513 path: path.to_path_buf(),
514 source: e,
515 })
516 }
517
518 pub fn search_paths() -> Vec<PathBuf> {
520 let mut paths = Vec::new();
521
522 paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
524
525 if let Some(config_dir) = dirs::config_dir() {
527 paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
528 }
529
530 if let Some(home_dir) = dirs::home_dir() {
532 paths.push(home_dir.join(".fips.yaml"));
533 }
534
535 paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
537
538 paths
539 }
540
541 pub fn merge(&mut self, other: Config) {
545 if other.node.identity.nsec.is_some() {
547 self.node.identity.nsec = other.node.identity.nsec;
548 }
549 if other.node.identity.persistent {
550 self.node.identity.persistent = true;
551 }
552 if other.node.leaf_only {
554 self.node.leaf_only = true;
555 }
556 if other.tun.enabled {
558 self.tun.enabled = true;
559 }
560 if other.tun.name.is_some() {
561 self.tun.name = other.tun.name;
562 }
563 if other.tun.mtu.is_some() {
564 self.tun.mtu = other.tun.mtu;
565 }
566 self.dns.enabled = other.dns.enabled;
568 if other.dns.bind_addr.is_some() {
569 self.dns.bind_addr = other.dns.bind_addr;
570 }
571 if other.dns.port.is_some() {
572 self.dns.port = other.dns.port;
573 }
574 if other.dns.ttl.is_some() {
575 self.dns.ttl = other.dns.ttl;
576 }
577 self.transports.merge(other.transports);
579 if !other.peers.is_empty() {
581 self.peers = other.peers;
582 }
583 #[cfg(target_os = "linux")]
585 if other.gateway.is_some() {
586 self.gateway = other.gateway;
587 }
588 }
589
590 pub fn create_identity(&self) -> Result<Identity, ConfigError> {
595 match &self.node.identity.nsec {
596 Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
597 None => Ok(Identity::generate()),
598 }
599 }
600
601 pub fn has_identity(&self) -> bool {
603 self.node.identity.nsec.is_some()
604 }
605
606 pub fn is_leaf_only(&self) -> bool {
608 self.node.leaf_only
609 }
610
611 pub fn peers(&self) -> &[PeerConfig] {
613 &self.peers
614 }
615
616 pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
618 self.peers.iter().filter(|p| p.is_auto_connect())
619 }
620
621 pub fn validate(&self) -> Result<(), ConfigError> {
623 let nostr = &self.node.discovery.nostr;
624 let local = &self.node.discovery.local;
625
626 if local.enabled && !local.has_valid_rendezvous_addr() {
627 return Err(ConfigError::Validation(format!(
628 "node.discovery.local.rendezvous_addr must be a non-zero IPv4 loopback address, got {}",
629 local.rendezvous_addr
630 )));
631 }
632
633 if local.enabled {
634 let fixed = std::net::SocketAddr::V4(local.rendezvous_addr);
635 for (name, udp) in self.transports.udp.iter() {
636 if udp.outbound_only() {
637 continue;
638 }
639 let Ok(bind) = udp.bind_addr().parse::<std::net::SocketAddr>() else {
640 continue;
641 };
642 if bind.port() == fixed.port() && (bind.ip().is_unspecified() || bind == fixed) {
643 let name = name.unwrap_or("default");
644 return Err(ConfigError::Validation(format!(
645 "transports.udp.{name} bind {bind} collides with the local rendezvous at {fixed}"
646 )));
647 }
648 }
649 }
650
651 let any_transport_advertises_on_nostr = self
652 .transports
653 .udp
654 .iter()
655 .any(|(_, cfg)| cfg.advertise_on_nostr())
656 || self
657 .transports
658 .tcp
659 .iter()
660 .any(|(_, cfg)| cfg.advertise_on_nostr())
661 || self
662 .transports
663 .tor
664 .iter()
665 .any(|(_, cfg)| cfg.advertise_on_nostr())
666 || self
667 .transports
668 .webrtc
669 .iter()
670 .any(|(_, cfg)| cfg.advertise_on_nostr());
671
672 if any_transport_advertises_on_nostr && !nostr.enabled {
673 return Err(ConfigError::Validation(
674 "at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
675 ));
676 }
677
678 let ble_discovery_enabled = self
679 .transports
680 .ble
681 .iter()
682 .any(|(_, config)| config.scan() && config.auto_connect());
683 let can_resolve_addressless_peers = nostr.enabled
684 || self
685 .transports
686 .websocket
687 .iter()
688 .any(|(_, cfg)| !cfg.seed_urls.is_empty())
689 || self
690 .transports
691 .ethernet
692 .iter()
693 .any(|(_, cfg)| cfg.discovery())
694 || ble_discovery_enabled;
695
696 for (i, peer) in self.peers.iter().enumerate() {
697 if peer.addresses.is_empty() && !can_resolve_addressless_peers {
698 return Err(ConfigError::Validation(format!(
699 "peers[{i}] ({}): must specify at least one address, a WebSocket seed URL, or an enabled peer-discovery transport",
700 peer.npub
701 )));
702 }
703 }
704
705 let has_nat_udp_advert = self
706 .transports
707 .udp
708 .iter()
709 .any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
710
711 if nostr.enabled && has_nat_udp_advert && nostr.stun_servers.is_empty() {
712 return Err(ConfigError::Validation(
713 "NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
714 ));
715 }
716
717 for (name, cfg) in self.transports.udp.iter() {
724 if cfg.outbound_only() {
725 continue;
726 }
727 if is_loopback_addr_str(cfg.bind_addr()) {
728 let any_external_peer = self.peers.iter().any(|peer| {
729 peer.addresses
730 .iter()
731 .any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
732 });
733 if any_external_peer {
734 let label = name.unwrap_or("(unnamed)");
735 return Err(ConfigError::Validation(format!(
736 "transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
737 fips cannot reach external peers from a loopback-bound socket. \
738 Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
739 cfg.bind_addr()
740 )));
741 }
742 }
743 }
744
745 let mut webrtc_candidate_sockets = 0usize;
746 for (name, cfg) in self.transports.webrtc.iter() {
747 let stun_servers = cfg.stun_servers(&nostr.stun_servers);
748 let reservation =
749 validate_webrtc_candidate_socket_budget(cfg.max_connections(), &stun_servers)
750 .map_err(|reason| {
751 let label = name.unwrap_or("(unnamed)");
752 ConfigError::Validation(format!(
753 "transports.webrtc[{label}] candidate socket budget: {reason}"
754 ))
755 })?;
756 webrtc_candidate_sockets = webrtc_candidate_sockets
757 .checked_add(reservation)
758 .ok_or_else(|| {
759 ConfigError::Validation(
760 "transports.webrtc configured candidate socket budget overflowed".into(),
761 )
762 })?;
763 if webrtc_candidate_sockets > MAX_WEBRTC_CONFIG_CANDIDATE_SOCKETS {
764 return Err(ConfigError::Validation(format!(
765 "transports.webrtc configured candidate socket budget reserves {webrtc_candidate_sockets}, exceeding {MAX_WEBRTC_CONFIG_CANDIDATE_SOCKETS}"
766 )));
767 }
768 }
769
770 for (name, cfg) in self.transports.websocket.iter() {
771 cfg.validate().map_err(|reason| {
772 let label = name.unwrap_or("(unnamed)");
773 ConfigError::Validation(format!(
774 "transports.websocket[{label}] configuration: {reason}"
775 ))
776 })?;
777 }
778
779 Ok(())
780 }
781
782 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
784 serde_yaml::to_string(self)
785 }
786}
787
788fn merge_yaml_value(base: &mut serde_yaml::Value, overlay: serde_yaml::Value) {
789 match (base, overlay) {
790 (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(overlay_map)) => {
791 for (key, value) in overlay_map {
792 match base_map.get_mut(&key) {
793 Some(existing) => merge_yaml_value(existing, value),
794 None => {
795 base_map.insert(key, value);
796 }
797 }
798 }
799 }
800 (base_slot, overlay) => *base_slot = overlay,
801 }
802}
803
804#[cfg(test)]
805mod tests;
806
807#[cfg(test)]
808#[path = "tests/socket_defaults.rs"]
809mod socket_default_tests;
810
811#[cfg(test)]
812mod webrtc_budget_tests;