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, NostrRelayConfig, TcpConfig, TorConfig,
48 TransportInstances, TransportsConfig, UdpConfig, WebRtcConfig,
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 for (i, peer) in self.peers.iter().enumerate() {
679 if peer.addresses.is_empty() && !nostr.enabled {
680 return Err(ConfigError::Validation(format!(
681 "peers[{i}] ({}): must specify at least one address, or enable `node.discovery.nostr` to resolve endpoints from Nostr adverts",
682 peer.npub
683 )));
684 }
685 }
686
687 let has_nat_udp_advert = self
688 .transports
689 .udp
690 .iter()
691 .any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
692
693 if nostr.enabled && has_nat_udp_advert && nostr.stun_servers.is_empty() {
694 return Err(ConfigError::Validation(
695 "NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
696 ));
697 }
698
699 for (name, cfg) in self.transports.udp.iter() {
706 if cfg.outbound_only() {
707 continue;
708 }
709 if is_loopback_addr_str(cfg.bind_addr()) {
710 let any_external_peer = self.peers.iter().any(|peer| {
711 peer.addresses
712 .iter()
713 .any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
714 });
715 if any_external_peer {
716 let label = name.unwrap_or("(unnamed)");
717 return Err(ConfigError::Validation(format!(
718 "transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
719 fips cannot reach external peers from a loopback-bound socket. \
720 Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
721 cfg.bind_addr()
722 )));
723 }
724 }
725 }
726
727 let mut webrtc_candidate_sockets = 0usize;
728 for (name, cfg) in self.transports.webrtc.iter() {
729 let stun_servers = cfg.stun_servers(&nostr.stun_servers);
730 let reservation =
731 validate_webrtc_candidate_socket_budget(cfg.max_connections(), &stun_servers)
732 .map_err(|reason| {
733 let label = name.unwrap_or("(unnamed)");
734 ConfigError::Validation(format!(
735 "transports.webrtc[{label}] candidate socket budget: {reason}"
736 ))
737 })?;
738 webrtc_candidate_sockets = webrtc_candidate_sockets
739 .checked_add(reservation)
740 .ok_or_else(|| {
741 ConfigError::Validation(
742 "transports.webrtc configured candidate socket budget overflowed".into(),
743 )
744 })?;
745 if webrtc_candidate_sockets > MAX_WEBRTC_CONFIG_CANDIDATE_SOCKETS {
746 return Err(ConfigError::Validation(format!(
747 "transports.webrtc configured candidate socket budget reserves {webrtc_candidate_sockets}, exceeding {MAX_WEBRTC_CONFIG_CANDIDATE_SOCKETS}"
748 )));
749 }
750 }
751
752 Ok(())
753 }
754
755 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
757 serde_yaml::to_string(self)
758 }
759}
760
761fn merge_yaml_value(base: &mut serde_yaml::Value, overlay: serde_yaml::Value) {
762 match (base, overlay) {
763 (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(overlay_map)) => {
764 for (key, value) in overlay_map {
765 match base_map.get_mut(&key) {
766 Some(existing) => merge_yaml_value(existing, value),
767 None => {
768 base_map.insert(key, value);
769 }
770 }
771 }
772 }
773 (base_slot, overlay) => *base_slot = overlay,
774 }
775}
776
777#[cfg(test)]
778mod tests;
779
780#[cfg(test)]
781#[path = "tests/socket_defaults.rs"]
782mod socket_default_tests;
783
784#[cfg(test)]
785mod webrtc_budget_tests;