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