Files
fips/src/config/mod.rs
T
Johnathan Corgan daf1e629df Change default UDP port to 2121 and EtherType to 0x2121
Update the default UDP bind port from 4000 to 2121 (decimal) and the
default Ethernet EtherType from 0x88B5 to 0x2121 across all source
code, documentation, configuration templates, test fixtures, and
scripts. Remove references to "IEEE 802 experimental range" since
0x2121 is not in that range.
2026-02-26 13:22:09 +00:00

620 lines
18 KiB
Rust

//! FIPS Configuration System
//!
//! Loads configuration from YAML files with a cascading priority system:
//! 1. `./fips.yaml` (current directory - highest priority)
//! 2. `~/.config/fips/fips.yaml` (user config directory)
//! 3. `/etc/fips/fips.yaml` (system - lowest priority)
//!
//! Values from higher priority files override those from lower priority files.
//!
//! # YAML Structure
//!
//! The YAML structure mirrors the sysctl-style paths in the architecture docs.
//! For example, `node.identity.nsec` in the docs corresponds to:
//!
//! ```yaml
//! node:
//! identity:
//! nsec: "nsec1..."
//! ```
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;
pub use node::{
BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
NodeConfig, RateLimitConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{EthernetConfig, TransportInstances, TransportsConfig, UdpConfig};
/// Default config filename.
const CONFIG_FILENAME: &str = "fips.yaml";
/// Errors that can occur during configuration loading.
#[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("identity error: {0}")]
Identity(#[from] IdentityError),
}
/// Identity configuration (`node.identity.*`).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IdentityConfig {
/// Secret key in nsec (bech32) or hex format (`node.identity.nsec`).
/// If not specified, a new keypair will be generated.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nsec: Option<String>,
}
/// Root configuration structure.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Config {
/// Node configuration (`node.*`).
#[serde(default)]
pub node: NodeConfig,
/// TUN interface configuration (`tun.*`).
#[serde(default)]
pub tun: TunConfig,
/// DNS responder configuration (`dns.*`).
#[serde(default)]
pub dns: DnsConfig,
/// Transport instances (`transports.*`).
#[serde(default, skip_serializing_if = "TransportsConfig::is_empty")]
pub transports: TransportsConfig,
/// Static peers to connect to (`peers`).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub peers: Vec<PeerConfig>,
}
impl Config {
/// Create a new empty configuration.
pub fn new() -> Self {
Self::default()
}
/// Load configuration from the standard search paths.
///
/// Files are loaded in reverse priority order and merged:
/// 1. `/etc/fips/fips.yaml` (loaded first, lowest priority)
/// 2. `~/.config/fips/fips.yaml` (user config)
/// 3. `./fips.yaml` (loaded last, highest priority)
///
/// Returns a tuple of (config, paths_loaded) where paths_loaded contains
/// the paths that were successfully loaded.
pub fn load() -> Result<(Self, Vec<PathBuf>), ConfigError> {
let search_paths = Self::search_paths();
Self::load_from_paths(&search_paths)
}
/// Load configuration from specific paths.
///
/// Paths are processed in order, with later paths overriding earlier ones.
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))
}
/// Load configuration from a single file.
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,
})
}
/// Get the standard search paths in priority order (lowest to highest).
pub fn search_paths() -> Vec<PathBuf> {
let mut paths = Vec::new();
// System config (lowest priority)
paths.push(PathBuf::from("/etc/fips").join(CONFIG_FILENAME));
// User config directory
if let Some(config_dir) = dirs::config_dir() {
paths.push(config_dir.join("fips").join(CONFIG_FILENAME));
}
// Home directory (legacy location)
if let Some(home_dir) = dirs::home_dir() {
paths.push(home_dir.join(".fips.yaml"));
}
// Current directory (highest priority)
paths.push(PathBuf::from(".").join(CONFIG_FILENAME));
paths
}
/// Merge another configuration into this one.
///
/// Values from `other` override values in `self` when present.
pub fn merge(&mut self, other: Config) {
// Merge node.identity section
if other.node.identity.nsec.is_some() {
self.node.identity.nsec = other.node.identity.nsec;
}
// Merge node.leaf_only
if other.node.leaf_only {
self.node.leaf_only = true;
}
// Merge tun section
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;
}
// Merge dns section — higher-priority config always wins for enabled
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;
}
// Merge transports section
self.transports.merge(other.transports);
// Merge peers (replace if non-empty)
if !other.peers.is_empty() {
self.peers = other.peers;
}
}
/// Create an Identity from this configuration.
///
/// If an nsec is configured, uses that to create the identity.
/// Otherwise, generates a new random identity.
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()),
}
}
/// Check if an identity is configured (vs. will be generated).
pub fn has_identity(&self) -> bool {
self.node.identity.nsec.is_some()
}
/// Check if leaf-only mode is configured.
pub fn is_leaf_only(&self) -> bool {
self.node.leaf_only
}
/// Get the configured peers.
pub fn peers(&self) -> &[PeerConfig] {
&self.peers
}
/// Get peers that should auto-connect on startup.
pub fn auto_connect_peers(&self) -> impl Iterator<Item = &PeerConfig> {
self.peers.iter().filter(|p| p.is_auto_connect())
}
/// Serialize this configuration to YAML.
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();
// Create two config files
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();
// Should include current directory
assert!(paths.iter().any(|p| p.ends_with("fips.yaml")));
// Should include /etc/fips
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_to_yaml_empty_nsec_omitted() {
let config = Config::new();
let yaml = config.to_yaml().unwrap();
// Empty nsec should not be serialized
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); // Single instance has no name
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();
// Named instances have Some(name)
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() {
// Single instance - no name
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);
// Named instances - have names
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);
// All named instances should have Some(name)
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());
// Check addresses are sorted by priority
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());
// Default connect_policy is auto_connect
assert!(peer.is_auto_connect());
// Default priority is 100
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());
}
}