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 {
733    use super::*;
734    use std::collections::HashMap;
735    use std::fs;
736    use tempfile::TempDir;
737
738    #[cfg(unix)]
739    static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(());
740
741    #[test]
742    fn test_empty_config() {
743        let config = Config::new();
744        assert!(config.node.identity.nsec.is_none());
745        assert!(!config.has_identity());
746    }
747
748    #[test]
749    fn test_parse_yaml_with_nsec() {
750        let yaml = r#"
751node:
752  identity:
753    nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
754"#;
755        let config: Config = serde_yaml::from_str(yaml).unwrap();
756        assert!(config.node.identity.nsec.is_some());
757        assert!(config.has_identity());
758    }
759
760    #[test]
761    fn test_parse_yaml_with_hex() {
762        let yaml = r#"
763node:
764  identity:
765    nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
766"#;
767        let config: Config = serde_yaml::from_str(yaml).unwrap();
768        assert!(config.node.identity.nsec.is_some());
769
770        let identity = config.create_identity().unwrap();
771        assert!(!identity.npub().is_empty());
772    }
773
774    #[test]
775    fn test_parse_yaml_empty() {
776        let yaml = "";
777        let config: Config = serde_yaml::from_str(yaml).unwrap();
778        assert!(config.node.identity.nsec.is_none());
779    }
780
781    #[test]
782    fn test_parse_yaml_partial() {
783        let yaml = r#"
784node:
785  identity: {}
786"#;
787        let config: Config = serde_yaml::from_str(yaml).unwrap();
788        assert!(config.node.identity.nsec.is_none());
789    }
790
791    #[test]
792    fn test_merge_configs() {
793        let mut base = Config::new();
794        base.node.identity.nsec = Some("base_nsec".to_string());
795
796        let mut override_config = Config::new();
797        override_config.node.identity.nsec = Some("override_nsec".to_string());
798
799        base.merge(override_config);
800        assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
801    }
802
803    #[test]
804    fn test_merge_preserves_base_when_override_empty() {
805        let mut base = Config::new();
806        base.node.identity.nsec = Some("base_nsec".to_string());
807
808        let override_config = Config::new();
809
810        base.merge(override_config);
811        assert_eq!(base.node.identity.nsec, Some("base_nsec".to_string()));
812    }
813
814    #[test]
815    fn test_create_identity_from_nsec() {
816        let mut config = Config::new();
817        config.node.identity.nsec =
818            Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
819
820        let identity = config.create_identity().unwrap();
821        assert!(!identity.npub().is_empty());
822    }
823
824    #[test]
825    fn test_create_identity_generates_new() {
826        let config = Config::new();
827        let identity = config.create_identity().unwrap();
828        assert!(!identity.npub().is_empty());
829    }
830
831    #[test]
832    fn test_load_from_file() {
833        let temp_dir = TempDir::new().unwrap();
834        let config_path = temp_dir.path().join("fips.yaml");
835
836        let yaml = r#"
837node:
838  identity:
839    nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
840"#;
841        fs::write(&config_path, yaml).unwrap();
842
843        let config = Config::load_file(&config_path).unwrap();
844        assert!(config.node.identity.nsec.is_some());
845    }
846
847    #[test]
848    fn test_load_from_paths_merges() {
849        let temp_dir = TempDir::new().unwrap();
850
851        // Create two config files
852        let low_priority = temp_dir.path().join("low.yaml");
853        let high_priority = temp_dir.path().join("high.yaml");
854
855        fs::write(
856            &low_priority,
857            r#"
858node:
859  identity:
860    nsec: "low_priority_nsec"
861"#,
862        )
863        .unwrap();
864
865        fs::write(
866            &high_priority,
867            r#"
868node:
869  identity:
870    nsec: "high_priority_nsec"
871"#,
872        )
873        .unwrap();
874
875        let paths = vec![low_priority.clone(), high_priority.clone()];
876        let (config, loaded) = Config::load_from_paths(&paths).unwrap();
877
878        assert_eq!(loaded.len(), 2);
879        assert_eq!(
880            config.node.identity.nsec,
881            Some("high_priority_nsec".to_string())
882        );
883    }
884
885    #[test]
886    fn test_load_from_paths_deep_merges_partial_node_sections() {
887        let temp_dir = TempDir::new().unwrap();
888        let low_priority = temp_dir.path().join("low.yaml");
889        let high_priority = temp_dir.path().join("high.yaml");
890
891        fs::write(
892            &low_priority,
893            r#"
894node:
895  limits:
896    max_peers: 4096
897  connected_udp:
898    fd_reserve: 2048
899"#,
900        )
901        .unwrap();
902
903        fs::write(
904            &high_priority,
905            r#"
906node:
907  identity:
908    nsec: "high_priority_nsec"
909  connected_udp:
910    enabled: false
911"#,
912        )
913        .unwrap();
914
915        let (config, loaded) =
916            Config::load_from_paths(&[low_priority.clone(), high_priority.clone()]).unwrap();
917
918        assert_eq!(loaded, vec![low_priority, high_priority]);
919        assert_eq!(config.node.limits.max_peers, 4096);
920        assert!(!config.node.connected_udp.enabled);
921        assert_eq!(config.node.connected_udp.fd_reserve, 2048);
922        assert_eq!(
923            config.node.identity.nsec,
924            Some("high_priority_nsec".to_string())
925        );
926    }
927
928    #[test]
929    fn test_load_skips_missing_files() {
930        let temp_dir = TempDir::new().unwrap();
931        let existing = temp_dir.path().join("exists.yaml");
932        let missing = temp_dir.path().join("missing.yaml");
933
934        fs::write(
935            &existing,
936            r#"
937node:
938  identity:
939    nsec: "existing_nsec"
940"#,
941        )
942        .unwrap();
943
944        let paths = vec![missing, existing.clone()];
945        let (config, loaded) = Config::load_from_paths(&paths).unwrap();
946
947        assert_eq!(loaded.len(), 1);
948        assert_eq!(loaded[0], existing);
949        assert_eq!(config.node.identity.nsec, Some("existing_nsec".to_string()));
950    }
951
952    #[test]
953    fn test_search_paths_includes_expected() {
954        let paths = Config::search_paths();
955
956        // Should include current directory
957        assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
958
959        // Should include /etc/fips on Unix
960        #[cfg(unix)]
961        assert!(
962            paths
963                .iter()
964                .any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
965        );
966    }
967
968    #[test]
969    fn test_to_yaml() {
970        let mut config = Config::new();
971        config.node.identity.nsec = Some("test_nsec".to_string());
972
973        let yaml = config.to_yaml().unwrap();
974        assert!(yaml.contains("node:"));
975        assert!(yaml.contains("identity:"));
976        assert!(yaml.contains("nsec:"));
977        assert!(yaml.contains("test_nsec"));
978    }
979
980    #[test]
981    fn test_key_file_write_read_roundtrip() {
982        let temp_dir = TempDir::new().unwrap();
983        let key_path = temp_dir.path().join("fips.key");
984
985        let identity = crate::Identity::generate();
986        let nsec = crate::encode_nsec(&identity.keypair().secret_key());
987
988        write_key_file(&key_path, &nsec).unwrap();
989
990        let loaded_nsec = read_key_file(&key_path).unwrap();
991        assert_eq!(loaded_nsec, nsec);
992
993        // Verify the loaded nsec produces the same identity
994        let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
995        assert_eq!(loaded_identity.npub(), identity.npub());
996    }
997
998    #[cfg(unix)]
999    #[test]
1000    fn test_key_file_permissions() {
1001        use std::os::unix::fs::MetadataExt;
1002
1003        let temp_dir = TempDir::new().unwrap();
1004        let key_path = temp_dir.path().join("fips.key");
1005
1006        write_key_file(&key_path, "nsec1test").unwrap();
1007
1008        let metadata = fs::metadata(&key_path).unwrap();
1009        assert_eq!(metadata.mode() & 0o777, 0o600);
1010    }
1011
1012    #[cfg(unix)]
1013    #[test]
1014    fn test_key_file_permissions_are_tightened_on_overwrite() {
1015        use std::os::unix::fs::{MetadataExt, PermissionsExt};
1016
1017        let temp_dir = TempDir::new().unwrap();
1018        let key_path = temp_dir.path().join("fips.key");
1019        fs::write(&key_path, "old\n").unwrap();
1020        fs::set_permissions(&key_path, fs::Permissions::from_mode(0o644)).unwrap();
1021
1022        write_key_file(&key_path, "nsec1test").unwrap();
1023
1024        let metadata = fs::metadata(&key_path).unwrap();
1025        assert_eq!(metadata.mode() & 0o777, 0o600);
1026        assert_eq!(read_key_file(&key_path).unwrap(), "nsec1test");
1027    }
1028
1029    #[cfg(unix)]
1030    #[test]
1031    fn test_pub_file_permissions() {
1032        use std::os::unix::fs::MetadataExt;
1033
1034        let temp_dir = TempDir::new().unwrap();
1035        let pub_path = temp_dir.path().join("fips.pub");
1036
1037        write_pub_file(&pub_path, "npub1test").unwrap();
1038
1039        let metadata = fs::metadata(&pub_path).unwrap();
1040        assert_eq!(metadata.mode() & 0o777, 0o644);
1041    }
1042
1043    #[test]
1044    fn test_key_file_empty_error() {
1045        let temp_dir = TempDir::new().unwrap();
1046        let key_path = temp_dir.path().join("fips.key");
1047
1048        fs::write(&key_path, "").unwrap();
1049
1050        let result = read_key_file(&key_path);
1051        assert!(result.is_err());
1052        assert!(result.unwrap_err().to_string().contains("empty"));
1053    }
1054
1055    #[test]
1056    fn test_key_file_whitespace_trimmed() {
1057        let temp_dir = TempDir::new().unwrap();
1058        let key_path = temp_dir.path().join("fips.key");
1059
1060        fs::write(&key_path, "  nsec1test  \n").unwrap();
1061
1062        let nsec = read_key_file(&key_path).unwrap();
1063        assert_eq!(nsec, "nsec1test");
1064    }
1065
1066    #[test]
1067    fn test_key_file_path_derivation() {
1068        let config_path = PathBuf::from("/etc/fips/fips.yaml");
1069        assert_eq!(
1070            key_file_path(&config_path),
1071            PathBuf::from("/etc/fips/fips.key")
1072        );
1073        assert_eq!(
1074            pub_file_path(&config_path),
1075            PathBuf::from("/etc/fips/fips.pub")
1076        );
1077    }
1078
1079    #[cfg(windows)]
1080    #[test]
1081    fn test_key_file_write_read_roundtrip_windows() {
1082        let temp_dir = TempDir::new().unwrap();
1083        let key_path = temp_dir.path().join("fips.key");
1084
1085        let identity = crate::Identity::generate();
1086        let nsec = crate::encode_nsec(&identity.keypair().secret_key());
1087
1088        write_key_file(&key_path, &nsec).unwrap();
1089
1090        // Verify file was created and can be read back
1091        let loaded_nsec = read_key_file(&key_path).unwrap();
1092        assert_eq!(loaded_nsec, nsec);
1093
1094        // Verify the loaded nsec produces the same identity
1095        let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
1096        assert_eq!(loaded_identity.npub(), identity.npub());
1097    }
1098
1099    #[test]
1100    fn test_resolve_identity_from_config() {
1101        let mut config = Config::new();
1102        config.node.identity.nsec =
1103            Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
1104
1105        let resolved = resolve_identity(&config, &[]).unwrap();
1106        assert!(matches!(resolved.source, IdentitySource::Config));
1107    }
1108
1109    #[test]
1110    fn test_resolve_identity_ephemeral_by_default() {
1111        let temp_dir = TempDir::new().unwrap();
1112        let config_path = temp_dir.path().join("fips.yaml");
1113
1114        fs::write(&config_path, "node:\n  identity: {}\n").unwrap();
1115
1116        let config = Config::load_file(&config_path).unwrap();
1117        assert!(!config.node.identity.persistent);
1118
1119        let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1120        assert!(matches!(resolved.source, IdentitySource::Ephemeral));
1121
1122        // Key files should still be written for operator visibility
1123        let key_path = temp_dir.path().join("fips.key");
1124        let pub_path = temp_dir.path().join("fips.pub");
1125        assert!(key_path.exists());
1126        assert!(pub_path.exists());
1127    }
1128
1129    #[test]
1130    fn test_resolve_identity_ephemeral_changes_each_call() {
1131        let temp_dir = TempDir::new().unwrap();
1132        let config_path = temp_dir.path().join("fips.yaml");
1133
1134        fs::write(&config_path, "node:\n  identity: {}\n").unwrap();
1135
1136        let config = Config::load_file(&config_path).unwrap();
1137        let first = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1138        let second = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1139
1140        // Each call generates a different key
1141        assert_ne!(first.nsec, second.nsec);
1142    }
1143
1144    #[test]
1145    fn test_resolve_identity_persistent_from_key_file() {
1146        let temp_dir = TempDir::new().unwrap();
1147        let config_path = temp_dir.path().join("fips.yaml");
1148        let key_path = temp_dir.path().join("fips.key");
1149
1150        fs::write(&config_path, "node:\n  identity:\n    persistent: true\n").unwrap();
1151
1152        // Write a key file
1153        let identity = crate::Identity::generate();
1154        let nsec = crate::encode_nsec(&identity.keypair().secret_key());
1155        write_key_file(&key_path, &nsec).unwrap();
1156
1157        let config = Config::load_file(&config_path).unwrap();
1158        assert!(config.node.identity.persistent);
1159
1160        let resolved = resolve_identity(&config, &[config_path]).unwrap();
1161        assert!(matches!(resolved.source, IdentitySource::KeyFile(_)));
1162        assert_eq!(resolved.nsec, nsec);
1163    }
1164
1165    #[test]
1166    fn test_resolve_identity_persistent_generates_and_persists() {
1167        let temp_dir = TempDir::new().unwrap();
1168        let config_path = temp_dir.path().join("fips.yaml");
1169
1170        fs::write(&config_path, "node:\n  identity:\n    persistent: true\n").unwrap();
1171
1172        let config = Config::load_file(&config_path).unwrap();
1173        let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1174
1175        assert!(matches!(resolved.source, IdentitySource::Generated(_)));
1176
1177        // Key file and pub file should now exist
1178        let key_path = temp_dir.path().join("fips.key");
1179        let pub_path = temp_dir.path().join("fips.pub");
1180        assert!(key_path.exists());
1181        assert!(pub_path.exists());
1182
1183        // Second resolve should load from key file (not generate new)
1184        let resolved2 = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1185        assert!(matches!(resolved2.source, IdentitySource::KeyFile(_)));
1186        assert_eq!(resolved.nsec, resolved2.nsec);
1187    }
1188
1189    #[test]
1190    fn test_to_yaml_empty_nsec_omitted() {
1191        let config = Config::new();
1192        let yaml = config.to_yaml().unwrap();
1193
1194        // Empty nsec should not be serialized
1195        assert!(!yaml.contains("nsec:"));
1196    }
1197
1198    #[test]
1199    fn test_parse_transport_single_instance() {
1200        let yaml = r#"
1201transports:
1202  udp:
1203    bind_addr: "0.0.0.0:2121"
1204    mtu: 1400
1205"#;
1206        let config: Config = serde_yaml::from_str(yaml).unwrap();
1207
1208        assert_eq!(config.transports.udp.len(), 1);
1209        let instances: Vec<_> = config.transports.udp.iter().collect();
1210        assert_eq!(instances.len(), 1);
1211        assert_eq!(instances[0].0, None); // Single instance has no name
1212        assert_eq!(instances[0].1.bind_addr(), "0.0.0.0:2121");
1213        assert_eq!(instances[0].1.mtu(), 1400);
1214    }
1215
1216    #[test]
1217    fn test_parse_transport_named_instances() {
1218        let yaml = r#"
1219transports:
1220  udp:
1221    main:
1222      bind_addr: "0.0.0.0:2121"
1223    backup:
1224      bind_addr: "192.168.1.100:2122"
1225      mtu: 1280
1226"#;
1227        let config: Config = serde_yaml::from_str(yaml).unwrap();
1228
1229        assert_eq!(config.transports.udp.len(), 2);
1230
1231        let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
1232
1233        // Named instances have Some(name)
1234        assert!(instances.contains_key(&Some("main")));
1235        assert!(instances.contains_key(&Some("backup")));
1236        assert_eq!(instances[&Some("main")].bind_addr(), "0.0.0.0:2121");
1237        assert_eq!(instances[&Some("backup")].bind_addr(), "192.168.1.100:2122");
1238        assert_eq!(instances[&Some("backup")].mtu(), 1280);
1239    }
1240
1241    #[test]
1242    fn test_parse_transport_empty() {
1243        let yaml = r#"
1244transports: {}
1245"#;
1246        let config: Config = serde_yaml::from_str(yaml).unwrap();
1247        assert!(config.transports.udp.is_empty());
1248        assert!(config.transports.is_empty());
1249    }
1250
1251    #[test]
1252    fn test_transport_instances_iter() {
1253        // Single instance - no name
1254        let single = TransportInstances::Single(UdpConfig {
1255            bind_addr: Some("0.0.0.0:2121".to_string()),
1256            mtu: None,
1257            ..Default::default()
1258        });
1259        let items: Vec<_> = single.iter().collect();
1260        assert_eq!(items.len(), 1);
1261        assert_eq!(items[0].0, None);
1262
1263        // Named instances - have names
1264        let mut map = HashMap::new();
1265        map.insert("a".to_string(), UdpConfig::default());
1266        map.insert("b".to_string(), UdpConfig::default());
1267        let named = TransportInstances::Named(map);
1268        let items: Vec<_> = named.iter().collect();
1269        assert_eq!(items.len(), 2);
1270        // All named instances should have Some(name)
1271        assert!(items.iter().all(|(name, _)| name.is_some()));
1272    }
1273
1274    #[test]
1275    fn test_parse_peer_config() {
1276        let yaml = r#"
1277peers:
1278  - npub: "npub1abc123"
1279    alias: "gateway"
1280    addresses:
1281      - transport: udp
1282        addr: "192.168.1.1:2121"
1283        priority: 1
1284      - transport: tor
1285        addr: "xyz.onion:2121"
1286        priority: 2
1287    connect_policy: auto_connect
1288"#;
1289        let config: Config = serde_yaml::from_str(yaml).unwrap();
1290
1291        assert_eq!(config.peers.len(), 1);
1292        let peer = &config.peers[0];
1293        assert_eq!(peer.npub, "npub1abc123");
1294        assert_eq!(peer.alias, Some("gateway".to_string()));
1295        assert_eq!(peer.addresses.len(), 2);
1296        assert!(peer.is_auto_connect());
1297
1298        // Check addresses are sorted by priority
1299        let sorted = peer.addresses_by_priority();
1300        assert_eq!(sorted[0].transport, "udp");
1301        assert_eq!(sorted[0].priority, 1);
1302        assert_eq!(sorted[1].transport, "tor");
1303        assert_eq!(sorted[1].priority, 2);
1304    }
1305
1306    #[test]
1307    fn test_parse_peer_minimal() {
1308        let yaml = r#"
1309peers:
1310  - npub: "npub1xyz"
1311    addresses:
1312      - transport: udp
1313        addr: "10.0.0.1:2121"
1314"#;
1315        let config: Config = serde_yaml::from_str(yaml).unwrap();
1316
1317        assert_eq!(config.peers.len(), 1);
1318        let peer = &config.peers[0];
1319        assert_eq!(peer.npub, "npub1xyz");
1320        assert!(peer.alias.is_none());
1321        // Default connect_policy is auto_connect
1322        assert!(peer.is_auto_connect());
1323        // Default priority is 100
1324        assert_eq!(peer.addresses[0].priority, 100);
1325    }
1326
1327    #[test]
1328    fn test_parse_multiple_peers() {
1329        let yaml = r#"
1330peers:
1331  - npub: "npub1peer1"
1332    addresses:
1333      - transport: udp
1334        addr: "10.0.0.1:2121"
1335  - npub: "npub1peer2"
1336    addresses:
1337      - transport: udp
1338        addr: "10.0.0.2:2121"
1339    connect_policy: on_demand
1340"#;
1341        let config: Config = serde_yaml::from_str(yaml).unwrap();
1342
1343        assert_eq!(config.peers.len(), 2);
1344        assert_eq!(config.auto_connect_peers().count(), 1);
1345    }
1346
1347    #[test]
1348    fn test_peer_config_builder() {
1349        let peer = PeerConfig::new("npub1test", "udp", "192.168.1.1:2121")
1350            .with_alias("test-peer")
1351            .with_address(PeerAddress::with_priority("tor", "xyz.onion:2121", 50));
1352
1353        assert_eq!(peer.npub, "npub1test");
1354        assert_eq!(peer.alias, Some("test-peer".to_string()));
1355        assert_eq!(peer.addresses.len(), 2);
1356        assert!(peer.is_auto_connect());
1357    }
1358
1359    #[test]
1360    fn test_parse_nostr_discovery_config() {
1361        let yaml = r#"
1362node:
1363  discovery:
1364    nostr:
1365      enabled: true
1366      advertise: false
1367      policy: configured_only
1368      open_discovery_max_pending: 12
1369      app: "fips.nat.test.v1"
1370      signal_ttl_secs: 45
1371      advert_relays:
1372        - "wss://relay-a.example"
1373      dm_relays:
1374        - "wss://relay-b.example"
1375      stun_servers:
1376        - "stun:stun.example.org:3478"
1377peers:
1378  - npub: "npub1peer"
1379    addresses:
1380      - transport: udp
1381        addr: "nat"
1382"#;
1383        let config: Config = serde_yaml::from_str(yaml).unwrap();
1384        assert!(config.node.discovery.nostr.enabled);
1385        assert!(!config.node.discovery.nostr.advertise);
1386        assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
1387        assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
1388        assert_eq!(
1389            config.node.discovery.nostr.policy,
1390            NostrDiscoveryPolicy::ConfiguredOnly
1391        );
1392        assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
1393        assert_eq!(
1394            config.node.discovery.nostr.advert_relays,
1395            vec!["wss://relay-a.example".to_string()]
1396        );
1397        assert_eq!(
1398            config.node.discovery.nostr.dm_relays,
1399            vec!["wss://relay-b.example".to_string()]
1400        );
1401        assert_eq!(
1402            config.node.discovery.nostr.stun_servers,
1403            vec!["stun:stun.example.org:3478".to_string()]
1404        );
1405        assert_eq!(
1406            config.peers[0].addresses[0].addr, "nat",
1407            "udp:nat address should parse without special-casing in YAML"
1408        );
1409    }
1410
1411    #[test]
1412    fn test_validate_transport_advert_requires_nostr_enabled() {
1413        let mut config = Config::default();
1414        config.transports.udp = TransportInstances::Single(UdpConfig {
1415            advertise_on_nostr: Some(true),
1416            ..Default::default()
1417        });
1418        config.node.discovery.nostr.enabled = false;
1419
1420        let err = config.validate().expect_err("validation should fail");
1421        assert!(err.to_string().contains("advertise_on_nostr"));
1422
1423        config.transports.udp = TransportInstances::default();
1424        config.transports.webrtc = TransportInstances::Single(WebRtcConfig {
1425            advertise_on_nostr: Some(true),
1426            ..Default::default()
1427        });
1428
1429        let err = config.validate().expect_err("validation should fail");
1430        assert!(err.to_string().contains("advertise_on_nostr"));
1431    }
1432
1433    #[test]
1434    fn test_validate_empty_peer_addresses_require_nostr_enabled() {
1435        let mut config = Config {
1436            peers: vec![PeerConfig {
1437                npub: "npub1peer".to_string(),
1438                ..Default::default()
1439            }],
1440            ..Default::default()
1441        };
1442        config.node.discovery.nostr.enabled = false;
1443
1444        let err = config.validate().expect_err("validation should fail");
1445        assert!(err.to_string().contains("node.discovery.nostr"));
1446    }
1447
1448    #[test]
1449    fn test_validate_peer_addresses_optional_with_nostr_enabled() {
1450        // Empty addresses + Nostr discovery disabled -> error.
1451        let mut config = Config {
1452            peers: vec![PeerConfig {
1453                npub: "npub1peer".to_string(),
1454                ..Default::default()
1455            }],
1456            ..Default::default()
1457        };
1458        let err = config.validate().expect_err("validation should fail");
1459        assert!(err.to_string().contains("at least one address"));
1460
1461        // Empty addresses + Nostr discovery enabled -> ok.
1462        config.node.discovery.nostr.enabled = true;
1463        config
1464            .validate()
1465            .expect("Nostr discovery should allow empty addresses");
1466    }
1467
1468    #[test]
1469    fn test_validate_nat_udp_advert_requires_relays_and_stun() {
1470        let mut config = Config::default();
1471        config.node.discovery.nostr.enabled = true;
1472        config.node.discovery.nostr.dm_relays.clear();
1473        config.transports.udp = TransportInstances::Single(UdpConfig {
1474            advertise_on_nostr: Some(true),
1475            public: Some(false),
1476            ..Default::default()
1477        });
1478
1479        let err = config.validate().expect_err("validation should fail");
1480        assert!(err.to_string().contains("dm_relays"));
1481
1482        config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
1483        config.node.discovery.nostr.stun_servers.clear();
1484        let err = config.validate().expect_err("validation should fail");
1485        assert!(err.to_string().contains("stun_servers"));
1486    }
1487
1488    #[test]
1489    fn test_validate_webrtc_advert_requires_relays() {
1490        let mut config = Config::default();
1491        config.node.discovery.nostr.enabled = true;
1492        config.node.discovery.nostr.dm_relays.clear();
1493        config.transports.webrtc = TransportInstances::Single(WebRtcConfig {
1494            advertise_on_nostr: Some(true),
1495            ..Default::default()
1496        });
1497
1498        let err = config.validate().expect_err("validation should fail");
1499        assert!(err.to_string().contains("dm_relays"));
1500
1501        if let TransportInstances::Single(cfg) = &mut config.transports.webrtc {
1502            cfg.signal_relays = Some(vec!["wss://relay.example".to_string()]);
1503        }
1504        config
1505            .validate()
1506            .expect("WebRTC transport-specific relays should satisfy validation");
1507    }
1508
1509    #[test]
1510    fn test_is_loopback_addr_str() {
1511        assert!(is_loopback_addr_str("127.0.0.1:2121"));
1512        assert!(is_loopback_addr_str("127.0.0.5:9999"));
1513        assert!(is_loopback_addr_str("[::1]:2121"));
1514        assert!(is_loopback_addr_str("::1:2121"));
1515        assert!(is_loopback_addr_str("localhost:80"));
1516        assert!(!is_loopback_addr_str("0.0.0.0:2121"));
1517        assert!(!is_loopback_addr_str("192.168.1.1:2121"));
1518        assert!(!is_loopback_addr_str("[fd00::1]:2121"));
1519        assert!(!is_loopback_addr_str("core-vm.tail65015.ts.net:2121"));
1520        assert!(!is_loopback_addr_str("example.com:443"));
1521    }
1522
1523    #[cfg(unix)]
1524    #[test]
1525    fn test_resolve_default_socket_call_sites_agree() {
1526        let _guard = ENV_MUTEX.lock().unwrap();
1527
1528        let control_client = default_control_path().to_string_lossy().into_owned();
1529        let gateway_client = default_gateway_path().to_string_lossy().into_owned();
1530        let control_daemon = ControlConfig::default().socket_path;
1531
1532        assert_eq!(control_daemon, control_client);
1533
1534        let control_dir = Path::new(&control_client)
1535            .parent()
1536            .map(|p| p.to_string_lossy().into_owned())
1537            .unwrap_or_default();
1538        let gateway_dir = Path::new(&gateway_client)
1539            .parent()
1540            .map(|p| p.to_string_lossy().into_owned())
1541            .unwrap_or_default();
1542        assert_eq!(control_dir, gateway_dir);
1543    }
1544
1545    #[cfg(unix)]
1546    #[test]
1547    fn test_resolve_default_socket_xdg_when_no_run_fips() {
1548        let _guard = ENV_MUTEX.lock().unwrap();
1549
1550        let temp_dir = TempDir::new().unwrap();
1551        let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
1552
1553        // SAFETY: serialized by ENV_MUTEX, so no other test in this module
1554        // observes the transient process environment.
1555        unsafe {
1556            std::env::set_var("XDG_RUNTIME_DIR", temp_dir.path());
1557        }
1558
1559        let path = resolve_default_socket("control.sock");
1560
1561        // SAFETY: serialized by ENV_MUTEX.
1562        unsafe {
1563            match prev_xdg {
1564                Some(value) => std::env::set_var("XDG_RUNTIME_DIR", value),
1565                None => std::env::remove_var("XDG_RUNTIME_DIR"),
1566            }
1567        }
1568
1569        assert!(
1570            path.starts_with("/run/fips/")
1571                || path.starts_with(&format!("{}/fips/", temp_dir.path().display())),
1572            "expected /run/fips or XDG path, got: {path}"
1573        );
1574    }
1575
1576    #[cfg(unix)]
1577    #[test]
1578    fn test_resolve_default_socket_tmp_when_xdg_invalid() {
1579        let _guard = ENV_MUTEX.lock().unwrap();
1580
1581        let prev_xdg = std::env::var("XDG_RUNTIME_DIR").ok();
1582        let bogus = "/nonexistent-xdg-runtime-dir-for-fips-test-zzz";
1583
1584        // SAFETY: serialized by ENV_MUTEX.
1585        unsafe {
1586            std::env::set_var("XDG_RUNTIME_DIR", bogus);
1587        }
1588
1589        let path = resolve_default_socket("gateway.sock");
1590
1591        // SAFETY: serialized by ENV_MUTEX.
1592        unsafe {
1593            match prev_xdg {
1594                Some(value) => std::env::set_var("XDG_RUNTIME_DIR", value),
1595                None => std::env::remove_var("XDG_RUNTIME_DIR"),
1596            }
1597        }
1598
1599        assert!(
1600            path.starts_with("/run/fips/") || path == "/tmp/fips-gateway.sock",
1601            "expected /run/fips or /tmp fallback, got: {path}"
1602        );
1603        assert!(
1604            !path.starts_with(bogus),
1605            "stale XDG_RUNTIME_DIR leaked into resolver: {path}"
1606        );
1607    }
1608
1609    #[test]
1610    fn test_validate_loopback_bind_with_external_peer_rejected() {
1611        use crate::config::PeerAddress;
1612        let mut config = Config::default();
1613        config.transports.udp = TransportInstances::Single(UdpConfig {
1614            bind_addr: Some("127.0.0.1:2121".to_string()),
1615            ..Default::default()
1616        });
1617        config.peers = vec![PeerConfig {
1618            npub: "npub1peer".to_string(),
1619            addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1620            ..Default::default()
1621        }];
1622
1623        let err = config.validate().expect_err("validation should fail");
1624        let msg = err.to_string();
1625        assert!(msg.contains("loopback"), "got: {msg}");
1626        assert!(msg.contains("non-loopback"), "got: {msg}");
1627    }
1628
1629    #[test]
1630    fn test_validate_loopback_bind_with_loopback_peer_ok() {
1631        use crate::config::PeerAddress;
1632        let mut config = Config::default();
1633        config.transports.udp = TransportInstances::Single(UdpConfig {
1634            bind_addr: Some("127.0.0.1:2121".to_string()),
1635            ..Default::default()
1636        });
1637        config.peers = vec![PeerConfig {
1638            npub: "npub1peer".to_string(),
1639            addresses: vec![PeerAddress::new("udp", "127.0.0.2:2121")],
1640            ..Default::default()
1641        }];
1642
1643        config
1644            .validate()
1645            .expect("loopback peer with loopback bind should validate");
1646    }
1647
1648    #[test]
1649    fn test_validate_outbound_only_exempt_from_loopback_check() {
1650        use crate::config::PeerAddress;
1651        let mut config = Config::default();
1652        // outbound_only overrides bind_addr → 0.0.0.0:0; the loopback
1653        // check must skip this transport entirely.
1654        config.transports.udp = TransportInstances::Single(UdpConfig {
1655            bind_addr: Some("127.0.0.1:2121".to_string()),
1656            outbound_only: Some(true),
1657            ..Default::default()
1658        });
1659        config.peers = vec![PeerConfig {
1660            npub: "npub1peer".to_string(),
1661            addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1662            ..Default::default()
1663        }];
1664
1665        config
1666            .validate()
1667            .expect("outbound_only should be exempt from the loopback check");
1668    }
1669
1670    #[test]
1671    fn test_outbound_only_forces_ephemeral_bind() {
1672        let cfg = UdpConfig {
1673            bind_addr: Some("127.0.0.1:2121".to_string()),
1674            outbound_only: Some(true),
1675            ..Default::default()
1676        };
1677        assert_eq!(cfg.bind_addr(), "0.0.0.0:0");
1678        assert!(cfg.outbound_only());
1679    }
1680
1681    #[test]
1682    fn test_outbound_only_forces_advertise_off() {
1683        let cfg = UdpConfig {
1684            advertise_on_nostr: Some(true),
1685            outbound_only: Some(true),
1686            ..Default::default()
1687        };
1688        assert!(!cfg.advertise_on_nostr());
1689    }
1690
1691    #[test]
1692    fn test_udp_accept_connections_default_true() {
1693        let cfg = UdpConfig::default();
1694        assert!(cfg.accept_connections());
1695    }
1696}