Skip to main content

fips_core/config/
mod.rs

1//! FIPS Configuration System
2//!
3//! Loads configuration from YAML files with a cascading priority system:
4//! 1. `./fips.yaml` (current directory - highest priority)
5//! 2. `~/.config/fips/fips.yaml` (user config directory)
6//! 3. `/etc/fips/fips.yaml` (system - lowest priority)
7//!
8//! Values from higher priority files override those from lower priority files.
9//!
10//! # YAML Structure
11//!
12//! The YAML structure mirrors the sysctl-style paths in the architecture docs.
13//! For example, `node.identity.nsec` in the docs corresponds to:
14//!
15//! ```yaml
16//! node:
17//!   identity:
18//!     nsec: "nsec1..."
19//! ```
20
21#[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
62/// Default config filename.
63const CONFIG_FILENAME: &str = "fips.yaml";
64
65/// Default key filename, placed alongside the config file.
66const KEY_FILENAME: &str = "fips.key";
67
68/// Default public key filename, placed alongside the key file.
69const PUB_FILENAME: &str = "fips.pub";
70
71/// Returns true if the textual `host:port` form refers to a loopback host.
72/// Recognizes IPv4 `127.x.x.x`, IPv6 `::1` (with or without brackets), and
73/// the literal string `localhost`. Hostnames are conservatively assumed to
74/// be non-loopback. Used by `Config::validate()` to reject misconfigured
75/// loopback UDP binds combined with non-loopback peer addresses (see
76/// ISSUE-2026-0005).
77fn is_loopback_addr_str(addr: &str) -> bool {
78    // Bracketed IPv6: `[::1]:port`
79    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    // Plain `host:port` — split on the rightmost ':'.
86    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
93/// Derive the key file path from a config file path.
94pub fn key_file_path(config_path: &Path) -> PathBuf {
95    config_path
96        .parent()
97        .unwrap_or(Path::new("."))
98        .join(KEY_FILENAME)
99}
100
101/// Derive the public key file path from a config file path.
102pub 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/// Resolve a default Unix-socket path under the canonical order:
110/// `/run/fips/<filename>` -> `$XDG_RUNTIME_DIR/fips/<filename>` -> `/tmp/fips-<filename>`.
111///
112/// `/run/fips` is the packaged convention. The resolver selects it whenever
113/// the directory exists so daemon and client defaults stay aligned. The daemon
114/// bind path creates missing parent directories; packaged installs create
115/// `/run/fips` via tmpfiles before service start.
116#[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
131/// Default control socket path for fipsctl / fipstop.
132///
133/// On Unix, checks the system-wide path first (used when the daemon runs as
134/// a systemd service), then falls back to the user's XDG runtime directory.
135/// On Windows, returns the default TCP port ("21210").
136pub 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
147/// Default gateway control socket path.
148///
149/// On Unix, follows the same pattern as the main control socket.
150/// On Windows, returns a placeholder TCP port ("21211").
151pub 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
162/// Read a bare bech32 nsec from a key file.
163pub 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
177/// Write a bare bech32 nsec to a key file with restricted permissions.
178///
179/// On Unix, the file is created with mode 0600 (owner read/write only).
180/// On Windows, the file inherits default ACLs from the parent directory.
181pub 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
221/// Write a bare bech32 npub to a public key file.
222///
223/// On Unix, the file is created with mode 0644 (owner read/write, others read).
224/// On Windows, the file inherits default ACLs from the parent directory.
225pub 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
255/// Resolve identity from config and key file.
256///
257/// Behavior depends on `node.identity.persistent`:
258///
259/// - **`persistent: false`** (default): generate a fresh ephemeral keypair
260///   every start. Key files are written for operator visibility but overwritten
261///   on each restart.
262///
263/// - **`persistent: true`**: use three-tier resolution:
264///   1. Explicit nsec in config — highest priority
265///   2. Persistent key file (`fips.key`) — reused across restarts
266///   3. Generate new — creates keypair, writes `fips.key` and `fips.pub`
267///
268/// - **`nsec` set explicitly**: always uses that, regardless of `persistent`.
269///
270/// Returns the nsec string (bech32 or hex) to be used for identity creation.
271pub fn resolve_identity(
272    config: &Config,
273    loaded_paths: &[PathBuf],
274) -> Result<ResolvedIdentity, ConfigError> {
275    use crate::encode_nsec;
276
277    // Explicit nsec in config always wins
278    if let Some(nsec) = &config.node.identity.nsec {
279        return Ok(ResolvedIdentity {
280            nsec: nsec.clone(),
281            source: IdentitySource::Config,
282        });
283    }
284
285    // Determine key file directory from loaded config paths
286    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        // Persistent mode: load existing key file or generate-and-persist
299        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        // No key file yet — generate and persist
310        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        // Ephemeral mode (default): fresh keypair every start, write key files
333        // for operator visibility
334        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
352/// Result of identity resolution.
353pub struct ResolvedIdentity {
354    /// The nsec string (bech32 or hex) for creating an Identity.
355    pub nsec: String,
356    /// Where the identity came from.
357    pub source: IdentitySource,
358}
359
360/// Where a resolved identity originated.
361pub enum IdentitySource {
362    /// From explicit nsec in config file.
363    Config,
364    /// Loaded from a persistent key file.
365    KeyFile(PathBuf),
366    /// Generated and saved to a new key file.
367    Generated(PathBuf),
368    /// Generated but could not be persisted.
369    Ephemeral,
370}
371
372/// Errors that can occur during configuration loading.
373#[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/// Identity configuration (`node.identity.*`).
404#[derive(Debug, Clone, Default, Serialize, Deserialize)]
405pub struct IdentityConfig {
406    /// Secret key in nsec (bech32) or hex format (`node.identity.nsec`).
407    /// If not specified, a new keypair will be generated.
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub nsec: Option<String>,
410
411    /// Whether to persist the identity across restarts (`node.identity.persistent`).
412    /// When false (default), a fresh ephemeral keypair is generated each start.
413    /// When true, the key file is reused across restarts.
414    #[serde(default)]
415    pub persistent: bool,
416}
417
418/// Root configuration structure.
419#[derive(Debug, Clone, Default, Serialize, Deserialize)]
420pub struct Config {
421    /// Node configuration (`node.*`).
422    #[serde(default)]
423    pub node: NodeConfig,
424
425    /// TUN interface configuration (`tun.*`).
426    #[serde(default)]
427    pub tun: TunConfig,
428
429    /// DNS responder configuration (`dns.*`).
430    #[serde(default)]
431    pub dns: DnsConfig,
432
433    /// Transport instances (`transports.*`).
434    #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
435    pub transports: TransportsConfig,
436
437    /// Static peers to connect to (`peers`).
438    #[serde(default, skip_serializing_if = "Vec::is_empty")]
439    pub peers: Vec<PeerConfig>,
440
441    /// Gateway configuration (`gateway`).
442    #[cfg(target_os = "linux")]
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub gateway: Option<GatewayConfig>,
445}
446
447impl Config {
448    /// Create a new empty configuration.
449    pub fn new() -> Self {
450        Self::default()
451    }
452
453    /// Load configuration from the standard search paths.
454    ///
455    /// Files are loaded in reverse priority order and merged:
456    /// 1. `/etc/fips/fips.yaml` (loaded first, lowest priority)
457    /// 2. `~/.config/fips/fips.yaml` (user config)
458    /// 3. `./fips.yaml` (loaded last, highest priority)
459    ///
460    /// Returns a tuple of (config, paths_loaded) where paths_loaded contains
461    /// the paths that were successfully loaded.
462    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    /// Load configuration from specific paths.
468    ///
469    /// Paths are processed in order, with later paths overriding earlier ones.
470    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    /// Load configuration from a single file.
506    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    /// Get the standard search paths in priority order (lowest to highest).
519    pub fn search_paths() -> Vec<PathBuf> {
520        let mut paths = Vec::new();
521
522        // System config (lowest priority)
523        paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
524
525        // User config directory
526        if let Some(config_dir) = dirs::config_dir() {
527            paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
528        }
529
530        // Home directory (legacy location)
531        if let Some(home_dir) = dirs::home_dir() {
532            paths.push(home_dir.join(".fips.yaml"));
533        }
534
535        // Current directory (highest priority)
536        paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
537
538        paths
539    }
540
541    /// Merge another configuration into this one.
542    ///
543    /// Values from `other` override values in `self` when present.
544    pub fn merge(&mut self, other: Config) {
545        // Merge node.identity section
546        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        // Merge node.leaf_only
553        if other.node.leaf_only {
554            self.node.leaf_only = true;
555        }
556        // Merge tun section
557        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        // Merge dns section — higher-priority config always wins for enabled
567        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        // Merge transports section
578        self.transports.merge(other.transports);
579        // Merge peers (replace if non-empty)
580        if !other.peers.is_empty() {
581            self.peers = other.peers;
582        }
583        // Merge gateway section — higher-priority config replaces entirely
584        #[cfg(target_os = "linux")]
585        if other.gateway.is_some() {
586            self.gateway = other.gateway;
587        }
588    }
589
590    /// Create an Identity from this configuration.
591    ///
592    /// If an nsec is configured, uses that to create the identity.
593    /// Otherwise, generates a new random identity.
594    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    /// Check if an identity is configured (vs. will be generated).
602    pub fn has_identity(&self) -> bool {
603        self.node.identity.nsec.is_some()
604    }
605
606    /// Check if leaf-only mode is configured.
607    pub fn is_leaf_only(&self) -> bool {
608        self.node.leaf_only
609    }
610
611    /// Get the configured peers.
612    pub fn peers(&self) -> &[PeerConfig] {
613        &self.peers
614    }
615
616    /// Get peers that should auto-connect on startup.
617    pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
618        self.peers.iter().filter(|p| p.is_auto_connect())
619    }
620
621    /// Validate cross-field configuration invariants.
622    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        // Reject loopback UDP bind combined with non-loopback peer addresses.
700        // Linux pins the source IP to a loopback-bound socket, so packets
701        // sent from such a socket to external peers are dropped at the
702        // routing layer with no clear error in the daemon log. See
703        // ISSUE-2026-0005. Outbound-only mode is exempt because it
704        // overrides bind_addr to 0.0.0.0:0 (kernel-picked source).
705        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    /// Serialize this configuration to YAML.
756    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)]
781mod webrtc_budget_tests;