#[cfg(target_os = "linux")]
mod gateway;
mod node;
mod peer;
mod transport;
use crate::upper::config::{DnsConfig, TunConfig};
use crate::{Identity, IdentityError};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[cfg(target_os = "linux")]
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
pub use node::{
BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
RetryConfig, RoutingConfig, RoutingMode, SessionConfig, SessionMmpConfig, TreeConfig,
};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
#[cfg(feature = "sim-transport")]
pub use transport::SimTransportConfig;
pub use transport::{
BleConfig, DirectoryServiceConfig, EthernetConfig, TcpConfig, TorConfig, TransportInstances,
TransportsConfig, UdpConfig,
};
const CONFIG_FILENAME: &str = "fips.yaml";
const KEY_FILENAME: &str = "fips.key";
const PUB_FILENAME: &str = "fips.pub";
fn is_loopback_addr_str(addr: &str) -> bool {
if let Some(rest) = addr.strip_prefix('[')
&& let Some(end) = rest.find(']')
{
let host = &rest[..end];
return host == "::1";
}
let host = match addr.rsplit_once(':') {
Some((h, _)) => h,
None => addr,
};
host == "localhost" || host == "::1" || host == "0:0:0:0:0:0:0:1" || host.starts_with("127.")
}
pub fn key_file_path(config_path: &Path) -> PathBuf {
config_path
.parent()
.unwrap_or(Path::new("."))
.join(KEY_FILENAME)
}
pub fn pub_file_path(config_path: &Path) -> PathBuf {
config_path
.parent()
.unwrap_or(Path::new("."))
.join(PUB_FILENAME)
}
pub fn default_control_path() -> PathBuf {
#[cfg(unix)]
{
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/control.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/control.sock"))
} else {
PathBuf::from("/tmp/fips-control.sock")
}
}
#[cfg(windows)]
{
PathBuf::from("21210")
}
}
pub fn default_gateway_path() -> PathBuf {
#[cfg(unix)]
{
if Path::new("/run/fips").exists() {
PathBuf::from("/run/fips/gateway.sock")
} else if let Ok(runtime_dir) = std::env::var("XDG_RUNTIME_DIR") {
PathBuf::from(format!("{runtime_dir}/fips/gateway.sock"))
} else {
PathBuf::from("/tmp/fips-gateway.sock")
}
}
#[cfg(windows)]
{
PathBuf::from("21211")
}
}
pub fn read_key_file(path: &Path) -> Result<String, ConfigError> {
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
path: path.to_path_buf(),
source: e,
})?;
let nsec = contents.trim().to_string();
if nsec.is_empty() {
return Err(ConfigError::EmptyKeyFile {
path: path.to_path_buf(),
});
}
Ok(nsec)
}
pub fn write_key_file(path: &Path, nsec: &str) -> Result<(), ConfigError> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(nsec.as_bytes())
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(b"\n")
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
pub fn write_pub_file(path: &Path, npub: &str) -> Result<(), ConfigError> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o644);
}
let mut file = opts.open(path).map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(npub.as_bytes())
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
file.write_all(b"\n")
.map_err(|e| ConfigError::WriteKeyFile {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
pub fn resolve_identity(
config: &Config,
loaded_paths: &[PathBuf],
) -> Result<ResolvedIdentity, ConfigError> {
use crate::encode_nsec;
if let Some(nsec) = &config.node.identity.nsec {
return Ok(ResolvedIdentity {
nsec: nsec.clone(),
source: IdentitySource::Config,
});
}
let config_ref = if let Some(path) = loaded_paths.last() {
path.clone()
} else {
Config::search_paths()
.first()
.cloned()
.unwrap_or_else(|| PathBuf::from("./fips.yaml"))
};
let key_path = key_file_path(&config_ref);
let pub_path = pub_file_path(&config_ref);
if config.node.identity.persistent {
if key_path.exists() {
let nsec = read_key_file(&key_path)?;
let identity = Identity::from_secret_str(&nsec)?;
let _ = write_pub_file(&pub_path, &identity.npub());
return Ok(ResolvedIdentity {
nsec,
source: IdentitySource::KeyFile(key_path),
});
}
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
if let Some(parent) = key_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match write_key_file(&key_path, &nsec) {
Ok(()) => {
let _ = write_pub_file(&pub_path, &npub);
Ok(ResolvedIdentity {
nsec,
source: IdentitySource::Generated(key_path),
})
}
Err(_) => Ok(ResolvedIdentity {
nsec,
source: IdentitySource::Ephemeral,
}),
}
} else {
let identity = Identity::generate();
let nsec = encode_nsec(&identity.keypair().secret_key());
let npub = identity.npub();
if let Some(parent) = key_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = write_key_file(&key_path, &nsec);
let _ = write_pub_file(&pub_path, &npub);
Ok(ResolvedIdentity {
nsec,
source: IdentitySource::Ephemeral,
})
}
}
pub struct ResolvedIdentity {
pub nsec: String,
pub source: IdentitySource,
}
pub enum IdentitySource {
Config,
KeyFile(PathBuf),
Generated(PathBuf),
Ephemeral,
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config file {path}: {source}")]
ReadFile {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse config file {path}: {source}")]
ParseYaml {
path: PathBuf,
source: serde_yaml::Error,
},
#[error("key file is empty: {path}")]
EmptyKeyFile { path: PathBuf },
#[error("failed to write key file {path}: {source}")]
WriteKeyFile {
path: PathBuf,
source: std::io::Error,
},
#[error("identity error: {0}")]
Identity(#[from] IdentityError),
#[error("invalid configuration: {0}")]
Validation(String),
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IdentityConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nsec: Option<String>,
#[serde(default)]
pub persistent: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
#[serde(default)]
pub node: NodeConfig,
#[serde(default)]
pub tun: TunConfig,
#[serde(default)]
pub dns: DnsConfig,
#[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
pub transports: TransportsConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peers: Vec<PeerConfig>,
#[cfg(target_os = "linux")]
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gateway: Option<GatewayConfig>,
}
impl Config {
pub fn new() -> Self {
Self::default()
}
pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
let search_paths = Self::search_paths();
Self::load_from_paths(&search_paths)
}
pub fn load_from_paths(paths: &[PathBuf]) -> Result<(Self, Vec<PathBuf>), ConfigError> {
let mut config = Config::default();
let mut loaded_paths = Vec::new();
for path in paths {
if path.exists() {
let file_config = Self::load_file(path)?;
config.merge(file_config);
loaded_paths.push(path.clone());
}
}
Ok((config, loaded_paths))
}
pub fn load_file(path: &Path) -> Result<Self, ConfigError> {
let contents = std::fs::read_to_string(path).map_err(|e| ConfigError::ReadFile {
path: path.to_path_buf(),
source: e,
})?;
serde_yaml::from_str(&contents).map_err(|e| ConfigError::ParseYaml {
path: path.to_path_buf(),
source: e,
})
}
pub fn search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
}
if let Some(home_dir) = dirs::home_dir() {
paths.push(home_dir.join(".fips.yaml"));
}
paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
paths
}
pub fn merge(&mut self, other: Config) {
if other.node.identity.nsec.is_some() {
self.node.identity.nsec = other.node.identity.nsec;
}
if other.node.identity.persistent {
self.node.identity.persistent = true;
}
if other.node.leaf_only {
self.node.leaf_only = true;
}
if other.tun.enabled {
self.tun.enabled = true;
}
if other.tun.name.is_some() {
self.tun.name = other.tun.name;
}
if other.tun.mtu.is_some() {
self.tun.mtu = other.tun.mtu;
}
self.dns.enabled = other.dns.enabled;
if other.dns.bind_addr.is_some() {
self.dns.bind_addr = other.dns.bind_addr;
}
if other.dns.port.is_some() {
self.dns.port = other.dns.port;
}
if other.dns.ttl.is_some() {
self.dns.ttl = other.dns.ttl;
}
self.transports.merge(other.transports);
if !other.peers.is_empty() {
self.peers = other.peers;
}
#[cfg(target_os = "linux")]
if other.gateway.is_some() {
self.gateway = other.gateway;
}
}
pub fn create_identity(&self) -> Result<Identity, ConfigError> {
match &self.node.identity.nsec {
Some(nsec) => Ok(Identity::from_secret_str(nsec)?),
None => Ok(Identity::generate()),
}
}
pub fn has_identity(&self) -> bool {
self.node.identity.nsec.is_some()
}
pub fn is_leaf_only(&self) -> bool {
self.node.leaf_only
}
pub fn peers(&self) -> &[PeerConfig] {
&self.peers
}
pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
self.peers.iter().filter(|p| p.is_auto_connect())
}
pub fn validate(&self) -> Result<(), ConfigError> {
let nostr = &self.node.discovery.nostr;
let any_transport_advertises_on_nostr = self
.transports
.udp
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr())
|| self
.transports
.tcp
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr())
|| self
.transports
.tor
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr());
if any_transport_advertises_on_nostr && !nostr.enabled {
return Err(ConfigError::Validation(
"at least one transport has `advertise_on_nostr = true`, but `node.discovery.nostr.enabled` is false".to_string(),
));
}
for (i, peer) in self.peers.iter().enumerate() {
if peer.addresses.is_empty() && !nostr.enabled {
return Err(ConfigError::Validation(format!(
"peers[{i}] ({}): must specify at least one address, or enable `node.discovery.nostr` to resolve endpoints from Nostr adverts",
peer.npub
)));
}
}
let has_nat_udp_advert = self
.transports
.udp
.iter()
.any(|(_, cfg)| cfg.advertise_on_nostr() && !cfg.is_public());
if nostr.enabled && has_nat_udp_advert {
if nostr.dm_relays.is_empty() {
return Err(ConfigError::Validation(
"NAT UDP advert publishing requires `node.discovery.nostr.dm_relays` to be non-empty".to_string(),
));
}
if nostr.stun_servers.is_empty() {
return Err(ConfigError::Validation(
"NAT UDP advert publishing requires `node.discovery.nostr.stun_servers` to be non-empty".to_string(),
));
}
}
for (name, cfg) in self.transports.udp.iter() {
if cfg.outbound_only() {
continue;
}
if is_loopback_addr_str(cfg.bind_addr()) {
let any_external_peer = self.peers.iter().any(|peer| {
peer.addresses
.iter()
.any(|a| a.transport == "udp" && !is_loopback_addr_str(&a.addr))
});
if any_external_peer {
let label = name.unwrap_or("(unnamed)");
return Err(ConfigError::Validation(format!(
"transports.udp[{label}].bind_addr is loopback ({}) but at least one peer has a non-loopback UDP address; \
fips cannot reach external peers from a loopback-bound socket. \
Use bind_addr: \"0.0.0.0:2121\" (with kernel-firewall hardening if exposure is a concern), or set outbound_only: true.",
cfg.bind_addr()
)));
}
}
}
Ok(())
}
pub fn to_yaml(&self) -> Result<String, serde_yaml::Error> {
serde_yaml::to_string(self)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_empty_config() {
let config = Config::new();
assert!(config.node.identity.nsec.is_none());
assert!(!config.has_identity());
}
#[test]
fn test_parse_yaml_with_nsec() {
let yaml = r#"
node:
identity:
nsec: nsec1qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxqszqg9qyqsqypqxfnm5g9
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.node.identity.nsec.is_some());
assert!(config.has_identity());
}
#[test]
fn test_parse_yaml_with_hex() {
let yaml = r#"
node:
identity:
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.node.identity.nsec.is_some());
let identity = config.create_identity().unwrap();
assert!(!identity.npub().is_empty());
}
#[test]
fn test_parse_yaml_empty() {
let yaml = "";
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.node.identity.nsec.is_none());
}
#[test]
fn test_parse_yaml_partial() {
let yaml = r#"
node:
identity: {}
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.node.identity.nsec.is_none());
}
#[test]
fn test_merge_configs() {
let mut base = Config::new();
base.node.identity.nsec = Some("base_nsec".to_string());
let mut override_config = Config::new();
override_config.node.identity.nsec = Some("override_nsec".to_string());
base.merge(override_config);
assert_eq!(base.node.identity.nsec, Some("override_nsec".to_string()));
}
#[test]
fn test_merge_preserves_base_when_override_empty() {
let mut base = Config::new();
base.node.identity.nsec = Some("base_nsec".to_string());
let override_config = Config::new();
base.merge(override_config);
assert_eq!(base.node.identity.nsec, Some("base_nsec".to_string()));
}
#[test]
fn test_create_identity_from_nsec() {
let mut config = Config::new();
config.node.identity.nsec =
Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
let identity = config.create_identity().unwrap();
assert!(!identity.npub().is_empty());
}
#[test]
fn test_create_identity_generates_new() {
let config = Config::new();
let identity = config.create_identity().unwrap();
assert!(!identity.npub().is_empty());
}
#[test]
fn test_load_from_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
let yaml = r#"
node:
identity:
nsec: "0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20"
"#;
fs::write(&config_path, yaml).unwrap();
let config = Config::load_file(&config_path).unwrap();
assert!(config.node.identity.nsec.is_some());
}
#[test]
fn test_load_from_paths_merges() {
let temp_dir = TempDir::new().unwrap();
let low_priority = temp_dir.path().join("low.yaml");
let high_priority = temp_dir.path().join("high.yaml");
fs::write(
&low_priority,
r#"
node:
identity:
nsec: "low_priority_nsec"
"#,
)
.unwrap();
fs::write(
&high_priority,
r#"
node:
identity:
nsec: "high_priority_nsec"
"#,
)
.unwrap();
let paths = vec![low_priority.clone(), high_priority.clone()];
let (config, loaded) = Config::load_from_paths(&paths).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(
config.node.identity.nsec,
Some("high_priority_nsec".to_string())
);
}
#[test]
fn test_load_skips_missing_files() {
let temp_dir = TempDir::new().unwrap();
let existing = temp_dir.path().join("exists.yaml");
let missing = temp_dir.path().join("missing.yaml");
fs::write(
&existing,
r#"
node:
identity:
nsec: "existing_nsec"
"#,
)
.unwrap();
let paths = vec![missing, existing.clone()];
let (config, loaded) = Config::load_from_paths(&paths).unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0], existing);
assert_eq!(config.node.identity.nsec, Some("existing_nsec".to_string()));
}
#[test]
fn test_search_paths_includes_expected() {
let paths = Config::search_paths();
assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
#[cfg(unix)]
assert!(
paths
.iter()
.any(|p| p.starts_with("/etc/fips") && p.ends_with("fips.yaml"))
);
}
#[test]
fn test_to_yaml() {
let mut config = Config::new();
config.node.identity.nsec = Some("test_nsec".to_string());
let yaml = config.to_yaml().unwrap();
assert!(yaml.contains("node:"));
assert!(yaml.contains("identity:"));
assert!(yaml.contains("nsec:"));
assert!(yaml.contains("test_nsec"));
}
#[test]
fn test_key_file_write_read_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
let identity = crate::Identity::generate();
let nsec = crate::encode_nsec(&identity.keypair().secret_key());
write_key_file(&key_path, &nsec).unwrap();
let loaded_nsec = read_key_file(&key_path).unwrap();
assert_eq!(loaded_nsec, nsec);
let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
assert_eq!(loaded_identity.npub(), identity.npub());
}
#[cfg(unix)]
#[test]
fn test_key_file_permissions() {
use std::os::unix::fs::MetadataExt;
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
write_key_file(&key_path, "nsec1test").unwrap();
let metadata = fs::metadata(&key_path).unwrap();
assert_eq!(metadata.mode() & 0o777, 0o600);
}
#[cfg(unix)]
#[test]
fn test_pub_file_permissions() {
use std::os::unix::fs::MetadataExt;
let temp_dir = TempDir::new().unwrap();
let pub_path = temp_dir.path().join("fips.pub");
write_pub_file(&pub_path, "npub1test").unwrap();
let metadata = fs::metadata(&pub_path).unwrap();
assert_eq!(metadata.mode() & 0o777, 0o644);
}
#[test]
fn test_key_file_empty_error() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
fs::write(&key_path, "").unwrap();
let result = read_key_file(&key_path);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("empty"));
}
#[test]
fn test_key_file_whitespace_trimmed() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
fs::write(&key_path, " nsec1test \n").unwrap();
let nsec = read_key_file(&key_path).unwrap();
assert_eq!(nsec, "nsec1test");
}
#[test]
fn test_key_file_path_derivation() {
let config_path = PathBuf::from("/etc/fips/fips.yaml");
assert_eq!(
key_file_path(&config_path),
PathBuf::from("/etc/fips/fips.key")
);
assert_eq!(
pub_file_path(&config_path),
PathBuf::from("/etc/fips/fips.pub")
);
}
#[cfg(windows)]
#[test]
fn test_key_file_write_read_roundtrip_windows() {
let temp_dir = TempDir::new().unwrap();
let key_path = temp_dir.path().join("fips.key");
let identity = crate::Identity::generate();
let nsec = crate::encode_nsec(&identity.keypair().secret_key());
write_key_file(&key_path, &nsec).unwrap();
let loaded_nsec = read_key_file(&key_path).unwrap();
assert_eq!(loaded_nsec, nsec);
let loaded_identity = crate::Identity::from_secret_str(&loaded_nsec).unwrap();
assert_eq!(loaded_identity.npub(), identity.npub());
}
#[test]
fn test_resolve_identity_from_config() {
let mut config = Config::new();
config.node.identity.nsec =
Some("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20".to_string());
let resolved = resolve_identity(&config, &[]).unwrap();
assert!(matches!(resolved.source, IdentitySource::Config));
}
#[test]
fn test_resolve_identity_ephemeral_by_default() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
let config = Config::load_file(&config_path).unwrap();
assert!(!config.node.identity.persistent);
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
assert!(matches!(resolved.source, IdentitySource::Ephemeral));
let key_path = temp_dir.path().join("fips.key");
let pub_path = temp_dir.path().join("fips.pub");
assert!(key_path.exists());
assert!(pub_path.exists());
}
#[test]
fn test_resolve_identity_ephemeral_changes_each_call() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
fs::write(&config_path, "node:\n identity: {}\n").unwrap();
let config = Config::load_file(&config_path).unwrap();
let first = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
let second = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
assert_ne!(first.nsec, second.nsec);
}
#[test]
fn test_resolve_identity_persistent_from_key_file() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
let key_path = temp_dir.path().join("fips.key");
fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
let identity = crate::Identity::generate();
let nsec = crate::encode_nsec(&identity.keypair().secret_key());
write_key_file(&key_path, &nsec).unwrap();
let config = Config::load_file(&config_path).unwrap();
assert!(config.node.identity.persistent);
let resolved = resolve_identity(&config, &[config_path]).unwrap();
assert!(matches!(resolved.source, IdentitySource::KeyFile(_)));
assert_eq!(resolved.nsec, nsec);
}
#[test]
fn test_resolve_identity_persistent_generates_and_persists() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join("fips.yaml");
fs::write(&config_path, "node:\n identity:\n persistent: true\n").unwrap();
let config = Config::load_file(&config_path).unwrap();
let resolved = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
assert!(matches!(resolved.source, IdentitySource::Generated(_)));
let key_path = temp_dir.path().join("fips.key");
let pub_path = temp_dir.path().join("fips.pub");
assert!(key_path.exists());
assert!(pub_path.exists());
let resolved2 = resolve_identity(&config, std::slice::from_ref(&config_path)).unwrap();
assert!(matches!(resolved2.source, IdentitySource::KeyFile(_)));
assert_eq!(resolved.nsec, resolved2.nsec);
}
#[test]
fn test_to_yaml_empty_nsec_omitted() {
let config = Config::new();
let yaml = config.to_yaml().unwrap();
assert!(!yaml.contains("nsec:"));
}
#[test]
fn test_parse_transport_single_instance() {
let yaml = r#"
transports:
udp:
bind_addr: "0.0.0.0:2121"
mtu: 1400
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.transports.udp.len(), 1);
let instances: Vec<_> = config.transports.udp.iter().collect();
assert_eq!(instances.len(), 1);
assert_eq!(instances[0].0, None); assert_eq!(instances[0].1.bind_addr(), "0.0.0.0:2121");
assert_eq!(instances[0].1.mtu(), 1400);
}
#[test]
fn test_parse_transport_named_instances() {
let yaml = r#"
transports:
udp:
main:
bind_addr: "0.0.0.0:2121"
backup:
bind_addr: "192.168.1.100:2122"
mtu: 1280
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.transports.udp.len(), 2);
let instances: std::collections::HashMap<_, _> = config.transports.udp.iter().collect();
assert!(instances.contains_key(&Some("main")));
assert!(instances.contains_key(&Some("backup")));
assert_eq!(instances[&Some("main")].bind_addr(), "0.0.0.0:2121");
assert_eq!(instances[&Some("backup")].bind_addr(), "192.168.1.100:2122");
assert_eq!(instances[&Some("backup")].mtu(), 1280);
}
#[test]
fn test_parse_transport_empty() {
let yaml = r#"
transports: {}
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.transports.udp.is_empty());
assert!(config.transports.is_empty());
}
#[test]
fn test_transport_instances_iter() {
let single = TransportInstances::Single(UdpConfig {
bind_addr: Some("0.0.0.0:2121".to_string()),
mtu: None,
..Default::default()
});
let items: Vec<_> = single.iter().collect();
assert_eq!(items.len(), 1);
assert_eq!(items[0].0, None);
let mut map = HashMap::new();
map.insert("a".to_string(), UdpConfig::default());
map.insert("b".to_string(), UdpConfig::default());
let named = TransportInstances::Named(map);
let items: Vec<_> = named.iter().collect();
assert_eq!(items.len(), 2);
assert!(items.iter().all(|(name, _)| name.is_some()));
}
#[test]
fn test_parse_peer_config() {
let yaml = r#"
peers:
- npub: "npub1abc123"
alias: "gateway"
addresses:
- transport: udp
addr: "192.168.1.1:2121"
priority: 1
- transport: tor
addr: "xyz.onion:2121"
priority: 2
connect_policy: auto_connect
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.peers.len(), 1);
let peer = &config.peers[0];
assert_eq!(peer.npub, "npub1abc123");
assert_eq!(peer.alias, Some("gateway".to_string()));
assert_eq!(peer.addresses.len(), 2);
assert!(peer.is_auto_connect());
let sorted = peer.addresses_by_priority();
assert_eq!(sorted[0].transport, "udp");
assert_eq!(sorted[0].priority, 1);
assert_eq!(sorted[1].transport, "tor");
assert_eq!(sorted[1].priority, 2);
}
#[test]
fn test_parse_peer_minimal() {
let yaml = r#"
peers:
- npub: "npub1xyz"
addresses:
- transport: udp
addr: "10.0.0.1:2121"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.peers.len(), 1);
let peer = &config.peers[0];
assert_eq!(peer.npub, "npub1xyz");
assert!(peer.alias.is_none());
assert!(peer.is_auto_connect());
assert_eq!(peer.addresses[0].priority, 100);
}
#[test]
fn test_parse_multiple_peers() {
let yaml = r#"
peers:
- npub: "npub1peer1"
addresses:
- transport: udp
addr: "10.0.0.1:2121"
- npub: "npub1peer2"
addresses:
- transport: udp
addr: "10.0.0.2:2121"
connect_policy: on_demand
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.peers.len(), 2);
assert_eq!(config.auto_connect_peers().count(), 1);
}
#[test]
fn test_peer_config_builder() {
let peer = PeerConfig::new("npub1test", "udp", "192.168.1.1:2121")
.with_alias("test-peer")
.with_address(PeerAddress::with_priority("tor", "xyz.onion:2121", 50));
assert_eq!(peer.npub, "npub1test");
assert_eq!(peer.alias, Some("test-peer".to_string()));
assert_eq!(peer.addresses.len(), 2);
assert!(peer.is_auto_connect());
}
#[test]
fn test_parse_nostr_discovery_config() {
let yaml = r#"
node:
discovery:
nostr:
enabled: true
advertise: false
policy: configured_only
open_discovery_max_pending: 12
app: "fips.nat.test.v1"
signal_ttl_secs: 45
advert_relays:
- "wss://relay-a.example"
dm_relays:
- "wss://relay-b.example"
stun_servers:
- "stun:stun.example.org:3478"
peers:
- npub: "npub1peer"
addresses:
- transport: udp
addr: "nat"
"#;
let config: Config = serde_yaml::from_str(yaml).unwrap();
assert!(config.node.discovery.nostr.enabled);
assert!(!config.node.discovery.nostr.advertise);
assert_eq!(config.node.discovery.nostr.app, "fips.nat.test.v1");
assert_eq!(config.node.discovery.nostr.signal_ttl_secs, 45);
assert_eq!(
config.node.discovery.nostr.policy,
NostrDiscoveryPolicy::ConfiguredOnly
);
assert_eq!(config.node.discovery.nostr.open_discovery_max_pending, 12);
assert_eq!(
config.node.discovery.nostr.advert_relays,
vec!["wss://relay-a.example".to_string()]
);
assert_eq!(
config.node.discovery.nostr.dm_relays,
vec!["wss://relay-b.example".to_string()]
);
assert_eq!(
config.node.discovery.nostr.stun_servers,
vec!["stun:stun.example.org:3478".to_string()]
);
assert_eq!(
config.peers[0].addresses[0].addr, "nat",
"udp:nat address should parse without special-casing in YAML"
);
}
#[test]
fn test_validate_transport_advert_requires_nostr_enabled() {
let mut config = Config::default();
config.transports.udp = TransportInstances::Single(UdpConfig {
advertise_on_nostr: Some(true),
..Default::default()
});
config.node.discovery.nostr.enabled = false;
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("advertise_on_nostr"));
}
#[test]
fn test_validate_empty_peer_addresses_require_nostr_enabled() {
let mut config = Config {
peers: vec![PeerConfig {
npub: "npub1peer".to_string(),
..Default::default()
}],
..Default::default()
};
config.node.discovery.nostr.enabled = false;
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("node.discovery.nostr"));
}
#[test]
fn test_validate_peer_addresses_optional_with_nostr_enabled() {
let mut config = Config {
peers: vec![PeerConfig {
npub: "npub1peer".to_string(),
..Default::default()
}],
..Default::default()
};
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("at least one address"));
config.node.discovery.nostr.enabled = true;
config
.validate()
.expect("Nostr discovery should allow empty addresses");
}
#[test]
fn test_validate_nat_udp_advert_requires_relays_and_stun() {
let mut config = Config::default();
config.node.discovery.nostr.enabled = true;
config.node.discovery.nostr.dm_relays.clear();
config.transports.udp = TransportInstances::Single(UdpConfig {
advertise_on_nostr: Some(true),
public: Some(false),
..Default::default()
});
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("dm_relays"));
config.node.discovery.nostr.dm_relays = vec!["wss://relay.example".to_string()];
config.node.discovery.nostr.stun_servers.clear();
let err = config.validate().expect_err("validation should fail");
assert!(err.to_string().contains("stun_servers"));
}
#[test]
fn test_is_loopback_addr_str() {
assert!(is_loopback_addr_str("127.0.0.1:2121"));
assert!(is_loopback_addr_str("127.0.0.5:9999"));
assert!(is_loopback_addr_str("[::1]:2121"));
assert!(is_loopback_addr_str("::1:2121"));
assert!(is_loopback_addr_str("localhost:80"));
assert!(!is_loopback_addr_str("0.0.0.0:2121"));
assert!(!is_loopback_addr_str("192.168.1.1:2121"));
assert!(!is_loopback_addr_str("[fd00::1]:2121"));
assert!(!is_loopback_addr_str("core-vm.tail65015.ts.net:2121"));
assert!(!is_loopback_addr_str("example.com:443"));
}
#[test]
fn test_validate_loopback_bind_with_external_peer_rejected() {
use crate::config::PeerAddress;
let mut config = Config::default();
config.transports.udp = TransportInstances::Single(UdpConfig {
bind_addr: Some("127.0.0.1:2121".to_string()),
..Default::default()
});
config.peers = vec![PeerConfig {
npub: "npub1peer".to_string(),
addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
..Default::default()
}];
let err = config.validate().expect_err("validation should fail");
let msg = err.to_string();
assert!(msg.contains("loopback"), "got: {msg}");
assert!(msg.contains("non-loopback"), "got: {msg}");
}
#[test]
fn test_validate_loopback_bind_with_loopback_peer_ok() {
use crate::config::PeerAddress;
let mut config = Config::default();
config.transports.udp = TransportInstances::Single(UdpConfig {
bind_addr: Some("127.0.0.1:2121".to_string()),
..Default::default()
});
config.peers = vec![PeerConfig {
npub: "npub1peer".to_string(),
addresses: vec![PeerAddress::new("udp", "127.0.0.2:2121")],
..Default::default()
}];
config
.validate()
.expect("loopback peer with loopback bind should validate");
}
#[test]
fn test_validate_outbound_only_exempt_from_loopback_check() {
use crate::config::PeerAddress;
let mut config = Config::default();
config.transports.udp = TransportInstances::Single(UdpConfig {
bind_addr: Some("127.0.0.1:2121".to_string()),
outbound_only: Some(true),
..Default::default()
});
config.peers = vec![PeerConfig {
npub: "npub1peer".to_string(),
addresses: vec![PeerAddress::new("udp", "core-vm.tail65015.ts.net:2121")],
..Default::default()
}];
config
.validate()
.expect("outbound_only should be exempt from the loopback check");
}
#[test]
fn test_outbound_only_forces_ephemeral_bind() {
let cfg = UdpConfig {
bind_addr: Some("127.0.0.1:2121".to_string()),
outbound_only: Some(true),
..Default::default()
};
assert_eq!(cfg.bind_addr(), "0.0.0.0:0");
assert!(cfg.outbound_only());
}
#[test]
fn test_outbound_only_forces_advertise_off() {
let cfg = UdpConfig {
advertise_on_nostr: Some(true),
outbound_only: Some(true),
..Default::default()
};
assert!(!cfg.advertise_on_nostr());
}
#[test]
fn test_udp_accept_connections_default_true() {
let cfg = UdpConfig::default();
assert!(cfg.accept_connections());
}
}