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
33#[cfg(target_os = "linux")]
34pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
35pub use node::{
36    BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
37    NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
38    RetryConfig, RoutingConfig, RoutingMode, SessionConfig, SessionMmpConfig, TreeConfig,
39};
40pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
41#[cfg(feature = "sim-transport")]
42pub use transport::SimTransportConfig;
43pub use transport::{
44    BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
45    TransportsConfig, UdpConfig,
46};
47
48/// Default config filename.
49const CONFIG_FILENAME: &str = "fips.yaml";
50
51/// Default key filename, placed alongside the config file.
52const KEY_FILENAME: &str = "fips.key";
53
54/// Default public key filename, placed alongside the key file.
55const PUB_FILENAME: &str = "fips.pub";
56
57/// Returns true if the textual `host:port` form refers to a loopback host.
58/// Recognizes IPv4 `127.x.x.x`, IPv6 `::1` (with or without brackets), and
59/// the literal string `localhost`. Hostnames are conservatively assumed to
60/// be non-loopback. Used by `Config::validate()` to reject misconfigured
61/// loopback UDP binds combined with non-loopback peer addresses (see
62/// ISSUE-2026-0005).
63fn is_loopback_addr_str(addr: &str) -> bool {
64    // Bracketed IPv6: `[::1]:port`
65    if let Some(rest) = addr.strip_prefix('[')
66        && let Some(end) = rest.find(']')
67    {
68        let host = &rest[..end];
69        return host == "::1";
70    }
71    // Plain `host:port` — split on the rightmost ':'.
72    let host = match addr.rsplit_once(':') {
73        Some((h, _)) => h,
74        None => addr,
75    };
76    host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" || host.starts_with("127.")
77}
78
79/// Derive the key file path from a config file path.
80pub fn key_file_path(config_path: &Path) -> PathBuf {
81    config_path
82        .parent()
83        .unwrap_or(Path::new("."))
84        .join(KEY_FILENAME)
85}
86
87/// Derive the public key file path from a config file path.
88pub fn pub_file_path(config_path: &Path) -> PathBuf {
89    config_path
90        .parent()
91        .unwrap_or(Path::new("."))
92        .join(PUB_FILENAME)
93}
94
95/// Default control socket path for fipsctl / fipstop.
96///
97/// On Unix, checks the system-wide path first (used when the daemon runs as
98/// a systemd service), then falls back to the user's XDG runtime directory.
99/// On Windows, returns the default TCP port ("21210").
100pub fn default_control_path() -> PathBuf {
101    #[cfg(unix)]
102    {
103        if Path::new("/run/fips").exists() {
104            PathBuf::from("/run/fips/control.sock")
105        } else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
106            PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
107        } else {
108            PathBuf::from("/tmp/fips-control.sock")
109        }
110    }
111    #[cfg(windows)]
112    {
113        PathBuf::from("21210")
114    }
115}
116
117/// Default gateway control socket path.
118///
119/// On Unix, follows the same pattern as the main control socket.
120/// On Windows, returns a placeholder TCP port ("21211").
121pub fn default_gateway_path() -> PathBuf {
122    #[cfg(unix)]
123    {
124        if Path::new("/run/fips").exists() {
125            PathBuf::from("/run/fips/gateway.sock")
126        } else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
127            PathBuf::from(format!("{runtime_dir}/fips/gateway.sock"))
128        } else {
129            PathBuf::from("/tmp/fips-gateway.sock")
130        }
131    }
132    #[cfg(windows)]
133    {
134        PathBuf::from("21211")
135    }
136}
137
138/// Read a bare bech32 nsec from a key file.
139pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
140    let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
141        path: path.to_path_buf(),
142        source: e,
143    })?;
144    let nsec = contents.trim().to_string();
145    if nsec.is_empty() {
146        return Err(ConfigError::EmptyKeyFile {
147            path: path.to_path_buf(),
148        });
149    }
150    Ok(nsec)
151}
152
153/// Write a bare bech32 nsec to a key file with restricted permissions.
154///
155/// On Unix, the file is created with mode 0600 (owner read/write only).
156/// On Windows, the file inherits default ACLs from the parent directory.
157pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
158    use std::io::Write;
159
160    let mut opts = std::fs::OpenOptions::new();
161    opts.write(true).create(true).truncate(true);
162
163    #[cfg(unix)]
164    {
165        use std::os::unix::fs::OpenOptionsExt;
166        opts.mode(0o600);
167    }
168
169    let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
170        path: path.to_path_buf(),
171        source: e,
172    })?;
173
174    file.write_all(nsec.as_bytes())
175        .map_err(|e| ConfigError::WriteKeyFile {
176            path: path.to_path_buf(),
177            source: e,
178        })?;
179    file.write_all(b"\n")
180        .map_err(|e| ConfigError::WriteKeyFile {
181            path: path.to_path_buf(),
182            source: e,
183        })?;
184    Ok(())
185}
186
187/// Write a bare bech32 npub to a public key file.
188///
189/// On Unix, the file is created with mode 0644 (owner read/write, others read).
190/// On Windows, the file inherits default ACLs from the parent directory.
191pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
192    use std::io::Write;
193
194    let mut opts = std::fs::OpenOptions::new();
195    opts.write(true).create(true).truncate(true);
196
197    #[cfg(unix)]
198    {
199        use std::os::unix::fs::OpenOptionsExt;
200        opts.mode(0o644);
201    }
202
203    let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
204        path: path.to_path_buf(),
205        source: e,
206    })?;
207
208    file.write_all(npub.as_bytes())
209        .map_err(|e| ConfigError::WriteKeyFile {
210            path: path.to_path_buf(),
211            source: e,
212        })?;
213    file.write_all(b"\n")
214        .map_err(|e| ConfigError::WriteKeyFile {
215            path: path.to_path_buf(),
216            source: e,
217        })?;
218    Ok(())
219}
220
221/// Resolve identity from config and key file.
222///
223/// Behavior depends on `node.identity.persistent`:
224///
225/// - **`persistent: false`** (default): generate a fresh ephemeral keypair
226///   every start. Key files are written for operator visibility but overwritten
227///   on each restart.
228///
229/// - **`persistent: true`**: use three-tier resolution:
230///   1. Explicit nsec in config — highest priority
231///   2. Persistent key file (`fips.key`) — reused across restarts
232///   3. Generate new — creates keypair, writes `fips.key` and `fips.pub`
233///
234/// - **`nsec` set explicitly**: always uses that, regardless of `persistent`.
235///
236/// Returns the nsec string (bech32 or hex) to be used for identity creation.
237pub fn resolve_identity(
238    config: &Config,
239    loaded_paths: &[PathBuf],
240) -> Result<ResolvedIdentity, ConfigError> {
241    use crate::encode_nsec;
242
243    // Explicit nsec in config always wins
244    if let Some(nsec) = &config.node.identity.nsec {
245        return Ok(ResolvedIdentity {
246            nsec: nsec.clone(),
247            source: IdentitySource::Config,
248        });
249    }
250
251    // Determine key file directory from loaded config paths
252    let config_ref = if let Some(path) = loaded_paths.last() {
253        path.clone()
254    } else {
255        Config::search_paths()
256            .first()
257            .cloned()
258            .unwrap_or_else(|| PathBuf::from("./fips.yaml"))
259    };
260    let key_path = key_file_path(&config_ref);
261    let pub_path = pub_file_path(&config_ref);
262
263    if config.node.identity.persistent {
264        // Persistent mode: load existing key file or generate-and-persist
265        if key_path.exists() {
266            let nsec = read_key_file(&key_path)?;
267            let identity = Identity::from_secret_str(&nsec)?;
268            let _ = write_pub_file(&pub_path, &identity.npub());
269            return Ok(ResolvedIdentity {
270                nsec,
271                source: IdentitySource::KeyFile(key_path),
272            });
273        }
274
275        // No key file yet — generate and persist
276        let identity = Identity::generate();
277        let nsec = encode_nsec(&identity.keypair().secret_key());
278        let npub = identity.npub();
279
280        if let Some(parent) = key_path.parent() {
281            let _ = std::fs::create_dir_all(parent);
282        }
283
284        match write_key_file(&key_path, &nsec) {
285            Ok(()) => {
286                let _ = write_pub_file(&pub_path, &npub);
287                Ok(ResolvedIdentity {
288                    nsec,
289                    source: IdentitySource::Generated(key_path),
290                })
291            }
292            Err(_) => Ok(ResolvedIdentity {
293                nsec,
294                source: IdentitySource::Ephemeral,
295            }),
296        }
297    } else {
298        // Ephemeral mode (default): fresh keypair every start, write key files
299        // for operator visibility
300        let identity = Identity::generate();
301        let nsec = encode_nsec(&identity.keypair().secret_key());
302        let npub = identity.npub();
303
304        if let Some(parent) = key_path.parent() {
305            let _ = std::fs::create_dir_all(parent);
306        }
307
308        let _ = write_key_file(&key_path, &nsec);
309        let _ = write_pub_file(&pub_path, &npub);
310
311        Ok(ResolvedIdentity {
312            nsec,
313            source: IdentitySource::Ephemeral,
314        })
315    }
316}
317
318/// Result of identity resolution.
319pub struct ResolvedIdentity {
320    /// The nsec string (bech32 or hex) for creating an Identity.
321    pub nsec: String,
322    /// Where the identity came from.
323    pub source: IdentitySource,
324}
325
326/// Where a resolved identity originated.
327pub enum IdentitySource {
328    /// From explicit nsec in config file.
329    Config,
330    /// Loaded from a persistent key file.
331    KeyFile(PathBuf),
332    /// Generated and saved to a new key file.
333    Generated(PathBuf),
334    /// Generated but could not be persisted.
335    Ephemeral,
336}
337
338/// Errors that can occur during configuration loading.
339#[derive(Debug, Error)]
340pub enum ConfigError {
341    #[error("failed to read config file {path}: {source}")]
342    ReadFile {
343        path: PathBuf,
344        source: std::io::Error,
345    },
346
347    #[error("failed to parse config file {path}: {source}")]
348    ParseYaml {
349        path: PathBuf,
350        source: serde_yaml::Error,
351    },
352
353    #[error("key file is empty: {path}")]
354    EmptyKeyFile { path: PathBuf },
355
356    #[error("failed to write key file {path}: {source}")]
357    WriteKeyFile {
358        path: PathBuf,
359        source: std::io::Error,
360    },
361
362    #[error("identity error: {0}")]
363    Identity(#[from] IdentityError),
364
365    #[error("invalid configuration: {0}")]
366    Validation(String),
367}
368
369/// Identity configuration (`node.identity.*`).
370#[derive(Debug, Clone, Default, Serialize, Deserialize)]
371pub struct IdentityConfig {
372    /// Secret key in nsec (bech32) or hex format (`node.identity.nsec`).
373    /// If not specified, a new keypair will be generated.
374    #[serde(default, skip_serializing_if = "Option::is_none")]
375    pub nsec: Option<String>,
376
377    /// Whether to persist the identity across restarts (`node.identity.persistent`).
378    /// When false (default), a fresh ephemeral keypair is generated each start.
379    /// When true, the key file is reused across restarts.
380    #[serde(default)]
381    pub persistent: bool,
382}
383
384/// Root configuration structure.
385#[derive(Debug, Clone, Default, Serialize, Deserialize)]
386pub struct Config {
387    /// Node configuration (`node.*`).
388    #[serde(default)]
389    pub node: NodeConfig,
390
391    /// TUN interface configuration (`tun.*`).
392    #[serde(default)]
393    pub tun: TunConfig,
394
395    /// DNS responder configuration (`dns.*`).
396    #[serde(default)]
397    pub dns: DnsConfig,
398
399    /// Transport instances (`transports.*`).
400    #[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
401    pub transports: TransportsConfig,
402
403    /// Static peers to connect to (`peers`).
404    #[serde(default, skip_serializing_if = "Vec::is_empty")]
405    pub peers: Vec<PeerConfig>,
406
407    /// Gateway configuration (`gateway`).
408    #[cfg(target_os = "linux")]
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub gateway: Option<GatewayConfig>,
411}
412
413impl Config {
414    /// Create a new empty configuration.
415    pub fn new() -> Self {
416        Self::default()
417    }
418
419    /// Load configuration from the standard search paths.
420    ///
421    /// Files are loaded in reverse priority order and merged:
422    /// 1. `/etc/fips/fips.yaml` (loaded first, lowest priority)
423    /// 2. `~/.config/fips/fips.yaml` (user config)
424    /// 3. `./fips.yaml` (loaded last, highest priority)
425    ///
426    /// Returns a tuple of (config, paths_loaded) where paths_loaded contains
427    /// the paths that were successfully loaded.
428    pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
429        let search_paths = Self::search_paths();
430        Self::load_from_paths(&search_paths)
431    }
432
433    /// Load configuration from specific paths.
434    ///
435    /// Paths are processed in order, with later paths overriding earlier ones.
436    pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
437        let mut config = Config::default();
438        let mut loaded_paths = Vec::new();
439
440        for path in paths {
441            if path.exists() {
442                let file_config = Self::load_file(path)?;
443                config.merge(file_config);
444                loaded_paths.push(path.clone());
445            }
446        }
447
448        Ok((config, loaded_paths))
449    }
450
451    /// Load configuration from a single file.
452    pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
453        let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
454            path: path.to_path_buf(),
455            source: e,
456        })?;
457
458        serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
459            path: path.to_path_buf(),
460            source: e,
461        })
462    }
463
464    /// Get the standard search paths in priority order (lowest to highest).
465    pub fn search_paths() -> Vec<PathBuf> {
466        let mut paths = Vec::new();
467
468        // System config (lowest priority)
469        paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
470
471        // User config directory
472        if let Some(config_dir) = dirs::config_dir() {
473            paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
474        }
475
476        // Home directory (legacy location)
477        if let Some(home_dir) = dirs::home_dir() {
478            paths.push(home_dir.join(".fips.yaml"));
479        }
480
481        // Current directory (highest priority)
482        paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
483
484        paths
485    }
486
487    /// Merge another configuration into this one.
488    ///
489    /// Values from `other` override values in `self` when present.
490    pub fn merge(&mut self, other: Config) {
491        // Merge node.identity section
492        if other.node.identity.nsec.is_some() {
493            self.node.identity.nsec = other.node.identity.nsec;
494        }
495        if other.node.identity.persistent {
496            self.node.identity.persistent = true;
497        }
498        // Merge node.leaf_only
499        if other.node.leaf_only {
500            self.node.leaf_only = true;
501        }
502        // Merge tun section
503        if other.tun.enabled {
504            self.tun.enabled = true;
505        }
506        if other.tun.name.is_some() {
507            self.tun.name = other.tun.name;
508        }
509        if other.tun.mtu.is_some() {
510            self.tun.mtu = other.tun.mtu;
511        }
512        // Merge dns section — higher-priority config always wins for enabled
513        self.dns.enabled = other.dns.enabled;
514        if other.dns.bind_addr.is_some() {
515            self.dns.bind_addr = other.dns.bind_addr;
516        }
517        if other.dns.port.is_some() {
518            self.dns.port = other.dns.port;
519        }
520        if other.dns.ttl.is_some() {
521            self.dns.ttl = other.dns.ttl;
522        }
523        // Merge transports section
524        self.transports.merge(other.transports);
525        // Merge peers (replace if non-empty)
526        if !other.peers.is_empty() {
527            self.peers = other.peers;
528        }
529        // Merge gateway section — higher-priority config replaces entirely
530        #[cfg(target_os = "linux")]
531        if other.gateway.is_some() {
532            self.gateway = other.gateway;
533        }
534    }
535
536    /// Create an Identity from this configuration.
537    ///
538    /// If an nsec is configured, uses that to create the identity.
539    /// Otherwise, generates a new random identity.
540    pub fn create_identity(&self) -> Result<Identity, ConfigError> {
541        match &self.node.identity.nsec {
542            Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
543            None => Ok(Identity::generate()),
544        }
545    }
546
547    /// Check if an identity is configured (vs. will be generated).
548    pub fn has_identity(&self) -> bool {
549        self.node.identity.nsec.is_some()
550    }
551
552    /// Check if leaf-only mode is configured.
553    pub fn is_leaf_only(&self) -> bool {
554        self.node.leaf_only
555    }
556
557    /// Get the configured peers.
558    pub fn peers(&self) -> &[PeerConfig] {
559        &self.peers
560    }
561
562    /// Get peers that should auto-connect on startup.
563    pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
564        self.peers.iter().filter(|p| p.is_auto_connect())
565    }
566
567    /// Validate cross-field configuration invariants.
568    pub fn validate(&self) -> Result<(), ConfigError> {
569        let nostr = &self.node.discovery.nostr;
570
571        let any_transport_advertises_on_nostr = self
572            .transports
573            .udp
574            .iter()
575            .any(|(_, cfg)| cfg.advertise_on_nostr())
576            || self
577                .transports
578                .tcp
579                .iter()
580                .any(|(_, cfg)| cfg.advertise_on_nostr())
581            || self
582                .transports
583                .tor
584                .iter()
585                .any(|(_, cfg)| cfg.advertise_on_nostr());
586
587        if any_transport_advertises_on_nostr && !nostr.enabled {
588            return Err(ConfigError::Validation(
589                "at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
590            ));
591        }
592
593        for (i, peer) in self.peers.iter().enumerate() {
594            if peer.addresses.is_empty() && !nostr.enabled {
595                return Err(ConfigError::Validation(format!(
596                    "peers[{i}] ({}): must specify at least one address, or enable `node.discovery.nostr` to resolve endpoints from Nostr adverts",
597                    peer.npub
598                )));
599            }
600        }
601
602        let has_nat_udp_advert = self
603            .transports
604            .udp
605            .iter()
606            .any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
607
608        if nostr.enabled && has_nat_udp_advert {
609            if nostr.dm_relays.is_empty() {
610                return Err(ConfigError::Validation(
611                    "NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
612                ));
613            }
614            if nostr.stun_servers.is_empty() {
615                return Err(ConfigError::Validation(
616                    "NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
617                ));
618            }
619        }
620
621        // Reject loopback UDP bind combined with non-loopback peer addresses.
622        // Linux pins the source IP to a loopback-bound socket, so packets
623        // sent from such a socket to external peers are dropped at the
624        // routing layer with no clear error in the daemon log. See
625        // ISSUE-2026-0005. Outbound-only mode is exempt because it
626        // overrides bind_addr to 0.0.0.0:0 (kernel-picked source).
627        for (name, cfg) in self.transports.udp.iter() {
628            if cfg.outbound_only() {
629                continue;
630            }
631            if is_loopback_addr_str(cfg.bind_addr()) {
632                let any_external_peer = self.peers.iter().any(|peer| {
633                    peer.addresses
634                        .iter()
635                        .any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
636                });
637                if any_external_peer {
638                    let label = name.unwrap_or("(unnamed)");
639                    return Err(ConfigError::Validation(format!(
640                        "transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
641                         fips cannot reach external peers from a loopback-bound socket. \
642                         Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
643                        cfg.bind_addr()
644                    )));
645                }
646            }
647        }
648
649        Ok(())
650    }
651
652    /// Serialize this configuration to YAML.
653    pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
654        serde_yaml::to_string(self)
655    }
656}
657
658#[cfg(test)]
659mod tests {
660    use super::*;
661    use std::collections::HashMap;
662    use std::fs;
663    use tempfile::TempDir;
664
665    #[test]
666    fn test_empty_config() {
667        let config = Config::new();
668        assert!(config.node.identity.nsec.is_none());
669        assert!(!config.has_identity());
670    }
671
672    #[test]
673    fn test_parse_yaml_with_nsec() {
674        let yaml = r#"
675node:
676  identity:
677    nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
678"#;
679        let config: Config = serde_yaml::from_str(yaml).unwrap();
680        assert!(config.node.identity.nsec.is_some());
681        assert!(config.has_identity());
682    }
683
684    #[test]
685    fn test_parse_yaml_with_hex() {
686        let yaml = r#"
687node:
688  identity:
689    nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
690"#;
691        let config: Config = serde_yaml::from_str(yaml).unwrap();
692        assert!(config.node.identity.nsec.is_some());
693
694        let identity = config.create_identity().unwrap();
695        assert!(!identity.npub().is_empty());
696    }
697
698    #[test]
699    fn test_parse_yaml_empty() {
700        let yaml = "";
701        let config: Config = serde_yaml::from_str(yaml).unwrap();
702        assert!(config.node.identity.nsec.is_none());
703    }
704
705    #[test]
706    fn test_parse_yaml_partial() {
707        let yaml = r#"
708node:
709  identity: {}
710"#;
711        let config: Config = serde_yaml::from_str(yaml).unwrap();
712        assert!(config.node.identity.nsec.is_none());
713    }
714
715    #[test]
716    fn test_merge_configs() {
717        let mut base = Config::new();
718        base.node.identity.nsec = Some("base_nsec".to_string());
719
720        let mut override_config = Config::new();
721        override_config.node.identity.nsec = Some("override_nsec".to_string());
722
723        base.merge(override_config);
724        assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
725    }
726
727    #[test]
728    fn test_merge_preserves_base_when_override_empty() {
729        let mut base = Config::new();
730        base.node.identity.nsec = Some("base_nsec".to_string());
731
732        let override_config = Config::new();
733
734        base.merge(override_config);
735        assert_eq!(base.node.identity.nsec, Some("base_nsec".to_string()));
736    }
737
738    #[test]
739    fn test_create_identity_from_nsec() {
740        let mut config = Config::new();
741        config.node.identity.nsec =
742            Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
743
744        let identity = config.create_identity().unwrap();
745        assert!(!identity.npub().is_empty());
746    }
747
748    #[test]
749    fn test_create_identity_generates_new() {
750        let config = Config::new();
751        let identity = config.create_identity().unwrap();
752        assert!(!identity.npub().is_empty());
753    }
754
755    #[test]
756    fn test_load_from_file() {
757        let temp_dir = TempDir::new().unwrap();
758        let config_path = temp_dir.path().join("fips.yaml");
759
760        let yaml = r#"
761node:
762  identity:
763    nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
764"#;
765        fs::write(&config_path, yaml).unwrap();
766
767        let config = Config::load_file(&config_path).unwrap();
768        assert!(config.node.identity.nsec.is_some());
769    }
770
771    #[test]
772    fn test_load_from_paths_merges() {
773        let temp_dir = TempDir::new().unwrap();
774
775        // Create two config files
776        let low_priority = temp_dir.path().join("low.yaml");
777        let high_priority = temp_dir.path().join("high.yaml");
778
779        fs::write(
780            &low_priority,
781            r#"
782node:
783  identity:
784    nsec: "low_priority_nsec"
785"#,
786        )
787        .unwrap();
788
789        fs::write(
790            &high_priority,
791            r#"
792node:
793  identity:
794    nsec: "high_priority_nsec"
795"#,
796        )
797        .unwrap();
798
799        let paths = vec![low_priority.clone(), high_priority.clone()];
800        let (config, loaded) = Config::load_from_paths(&paths).unwrap();
801
802        assert_eq!(loaded.len(), 2);
803        assert_eq!(
804            config.node.identity.nsec,
805            Some("high_priority_nsec".to_string())
806        );
807    }
808
809    #[test]
810    fn test_load_skips_missing_files() {
811        let temp_dir = TempDir::new().unwrap();
812        let existing = temp_dir.path().join("exists.yaml");
813        let missing = temp_dir.path().join("missing.yaml");
814
815        fs::write(
816            &existing,
817            r#"
818node:
819  identity:
820    nsec: "existing_nsec"
821"#,
822        )
823        .unwrap();
824
825        let paths = vec![missing, existing.clone()];
826        let (config, loaded) = Config::load_from_paths(&paths).unwrap();
827
828        assert_eq!(loaded.len(), 1);
829        assert_eq!(loaded[0], existing);
830        assert_eq!(config.node.identity.nsec, Some("existing_nsec".to_string()));
831    }
832
833    #[test]
834    fn test_search_paths_includes_expected() {
835        let paths = Config::search_paths();
836
837        // Should include current directory
838        assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
839
840        // Should include /etc/fips on Unix
841        #[cfg(unix)]
842        assert!(
843            paths
844                .iter()
845                .any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
846        );
847    }
848
849    #[test]
850    fn test_to_yaml() {
851        let mut config = Config::new();
852        config.node.identity.nsec = Some("test_nsec".to_string());
853
854        let yaml = config.to_yaml().unwrap();
855        assert!(yaml.contains("node:"));
856        assert!(yaml.contains("identity:"));
857        assert!(yaml.contains("nsec:"));
858        assert!(yaml.contains("test_nsec"));
859    }
860
861    #[test]
862    fn test_key_file_write_read_roundtrip() {
863        let temp_dir = TempDir::new().unwrap();
864        let key_path = temp_dir.path().join("fips.key");
865
866        let identity = crate::Identity::generate();
867        let nsec = crate::encode_nsec(&identity.keypair().secret_key());
868
869        write_key_file(&key_path, &nsec).unwrap();
870
871        let loaded_nsec = read_key_file(&key_path).unwrap();
872        assert_eq!(loaded_nsec, nsec);
873
874        // Verify the loaded nsec produces the same identity
875        let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
876        assert_eq!(loaded_identity.npub(), identity.npub());
877    }
878
879    #[cfg(unix)]
880    #[test]
881    fn test_key_file_permissions() {
882        use std::os::unix::fs::MetadataExt;
883
884        let temp_dir = TempDir::new().unwrap();
885        let key_path = temp_dir.path().join("fips.key");
886
887        write_key_file(&key_path, "nsec1test").unwrap();
888
889        let metadata = fs::metadata(&key_path).unwrap();
890        assert_eq!(metadata.mode() & 0o777, 0o600);
891    }
892
893    #[cfg(unix)]
894    #[test]
895    fn test_pub_file_permissions() {
896        use std::os::unix::fs::MetadataExt;
897
898        let temp_dir = TempDir::new().unwrap();
899        let pub_path = temp_dir.path().join("fips.pub");
900
901        write_pub_file(&pub_path, "npub1test").unwrap();
902
903        let metadata = fs::metadata(&pub_path).unwrap();
904        assert_eq!(metadata.mode() & 0o777, 0o644);
905    }
906
907    #[test]
908    fn test_key_file_empty_error() {
909        let temp_dir = TempDir::new().unwrap();
910        let key_path = temp_dir.path().join("fips.key");
911
912        fs::write(&key_path, "").unwrap();
913
914        let result = read_key_file(&key_path);
915        assert!(result.is_err());
916        assert!(result.unwrap_err().to_string().contains("empty"));
917    }
918
919    #[test]
920    fn test_key_file_whitespace_trimmed() {
921        let temp_dir = TempDir::new().unwrap();
922        let key_path = temp_dir.path().join("fips.key");
923
924        fs::write(&key_path, "  nsec1test  \n").unwrap();
925
926        let nsec = read_key_file(&key_path).unwrap();
927        assert_eq!(nsec, "nsec1test");
928    }
929
930    #[test]
931    fn test_key_file_path_derivation() {
932        let config_path = PathBuf::from("/etc/fips/fips.yaml");
933        assert_eq!(
934            key_file_path(&config_path),
935            PathBuf::from("/etc/fips/fips.key")
936        );
937        assert_eq!(
938            pub_file_path(&config_path),
939            PathBuf::from("/etc/fips/fips.pub")
940        );
941    }
942
943    #[cfg(windows)]
944    #[test]
945    fn test_key_file_write_read_roundtrip_windows() {
946        let temp_dir = TempDir::new().unwrap();
947        let key_path = temp_dir.path().join("fips.key");
948
949        let identity = crate::Identity::generate();
950        let nsec = crate::encode_nsec(&identity.keypair().secret_key());
951
952        write_key_file(&key_path, &nsec).unwrap();
953
954        // Verify file was created and can be read back
955        let loaded_nsec = read_key_file(&key_path).unwrap();
956        assert_eq!(loaded_nsec, nsec);
957
958        // Verify the loaded nsec produces the same identity
959        let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
960        assert_eq!(loaded_identity.npub(), identity.npub());
961    }
962
963    #[test]
964    fn test_resolve_identity_from_config() {
965        let mut config = Config::new();
966        config.node.identity.nsec =
967            Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
968
969        let resolved = resolve_identity(&config, &[]).unwrap();
970        assert!(matches!(resolved.source, IdentitySource::Config));
971    }
972
973    #[test]
974    fn test_resolve_identity_ephemeral_by_default() {
975        let temp_dir = TempDir::new().unwrap();
976        let config_path = temp_dir.path().join("fips.yaml");
977
978        fs::write(&config_path, "node:\n  identity: {}\n").unwrap();
979
980        let config = Config::load_file(&config_path).unwrap();
981        assert!(!config.node.identity.persistent);
982
983        let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
984        assert!(matches!(resolved.source, IdentitySource::Ephemeral));
985
986        // Key files should still be written for operator visibility
987        let key_path = temp_dir.path().join("fips.key");
988        let pub_path = temp_dir.path().join("fips.pub");
989        assert!(key_path.exists());
990        assert!(pub_path.exists());
991    }
992
993    #[test]
994    fn test_resolve_identity_ephemeral_changes_each_call() {
995        let temp_dir = TempDir::new().unwrap();
996        let config_path = temp_dir.path().join("fips.yaml");
997
998        fs::write(&config_path, "node:\n  identity: {}\n").unwrap();
999
1000        let config = Config::load_file(&config_path).unwrap();
1001        let first = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1002        let second = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1003
1004        // Each call generates a different key
1005        assert_ne!(first.nsec, second.nsec);
1006    }
1007
1008    #[test]
1009    fn test_resolve_identity_persistent_from_key_file() {
1010        let temp_dir = TempDir::new().unwrap();
1011        let config_path = temp_dir.path().join("fips.yaml");
1012        let key_path = temp_dir.path().join("fips.key");
1013
1014        fs::write(&config_path, "node:\n  identity:\n    persistent: true\n").unwrap();
1015
1016        // Write a key file
1017        let identity = crate::Identity::generate();
1018        let nsec = crate::encode_nsec(&identity.keypair().secret_key());
1019        write_key_file(&key_path, &nsec).unwrap();
1020
1021        let config = Config::load_file(&config_path).unwrap();
1022        assert!(config.node.identity.persistent);
1023
1024        let resolved = resolve_identity(&config, &[config_path]).unwrap();
1025        assert!(matches!(resolved.source, IdentitySource::KeyFile(_)));
1026        assert_eq!(resolved.nsec, nsec);
1027    }
1028
1029    #[test]
1030    fn test_resolve_identity_persistent_generates_and_persists() {
1031        let temp_dir = TempDir::new().unwrap();
1032        let config_path = temp_dir.path().join("fips.yaml");
1033
1034        fs::write(&config_path, "node:\n  identity:\n    persistent: true\n").unwrap();
1035
1036        let config = Config::load_file(&config_path).unwrap();
1037        let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1038
1039        assert!(matches!(resolved.source, IdentitySource::Generated(_)));
1040
1041        // Key file and pub file should now exist
1042        let key_path = temp_dir.path().join("fips.key");
1043        let pub_path = temp_dir.path().join("fips.pub");
1044        assert!(key_path.exists());
1045        assert!(pub_path.exists());
1046
1047        // Second resolve should load from key file (not generate new)
1048        let resolved2 = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
1049        assert!(matches!(resolved2.source, IdentitySource::KeyFile(_)));
1050        assert_eq!(resolved.nsec, resolved2.nsec);
1051    }
1052
1053    #[test]
1054    fn test_to_yaml_empty_nsec_omitted() {
1055        let config = Config::new();
1056        let yaml = config.to_yaml().unwrap();
1057
1058        // Empty nsec should not be serialized
1059        assert!(!yaml.contains("nsec:"));
1060    }
1061
1062    #[test]
1063    fn test_parse_transport_single_instance() {
1064        let yaml = r#"
1065transports:
1066  udp:
1067    bind_addr: "0.0.0.0:2121"
1068    mtu: 1400
1069"#;
1070        let config: Config = serde_yaml::from_str(yaml).unwrap();
1071
1072        assert_eq!(config.transports.udp.len(), 1);
1073        let instances: Vec<_> = config.transports.udp.iter().collect();
1074        assert_eq!(instances.len(), 1);
1075        assert_eq!(instances[0].0, None); // Single instance has no name
1076        assert_eq!(instances[0].1.bind_addr(), "0.0.0.0:2121");
1077        assert_eq!(instances[0].1.mtu(), 1400);
1078    }
1079
1080    #[test]
1081    fn test_parse_transport_named_instances() {
1082        let yaml = r#"
1083transports:
1084  udp:
1085    main:
1086      bind_addr: "0.0.0.0:2121"
1087    backup:
1088      bind_addr: "192.168.1.100:2122"
1089      mtu: 1280
1090"#;
1091        let config: Config = serde_yaml::from_str(yaml).unwrap();
1092
1093        assert_eq!(config.transports.udp.len(), 2);
1094
1095        let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
1096
1097        // Named instances have Some(name)
1098        assert!(instances.contains_key(&Some("main")));
1099        assert!(instances.contains_key(&Some("backup")));
1100        assert_eq!(instances[&Some("main")].bind_addr(), "0.0.0.0:2121");
1101        assert_eq!(instances[&Some("backup")].bind_addr(), "192.168.1.100:2122");
1102        assert_eq!(instances[&Some("backup")].mtu(), 1280);
1103    }
1104
1105    #[test]
1106    fn test_parse_transport_empty() {
1107        let yaml = r#"
1108transports: {}
1109"#;
1110        let config: Config = serde_yaml::from_str(yaml).unwrap();
1111        assert!(config.transports.udp.is_empty());
1112        assert!(config.transports.is_empty());
1113    }
1114
1115    #[test]
1116    fn test_transport_instances_iter() {
1117        // Single instance - no name
1118        let single = TransportInstances::Single(UdpConfig {
1119            bind_addr: Some("0.0.0.0:2121".to_string()),
1120            mtu: None,
1121            ..Default::default()
1122        });
1123        let items: Vec<_> = single.iter().collect();
1124        assert_eq!(items.len(), 1);
1125        assert_eq!(items[0].0, None);
1126
1127        // Named instances - have names
1128        let mut map = HashMap::new();
1129        map.insert("a".to_string(), UdpConfig::default());
1130        map.insert("b".to_string(), UdpConfig::default());
1131        let named = TransportInstances::Named(map);
1132        let items: Vec<_> = named.iter().collect();
1133        assert_eq!(items.len(), 2);
1134        // All named instances should have Some(name)
1135        assert!(items.iter().all(|(name, _)| name.is_some()));
1136    }
1137
1138    #[test]
1139    fn test_parse_peer_config() {
1140        let yaml = r#"
1141peers:
1142  - npub: "npub1abc123"
1143    alias: "gateway"
1144    addresses:
1145      - transport: udp
1146        addr: "192.168.1.1:2121"
1147        priority: 1
1148      - transport: tor
1149        addr: "xyz.onion:2121"
1150        priority: 2
1151    connect_policy: auto_connect
1152"#;
1153        let config: Config = serde_yaml::from_str(yaml).unwrap();
1154
1155        assert_eq!(config.peers.len(), 1);
1156        let peer = &config.peers[0];
1157        assert_eq!(peer.npub, "npub1abc123");
1158        assert_eq!(peer.alias, Some("gateway".to_string()));
1159        assert_eq!(peer.addresses.len(), 2);
1160        assert!(peer.is_auto_connect());
1161
1162        // Check addresses are sorted by priority
1163        let sorted = peer.addresses_by_priority();
1164        assert_eq!(sorted[0].transport, "udp");
1165        assert_eq!(sorted[0].priority, 1);
1166        assert_eq!(sorted[1].transport, "tor");
1167        assert_eq!(sorted[1].priority, 2);
1168    }
1169
1170    #[test]
1171    fn test_parse_peer_minimal() {
1172        let yaml = r#"
1173peers:
1174  - npub: "npub1xyz"
1175    addresses:
1176      - transport: udp
1177        addr: "10.0.0.1:2121"
1178"#;
1179        let config: Config = serde_yaml::from_str(yaml).unwrap();
1180
1181        assert_eq!(config.peers.len(), 1);
1182        let peer = &config.peers[0];
1183        assert_eq!(peer.npub, "npub1xyz");
1184        assert!(peer.alias.is_none());
1185        // Default connect_policy is auto_connect
1186        assert!(peer.is_auto_connect());
1187        // Default priority is 100
1188        assert_eq!(peer.addresses[0].priority, 100);
1189    }
1190
1191    #[test]
1192    fn test_parse_multiple_peers() {
1193        let yaml = r#"
1194peers:
1195  - npub: "npub1peer1"
1196    addresses:
1197      - transport: udp
1198        addr: "10.0.0.1:2121"
1199  - npub: "npub1peer2"
1200    addresses:
1201      - transport: udp
1202        addr: "10.0.0.2:2121"
1203    connect_policy: on_demand
1204"#;
1205        let config: Config = serde_yaml::from_str(yaml).unwrap();
1206
1207        assert_eq!(config.peers.len(), 2);
1208        assert_eq!(config.auto_connect_peers().count(), 1);
1209    }
1210
1211    #[test]
1212    fn test_peer_config_builder() {
1213        let peer = PeerConfig::new("npub1test", "udp", "192.168.1.1:2121")
1214            .with_alias("test-peer")
1215            .with_address(PeerAddress::with_priority("tor", "xyz.onion:2121", 50));
1216
1217        assert_eq!(peer.npub, "npub1test");
1218        assert_eq!(peer.alias, Some("test-peer".to_string()));
1219        assert_eq!(peer.addresses.len(), 2);
1220        assert!(peer.is_auto_connect());
1221    }
1222
1223    #[test]
1224    fn test_parse_nostr_discovery_config() {
1225        let yaml = r#"
1226node:
1227  discovery:
1228    nostr:
1229      enabled: true
1230      advertise: false
1231      policy: configured_only
1232      open_discovery_max_pending: 12
1233      app: "fips.nat.test.v1"
1234      signal_ttl_secs: 45
1235      advert_relays:
1236        - "wss://relay-a.example"
1237      dm_relays:
1238        - "wss://relay-b.example"
1239      stun_servers:
1240        - "stun:stun.example.org:3478"
1241peers:
1242  - npub: "npub1peer"
1243    addresses:
1244      - transport: udp
1245        addr: "nat"
1246"#;
1247        let config: Config = serde_yaml::from_str(yaml).unwrap();
1248        assert!(config.node.discovery.nostr.enabled);
1249        assert!(!config.node.discovery.nostr.advertise);
1250        assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
1251        assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
1252        assert_eq!(
1253            config.node.discovery.nostr.policy,
1254            NostrDiscoveryPolicy::ConfiguredOnly
1255        );
1256        assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
1257        assert_eq!(
1258            config.node.discovery.nostr.advert_relays,
1259            vec!["wss://relay-a.example".to_string()]
1260        );
1261        assert_eq!(
1262            config.node.discovery.nostr.dm_relays,
1263            vec!["wss://relay-b.example".to_string()]
1264        );
1265        assert_eq!(
1266            config.node.discovery.nostr.stun_servers,
1267            vec!["stun:stun.example.org:3478".to_string()]
1268        );
1269        assert_eq!(
1270            config.peers[0].addresses[0].addr, "nat",
1271            "udp:nat address should parse without special-casing in YAML"
1272        );
1273    }
1274
1275    #[test]
1276    fn test_validate_transport_advert_requires_nostr_enabled() {
1277        let mut config = Config::default();
1278        config.transports.udp = TransportInstances::Single(UdpConfig {
1279            advertise_on_nostr: Some(true),
1280            ..Default::default()
1281        });
1282        config.node.discovery.nostr.enabled = false;
1283
1284        let err = config.validate().expect_err("validation should fail");
1285        assert!(err.to_string().contains("advertise_on_nostr"));
1286    }
1287
1288    #[test]
1289    fn test_validate_empty_peer_addresses_require_nostr_enabled() {
1290        let mut config = Config {
1291            peers: vec![PeerConfig {
1292                npub: "npub1peer".to_string(),
1293                ..Default::default()
1294            }],
1295            ..Default::default()
1296        };
1297        config.node.discovery.nostr.enabled = false;
1298
1299        let err = config.validate().expect_err("validation should fail");
1300        assert!(err.to_string().contains("node.discovery.nostr"));
1301    }
1302
1303    #[test]
1304    fn test_validate_peer_addresses_optional_with_nostr_enabled() {
1305        // Empty addresses + Nostr discovery disabled -> error.
1306        let mut config = Config {
1307            peers: vec![PeerConfig {
1308                npub: "npub1peer".to_string(),
1309                ..Default::default()
1310            }],
1311            ..Default::default()
1312        };
1313        let err = config.validate().expect_err("validation should fail");
1314        assert!(err.to_string().contains("at least one address"));
1315
1316        // Empty addresses + Nostr discovery enabled -> ok.
1317        config.node.discovery.nostr.enabled = true;
1318        config
1319            .validate()
1320            .expect("Nostr discovery should allow empty addresses");
1321    }
1322
1323    #[test]
1324    fn test_validate_nat_udp_advert_requires_relays_and_stun() {
1325        let mut config = Config::default();
1326        config.node.discovery.nostr.enabled = true;
1327        config.node.discovery.nostr.dm_relays.clear();
1328        config.transports.udp = TransportInstances::Single(UdpConfig {
1329            advertise_on_nostr: Some(true),
1330            public: Some(false),
1331            ..Default::default()
1332        });
1333
1334        let err = config.validate().expect_err("validation should fail");
1335        assert!(err.to_string().contains("dm_relays"));
1336
1337        config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
1338        config.node.discovery.nostr.stun_servers.clear();
1339        let err = config.validate().expect_err("validation should fail");
1340        assert!(err.to_string().contains("stun_servers"));
1341    }
1342
1343    #[test]
1344    fn test_is_loopback_addr_str() {
1345        assert!(is_loopback_addr_str("127.0.0.1:2121"));
1346        assert!(is_loopback_addr_str("127.0.0.5:9999"));
1347        assert!(is_loopback_addr_str("[::1]:2121"));
1348        assert!(is_loopback_addr_str("::1:2121"));
1349        assert!(is_loopback_addr_str("localhost:80"));
1350        assert!(!is_loopback_addr_str("0.0.0.0:2121"));
1351        assert!(!is_loopback_addr_str("192.168.1.1:2121"));
1352        assert!(!is_loopback_addr_str("[fd00::1]:2121"));
1353        assert!(!is_loopback_addr_str("core-vm.tail65015.ts.net:2121"));
1354        assert!(!is_loopback_addr_str("example.com:443"));
1355    }
1356
1357    #[test]
1358    fn test_validate_loopback_bind_with_external_peer_rejected() {
1359        use crate::config::PeerAddress;
1360        let mut config = Config::default();
1361        config.transports.udp = TransportInstances::Single(UdpConfig {
1362            bind_addr: Some("127.0.0.1:2121".to_string()),
1363            ..Default::default()
1364        });
1365        config.peers = vec![PeerConfig {
1366            npub: "npub1peer".to_string(),
1367            addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1368            ..Default::default()
1369        }];
1370
1371        let err = config.validate().expect_err("validation should fail");
1372        let msg = err.to_string();
1373        assert!(msg.contains("loopback"), "got: {msg}");
1374        assert!(msg.contains("non-loopback"), "got: {msg}");
1375    }
1376
1377    #[test]
1378    fn test_validate_loopback_bind_with_loopback_peer_ok() {
1379        use crate::config::PeerAddress;
1380        let mut config = Config::default();
1381        config.transports.udp = TransportInstances::Single(UdpConfig {
1382            bind_addr: Some("127.0.0.1:2121".to_string()),
1383            ..Default::default()
1384        });
1385        config.peers = vec![PeerConfig {
1386            npub: "npub1peer".to_string(),
1387            addresses: vec![PeerAddress::new("udp", "127.0.0.2:2121")],
1388            ..Default::default()
1389        }];
1390
1391        config
1392            .validate()
1393            .expect("loopback peer with loopback bind should validate");
1394    }
1395
1396    #[test]
1397    fn test_validate_outbound_only_exempt_from_loopback_check() {
1398        use crate::config::PeerAddress;
1399        let mut config = Config::default();
1400        // outbound_only overrides bind_addr → 0.0.0.0:0; the loopback
1401        // check must skip this transport entirely.
1402        config.transports.udp = TransportInstances::Single(UdpConfig {
1403            bind_addr: Some("127.0.0.1:2121".to_string()),
1404            outbound_only: Some(true),
1405            ..Default::default()
1406        });
1407        config.peers = vec![PeerConfig {
1408            npub: "npub1peer".to_string(),
1409            addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
1410            ..Default::default()
1411        }];
1412
1413        config
1414            .validate()
1415            .expect("outbound_only should be exempt from the loopback check");
1416    }
1417
1418    #[test]
1419    fn test_outbound_only_forces_ephemeral_bind() {
1420        let cfg = UdpConfig {
1421            bind_addr: Some("127.0.0.1:2121".to_string()),
1422            outbound_only: Some(true),
1423            ..Default::default()
1424        };
1425        assert_eq!(cfg.bind_addr(), "0.0.0.0:0");
1426        assert!(cfg.outbound_only());
1427    }
1428
1429    #[test]
1430    fn test_outbound_only_forces_advertise_off() {
1431        let cfg = UdpConfig {
1432            advertise_on_nostr: Some(true),
1433            outbound_only: Some(true),
1434            ..Default::default()
1435        };
1436        assert!(!cfg.advertise_on_nostr());
1437    }
1438
1439    #[test]
1440    fn test_udp_accept_connections_default_true() {
1441        let cfg = UdpConfig::default();
1442        assert!(cfg.accept_connections());
1443    }
1444}