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;
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
50/// Default config filename.
51const CONFIG_FILENAME: &str = "fips.yaml";
52
53/// Default key filename, placed alongside the config file.
54const KEY_FILENAME: &str = "fips.key";
55
56/// Default public key filename, placed alongside the key file.
57const PUB_FILENAME: &str = "fips.pub";
58
59/// Returns true if the textual `host:port` form refers to a loopback host.
60/// Recognizes IPv4 `127.x.x.x`, IPv6 `::1` (with or without brackets), and
61/// the literal string `localhost`. Hostnames are conservatively assumed to
62/// be non-loopback. Used by `Config::validate()` to reject misconfigured
63/// loopback UDP binds combined with non-loopback peer addresses (see
64/// ISSUE-2026-0005).
65fn is_loopback_addr_str(addr: &str) -> bool {
66    // Bracketed IPv6: `[::1]:port`
67    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    // Plain `host:port` — split on the rightmost ':'.
74    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
81/// Derive the key file path from a config file path.
82pub fn key_file_path(config_path: &Path) -> PathBuf {
83    config_path
84        .parent()
85        .unwrap_or(Path::new("."))
86        .join(KEY_FILENAME)
87}
88
89/// Derive the public key file path from a config file path.
90pub 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/// Resolve a default Unix-socket path under the canonical order:
98/// `/run/fips/<filename>` -> `$XDG_RUNTIME_DIR/fips/<filename>` -> `/tmp/fips-<filename>`.
99///
100/// `/run/fips` is the packaged convention. The resolver selects it whenever
101/// the directory exists so daemon and client defaults stay aligned. The daemon
102/// bind path creates missing parent directories; packaged installs create
103/// `/run/fips` via tmpfiles before service start.
104#[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
119/// Default control socket path for fipsctl / fipstop.
120///
121/// On Unix, checks the system-wide path first (used when the daemon runs as
122/// a systemd service), then falls back to the user's XDG runtime directory.
123/// On Windows, returns the default TCP port ("21210").
124pub 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
135/// Default gateway control socket path.
136///
137/// On Unix, follows the same pattern as the main control socket.
138/// On Windows, returns a placeholder TCP port ("21211").
139pub 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
150/// Read a bare bech32 nsec from a key file.
151pub 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
165/// Write a bare bech32 nsec to a key file with restricted permissions.
166///
167/// On Unix, the file is created with mode 0600 (owner read/write only).
168/// On Windows, the file inherits default ACLs from the parent directory.
169pub 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
209/// Write a bare bech32 npub to a public key file.
210///
211/// On Unix, the file is created with mode 0644 (owner read/write, others read).
212/// On Windows, the file inherits default ACLs from the parent directory.
213pub 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
243/// Resolve identity from config and key file.
244///
245/// Behavior depends on `node.identity.persistent`:
246///
247/// - **`persistent: false`** (default): generate a fresh ephemeral keypair
248///   every start. Key files are written for operator visibility but overwritten
249///   on each restart.
250///
251/// - **`persistent: true`**: use three-tier resolution:
252///   1. Explicit nsec in config — highest priority
253///   2. Persistent key file (`fips.key`) — reused across restarts
254///   3. Generate new — creates keypair, writes `fips.key` and `fips.pub`
255///
256/// - **`nsec` set explicitly**: always uses that, regardless of `persistent`.
257///
258/// Returns the nsec string (bech32 or hex) to be used for identity creation.
259pub fn resolve_identity(
260    config: &Config,
261    loaded_paths: &[PathBuf],
262) -> Result<ResolvedIdentity, ConfigError> {
263    use crate::encode_nsec;
264
265    // Explicit nsec in config always wins
266    if let Some(nsec) = &config.node.identity.nsec {
267        return Ok(ResolvedIdentity {
268            nsec: nsec.clone(),
269            source: IdentitySource::Config,
270        });
271    }
272
273    // Determine key file directory from loaded config paths
274    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        // Persistent mode: load existing key file or generate-and-persist
287        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        // No key file yet — generate and persist
298        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        // Ephemeral mode (default): fresh keypair every start, write key files
321        // for operator visibility
322        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
340/// Result of identity resolution.
341pub struct ResolvedIdentity {
342    /// The nsec string (bech32 or hex) for creating an Identity.
343    pub nsec: String,
344    /// Where the identity came from.
345    pub source: IdentitySource,
346}
347
348/// Where a resolved identity originated.
349pub enum IdentitySource {
350    /// From explicit nsec in config file.
351    Config,
352    /// Loaded from a persistent key file.
353    KeyFile(PathBuf),
354    /// Generated and saved to a new key file.
355    Generated(PathBuf),
356    /// Generated but could not be persisted.
357    Ephemeral,
358}
359
360/// Errors that can occur during configuration loading.
361#[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/// Identity configuration (`node.identity.*`).
392#[derive(Debug, Clone, Default, Serialize, Deserialize)]
393pub struct IdentityConfig {
394    /// Secret key in nsec (bech32) or hex format (`node.identity.nsec`).
395    /// If not specified, a new keypair will be generated.
396    #[serde(default, skip_serializing_if = "Option::is_none")]
397    pub nsec: Option<String>,
398
399    /// Whether to persist the identity across restarts (`node.identity.persistent`).
400    /// When false (default), a fresh ephemeral keypair is generated each start.
401    /// When true, the key file is reused across restarts.
402    #[serde(default)]
403    pub persistent: bool,
404}
405
406/// Root configuration structure.
407#[derive(Debug, Clone, Default, Serialize, Deserialize)]
408pub struct Config {
409    /// Node configuration (`node.*`).
410    #[serde(default)]
411    pub node: NodeConfig,
412
413    /// TUN interface configuration (`tun.*`).
414    #[serde(default)]
415    pub tun: TunConfig,
416
417    /// DNS responder configuration (`dns.*`).
418    #[serde(default)]
419    pub dns: DnsConfig,
420
421    /// Transport instances (`transports.*`).
422    #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
423    pub transports: TransportsConfig,
424
425    /// Static peers to connect to (`peers`).
426    #[serde(default, skip_serializing_if = "Vec::is_empty")]
427    pub peers: Vec<PeerConfig>,
428
429    /// Gateway configuration (`gateway`).
430    #[cfg(target_os = "linux")]
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub gateway: Option<GatewayConfig>,
433}
434
435impl Config {
436    /// Create a new empty configuration.
437    pub fn new() -> Self {
438        Self::default()
439    }
440
441    /// Load configuration from the standard search paths.
442    ///
443    /// Files are loaded in reverse priority order and merged:
444    /// 1. `/etc/fips/fips.yaml` (loaded first, lowest priority)
445    /// 2. `~/.config/fips/fips.yaml` (user config)
446    /// 3. `./fips.yaml` (loaded last, highest priority)
447    ///
448    /// Returns a tuple of (config, paths_loaded) where paths_loaded contains
449    /// the paths that were successfully loaded.
450    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    /// Load configuration from specific paths.
456    ///
457    /// Paths are processed in order, with later paths overriding earlier ones.
458    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    /// Load configuration from a single file.
494    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    /// Get the standard search paths in priority order (lowest to highest).
507    pub fn search_paths() -> Vec<PathBuf> {
508        let mut paths = Vec::new();
509
510        // System config (lowest priority)
511        paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
512
513        // User config directory
514        if let Some(config_dir) = dirs::config_dir() {
515            paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
516        }
517
518        // Home directory (legacy location)
519        if let Some(home_dir) = dirs::home_dir() {
520            paths.push(home_dir.join(".fips.yaml"));
521        }
522
523        // Current directory (highest priority)
524        paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
525
526        paths
527    }
528
529    /// Merge another configuration into this one.
530    ///
531    /// Values from `other` override values in `self` when present.
532    pub fn merge(&mut self, other: Config) {
533        // Merge node.identity section
534        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        // Merge node.leaf_only
541        if other.node.leaf_only {
542            self.node.leaf_only = true;
543        }
544        // Merge tun section
545        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        // Merge dns section — higher-priority config always wins for enabled
555        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        // Merge transports section
566        self.transports.merge(other.transports);
567        // Merge peers (replace if non-empty)
568        if !other.peers.is_empty() {
569            self.peers = other.peers;
570        }
571        // Merge gateway section — higher-priority config replaces entirely
572        #[cfg(target_os = "linux")]
573        if other.gateway.is_some() {
574            self.gateway = other.gateway;
575        }
576    }
577
578    /// Create an Identity from this configuration.
579    ///
580    /// If an nsec is configured, uses that to create the identity.
581    /// Otherwise, generates a new random identity.
582    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    /// Check if an identity is configured (vs. will be generated).
590    pub fn has_identity(&self) -> bool {
591        self.node.identity.nsec.is_some()
592    }
593
594    /// Check if leaf-only mode is configured.
595    pub fn is_leaf_only(&self) -> bool {
596        self.node.leaf_only
597    }
598
599    /// Get the configured peers.
600    pub fn peers(&self) -> &[PeerConfig] {
601        &self.peers
602    }
603
604    /// Get peers that should auto-connect on startup.
605    pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
606        self.peers.iter().filter(|p| p.is_auto_connect())
607    }
608
609    /// Validate cross-field configuration invariants.
610    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        // Reject loopback UDP bind combined with non-loopback peer addresses.
679        // Linux pins the source IP to a loopback-bound socket, so packets
680        // sent from such a socket to external peers are dropped at the
681        // routing layer with no clear error in the daemon log. See
682        // ISSUE-2026-0005. Outbound-only mode is exempt because it
683        // overrides bind_addr to 0.0.0.0:0 (kernel-picked source).
684        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    /// Serialize this configuration to YAML.
710    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;