1#[cfg(target_os = "linux")]
22mod gateway;
23mod node;
24mod peer;
25mod transport;
26
27use crate::upper::config::{DnsConfig, TunConfig};
28use crate::{Identity, IdentityError};
29use serde::{Deserialize, Serialize};
30use std::path::{Path, PathBuf};
31use thiserror::Error;
32
33pub use crate::discovery::local::LocalInstanceDiscoveryConfig;
34#[cfg(target_os = "linux")]
35pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
36pub use node::{
37 BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
38 NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
39 RetryConfig, RoutingConfig, RoutingMode, SessionConfig, SessionMmpConfig, TreeConfig,
40};
41pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
42#[cfg(feature = "sim-transport")]
43pub use transport::SimTransportConfig;
44pub use transport::{
45 BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
46 TransportsConfig, UdpConfig, WebRtcConfig,
47};
48
49const CONFIG_FILENAME: &str = "fips.yaml";
51
52const KEY_FILENAME: &str = "fips.key";
54
55const PUB_FILENAME: &str = "fips.pub";
57
58fn is_loopback_addr_str(addr: &str) -> bool {
65 if let Some(rest) = addr.strip_prefix('[')
67 && let Some(end) = rest.find(']')
68 {
69 let host = &rest[..end];
70 return host == "::1";
71 }
72 let host = match addr.rsplit_once(':') {
74 Some((h, _)) => h,
75 None => addr,
76 };
77 host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" || host.starts_with("127.")
78}
79
80pub fn key_file_path(config_path: &Path) -> PathBuf {
82 config_path
83 .parent()
84 .unwrap_or(Path::new("."))
85 .join(KEY_FILENAME)
86}
87
88pub fn pub_file_path(config_path: &Path) -> PathBuf {
90 config_path
91 .parent()
92 .unwrap_or(Path::new("."))
93 .join(PUB_FILENAME)
94}
95
96#[cfg(unix)]
104pub(crate) fn resolve_default_socket(filename: &str) -> String {
105 if Path::new("/run/fips").is_dir() {
106 return format!("/run/fips/{filename}");
107 }
108
109 if let Ok(xdg) = std::env::var("XDG_RUNTIME_DIR")
110 && Path::new(&xdg).is_dir()
111 {
112 return format!("{xdg}/fips/{filename}");
113 }
114
115 format!("/tmp/fips-{filename}")
116}
117
118pub fn default_control_path() -> PathBuf {
124 #[cfg(unix)]
125 {
126 PathBuf::from(resolve_default_socket("control.sock"))
127 }
128 #[cfg(windows)]
129 {
130 PathBuf::from("21210")
131 }
132}
133
134pub fn default_gateway_path() -> PathBuf {
139 #[cfg(unix)]
140 {
141 PathBuf::from(resolve_default_socket("gateway.sock"))
142 }
143 #[cfg(windows)]
144 {
145 PathBuf::from("21211")
146 }
147}
148
149pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
151 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
152 path: path.to_path_buf(),
153 source: e,
154 })?;
155 let nsec = contents.trim().to_string();
156 if nsec.is_empty() {
157 return Err(ConfigError::EmptyKeyFile {
158 path: path.to_path_buf(),
159 });
160 }
161 Ok(nsec)
162}
163
164pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
169 use std::io::Write;
170
171 let mut opts = std::fs::OpenOptions::new();
172 opts.write(true).create(true).truncate(true);
173
174 #[cfg(unix)]
175 {
176 use std::os::unix::fs::OpenOptionsExt;
177 opts.mode(0o600);
178 }
179
180 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
181 path: path.to_path_buf(),
182 source: e,
183 })?;
184
185 #[cfg(unix)]
186 {
187 use std::os::unix::fs::PermissionsExt;
188 file.set_permissions(std::fs::Permissions::from_mode(0o600))
189 .map_err(|e| ConfigError::WriteKeyFile {
190 path: path.to_path_buf(),
191 source: e,
192 })?;
193 }
194
195 file.write_all(nsec.as_bytes())
196 .map_err(|e| ConfigError::WriteKeyFile {
197 path: path.to_path_buf(),
198 source: e,
199 })?;
200 file.write_all(b"\n")
201 .map_err(|e| ConfigError::WriteKeyFile {
202 path: path.to_path_buf(),
203 source: e,
204 })?;
205 Ok(())
206}
207
208pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
213 use std::io::Write;
214
215 let mut opts = std::fs::OpenOptions::new();
216 opts.write(true).create(true).truncate(true);
217
218 #[cfg(unix)]
219 {
220 use std::os::unix::fs::OpenOptionsExt;
221 opts.mode(0o644);
222 }
223
224 let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
225 path: path.to_path_buf(),
226 source: e,
227 })?;
228
229 file.write_all(npub.as_bytes())
230 .map_err(|e| ConfigError::WriteKeyFile {
231 path: path.to_path_buf(),
232 source: e,
233 })?;
234 file.write_all(b"\n")
235 .map_err(|e| ConfigError::WriteKeyFile {
236 path: path.to_path_buf(),
237 source: e,
238 })?;
239 Ok(())
240}
241
242pub fn resolve_identity(
259 config: &Config,
260 loaded_paths: &[PathBuf],
261) -> Result<ResolvedIdentity, ConfigError> {
262 use crate::encode_nsec;
263
264 if let Some(nsec) = &config.node.identity.nsec {
266 return Ok(ResolvedIdentity {
267 nsec: nsec.clone(),
268 source: IdentitySource::Config,
269 });
270 }
271
272 let config_ref = if let Some(path) = loaded_paths.last() {
274 path.clone()
275 } else {
276 Config::search_paths()
277 .first()
278 .cloned()
279 .unwrap_or_else(|| PathBuf::from("./fips.yaml"))
280 };
281 let key_path = key_file_path(&config_ref);
282 let pub_path = pub_file_path(&config_ref);
283
284 if config.node.identity.persistent {
285 if key_path.exists() {
287 let nsec = read_key_file(&key_path)?;
288 let identity = Identity::from_secret_str(&nsec)?;
289 let _ = write_pub_file(&pub_path, &identity.npub());
290 return Ok(ResolvedIdentity {
291 nsec,
292 source: IdentitySource::KeyFile(key_path),
293 });
294 }
295
296 let identity = Identity::generate();
298 let nsec = encode_nsec(&identity.keypair().secret_key());
299 let npub = identity.npub();
300
301 if let Some(parent) = key_path.parent() {
302 let _ = std::fs::create_dir_all(parent);
303 }
304
305 match write_key_file(&key_path, &nsec) {
306 Ok(()) => {
307 let _ = write_pub_file(&pub_path, &npub);
308 Ok(ResolvedIdentity {
309 nsec,
310 source: IdentitySource::Generated(key_path),
311 })
312 }
313 Err(_) => Ok(ResolvedIdentity {
314 nsec,
315 source: IdentitySource::Ephemeral,
316 }),
317 }
318 } else {
319 let identity = Identity::generate();
322 let nsec = encode_nsec(&identity.keypair().secret_key());
323 let npub = identity.npub();
324
325 if let Some(parent) = key_path.parent() {
326 let _ = std::fs::create_dir_all(parent);
327 }
328
329 let _ = write_key_file(&key_path, &nsec);
330 let _ = write_pub_file(&pub_path, &npub);
331
332 Ok(ResolvedIdentity {
333 nsec,
334 source: IdentitySource::Ephemeral,
335 })
336 }
337}
338
339pub struct ResolvedIdentity {
341 pub nsec: String,
343 pub source: IdentitySource,
345}
346
347pub enum IdentitySource {
349 Config,
351 KeyFile(PathBuf),
353 Generated(PathBuf),
355 Ephemeral,
357}
358
359#[derive(Debug, Error)]
361pub enum ConfigError {
362 #[error("failed to read config file {path}: {source}")]
363 ReadFile {
364 path: PathBuf,
365 source: std::io::Error,
366 },
367
368 #[error("failed to parse config file {path}: {source}")]
369 ParseYaml {
370 path: PathBuf,
371 source: serde_yaml::Error,
372 },
373
374 #[error("key file is empty: {path}")]
375 EmptyKeyFile { path: PathBuf },
376
377 #[error("failed to write key file {path}: {source}")]
378 WriteKeyFile {
379 path: PathBuf,
380 source: std::io::Error,
381 },
382
383 #[error("identity error: {0}")]
384 Identity(#[from] IdentityError),
385
386 #[error("invalid configuration: {0}")]
387 Validation(String),
388}
389
390#[derive(Debug, Clone, Default, Serialize, Deserialize)]
392pub struct IdentityConfig {
393 #[serde(default, skip_serializing_if = "Option::is_none")]
396 pub nsec: Option<String>,
397
398 #[serde(default)]
402 pub persistent: bool,
403}
404
405#[derive(Debug, Clone, Default, Serialize, Deserialize)]
407pub struct Config {
408 #[serde(default)]
410 pub node: NodeConfig,
411
412 #[serde(default)]
414 pub tun: TunConfig,
415
416 #[serde(default)]
418 pub dns: DnsConfig,
419
420 #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
422 pub transports: TransportsConfig,
423
424 #[serde(default, skip_serializing_if = "Vec::is_empty")]
426 pub peers: Vec<PeerConfig>,
427
428 #[cfg(target_os = "linux")]
430 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub gateway: Option<GatewayConfig>,
432}
433
434impl Config {
435 pub fn new() -> Self {
437 Self::default()
438 }
439
440 pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
450 let search_paths = Self::search_paths();
451 Self::load_from_paths(&search_paths)
452 }
453
454 pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
458 let mut merged = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
459 let mut loaded_paths = Vec::new();
460
461 for path in paths {
462 if path.exists() {
463 let contents =
464 std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
465 path: path.to_path_buf(),
466 source: e,
467 })?;
468 let mut file_config: serde_yaml::Value =
469 serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
470 path: path.to_path_buf(),
471 source: e,
472 })?;
473 if file_config.is_null() {
474 file_config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
475 }
476 merge_yaml_value(&mut merged, file_config);
477 loaded_paths.push(path.clone());
478 }
479 }
480
481 let config = serde_yaml::from_value(merged).map_err(|e| ConfigError::ParseYaml {
482 path: loaded_paths
483 .last()
484 .cloned()
485 .unwrap_or_else(|| PathBuf::from("<merged config>")),
486 source: e,
487 })?;
488
489 Ok((config, loaded_paths))
490 }
491
492 pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
494 let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
495 path: path.to_path_buf(),
496 source: e,
497 })?;
498
499 serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
500 path: path.to_path_buf(),
501 source: e,
502 })
503 }
504
505 pub fn search_paths() -> Vec<PathBuf> {
507 let mut paths = Vec::new();
508
509 paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
511
512 if let Some(config_dir) = dirs::config_dir() {
514 paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
515 }
516
517 if let Some(home_dir) = dirs::home_dir() {
519 paths.push(home_dir.join(".fips.yaml"));
520 }
521
522 paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
524
525 paths
526 }
527
528 pub fn merge(&mut self, other: Config) {
532 if other.node.identity.nsec.is_some() {
534 self.node.identity.nsec = other.node.identity.nsec;
535 }
536 if other.node.identity.persistent {
537 self.node.identity.persistent = true;
538 }
539 if other.node.leaf_only {
541 self.node.leaf_only = true;
542 }
543 if other.tun.enabled {
545 self.tun.enabled = true;
546 }
547 if other.tun.name.is_some() {
548 self.tun.name = other.tun.name;
549 }
550 if other.tun.mtu.is_some() {
551 self.tun.mtu = other.tun.mtu;
552 }
553 self.dns.enabled = other.dns.enabled;
555 if other.dns.bind_addr.is_some() {
556 self.dns.bind_addr = other.dns.bind_addr;
557 }
558 if other.dns.port.is_some() {
559 self.dns.port = other.dns.port;
560 }
561 if other.dns.ttl.is_some() {
562 self.dns.ttl = other.dns.ttl;
563 }
564 self.transports.merge(other.transports);
566 if !other.peers.is_empty() {
568 self.peers = other.peers;
569 }
570 #[cfg(target_os = "linux")]
572 if other.gateway.is_some() {
573 self.gateway = other.gateway;
574 }
575 }
576
577 pub fn create_identity(&self) -> Result<Identity, ConfigError> {
582 match &self.node.identity.nsec {
583 Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
584 None => Ok(Identity::generate()),
585 }
586 }
587
588 pub fn has_identity(&self) -> bool {
590 self.node.identity.nsec.is_some()
591 }
592
593 pub fn is_leaf_only(&self) -> bool {
595 self.node.leaf_only
596 }
597
598 pub fn peers(&self) -> &[PeerConfig] {
600 &self.peers
601 }
602
603 pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
605 self.peers.iter().filter(|p| p.is_auto_connect())
606 }
607
608 pub fn validate(&self) -> Result<(), ConfigError> {
610 let nostr = &self.node.discovery.nostr;
611
612 let any_transport_advertises_on_nostr = self
613 .transports
614 .udp
615 .iter()
616 .any(|(_, cfg)| cfg.advertise_on_nostr())
617 || self
618 .transports
619 .tcp
620 .iter()
621 .any(|(_, cfg)| cfg.advertise_on_nostr())
622 || self
623 .transports
624 .tor
625 .iter()
626 .any(|(_, cfg)| cfg.advertise_on_nostr())
627 || self
628 .transports
629 .webrtc
630 .iter()
631 .any(|(_, cfg)| cfg.advertise_on_nostr());
632
633 if any_transport_advertises_on_nostr && !nostr.enabled {
634 return Err(ConfigError::Validation(
635 "at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
636 ));
637 }
638
639 for (i, peer) in self.peers.iter().enumerate() {
640 if peer.addresses.is_empty() && !nostr.enabled {
641 return Err(ConfigError::Validation(format!(
642 "peers[{i}] ({}): must specify at least one address, or enable `node.discovery.nostr` to resolve endpoints from Nostr adverts",
643 peer.npub
644 )));
645 }
646 }
647
648 let has_nat_udp_advert = self
649 .transports
650 .udp
651 .iter()
652 .any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
653
654 if nostr.enabled && has_nat_udp_advert {
655 if nostr.dm_relays.is_empty() {
656 return Err(ConfigError::Validation(
657 "NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
658 ));
659 }
660 if nostr.stun_servers.is_empty() {
661 return Err(ConfigError::Validation(
662 "NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
663 ));
664 }
665 }
666
667 let has_webrtc_advert_without_relays = self.transports.webrtc.iter().any(|(_, cfg)| {
668 cfg.advertise_on_nostr() && cfg.signal_relays(&nostr.dm_relays).is_empty()
669 });
670
671 if nostr.enabled && has_webrtc_advert_without_relays {
672 return Err(ConfigError::Validation(
673 "WebRTC advert publishing requires `node.discovery.nostr.dm_relays` or `transports.webrtc.signal_relays` to be non-empty".to_string(),
674 ));
675 }
676
677 for (name, cfg) in self.transports.udp.iter() {
684 if cfg.outbound_only() {
685 continue;
686 }
687 if is_loopback_addr_str(cfg.bind_addr()) {
688 let any_external_peer = self.peers.iter().any(|peer| {
689 peer.addresses
690 .iter()
691 .any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
692 });
693 if any_external_peer {
694 let label = name.unwrap_or("(unnamed)");
695 return Err(ConfigError::Validation(format!(
696 "transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
697 fips cannot reach external peers from a loopback-bound socket. \
698 Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
699 cfg.bind_addr()
700 )));
701 }
702 }
703 }
704
705 Ok(())
706 }
707
708 pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
710 serde_yaml::to_string(self)
711 }
712}
713
714fn merge_yaml_value(base: &mut serde_yaml::Value, overlay: serde_yaml::Value) {
715 match (base, overlay) {
716 (serde_yaml::Value::Mapping(base_map), serde_yaml::Value::Mapping(overlay_map)) => {
717 for (key, value) in overlay_map {
718 match base_map.get_mut(&key) {
719 Some(existing) => merge_yaml_value(existing, value),
720 None => {
721 base_map.insert(key, value);
722 }
723 }
724 }
725 }
726 (base_slot, overlay) => *base_slot = overlay,
727 }
728}
729
730#[cfg(test)]
731mod tests;