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