From 930f1397872f7dc085e4ed8c480d1bc4f0df08f2 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Sun, 15 Feb 2026 17:56:32 +0000 Subject: [PATCH] Split cache.rs and config.rs into directory modules, create utils/ Module reorganization for three large single-file modules: cache.rs (792 lines) split into cache/ directory: - cache/mod.rs: CacheError, CacheStats, re-exports - cache/entry.rs: CacheEntry with TTL/LRU tracking - cache/coord_cache.rs: CoordCache (address-to-coordinate mappings) - cache/route_cache.rs: RouteCache + CachedCoords (discovery routes) - Added 22 new tests filling coverage gaps across all submodules config.rs (1318 lines) split into config/ directory: - config/mod.rs: ConfigError, IdentityConfig, Config struct with file loading/merge logic, all 24 integration tests - config/node.rs: NodeConfig + 9 subsection structs (Limits, RateLimit, Retry, Cache, Discovery, Tree, Bloom, Session, Buffers) - config/transport.rs: TransportInstances, TransportsConfig, UdpConfig - config/peer.rs: ConnectPolicy, PeerAddress, PeerConfig DnsConfig and TunConfig moved to upper/config.rs to co-locate with the upper layer components they configure (TUN interface, DNS responder). index.rs moved to utils/index.rs as cross-cutting infrastructure that serves both node and peer layers. --- src/cache.rs | 792 ----------------------- src/cache/coord_cache.rs | 477 ++++++++++++++ src/cache/entry.rs | 176 +++++ src/cache/mod.rs | 102 +++ src/cache/route_cache.rs | 364 +++++++++++ src/config.rs | 1318 -------------------------------------- src/config/mod.rs | 620 ++++++++++++++++++ src/config/node.rs | 361 +++++++++++ src/config/peer.rs | 131 ++++ src/config/transport.rs | 150 +++++ src/lib.rs | 5 +- src/node/mod.rs | 2 +- src/node/tests/mod.rs | 2 +- src/node/wire.rs | 2 +- src/peer/active.rs | 2 +- src/peer/connection.rs | 2 +- src/upper/config.rs | 86 +++ src/upper/mod.rs | 1 + src/{ => utils}/index.rs | 0 src/utils/mod.rs | 6 + 20 files changed, 2482 insertions(+), 2117 deletions(-) delete mode 100644 src/cache.rs create mode 100644 src/cache/coord_cache.rs create mode 100644 src/cache/entry.rs create mode 100644 src/cache/mod.rs create mode 100644 src/cache/route_cache.rs delete mode 100644 src/config.rs create mode 100644 src/config/mod.rs create mode 100644 src/config/node.rs create mode 100644 src/config/peer.rs create mode 100644 src/config/transport.rs create mode 100644 src/upper/config.rs rename src/{ => utils}/index.rs (100%) create mode 100644 src/utils/mod.rs diff --git a/src/cache.rs b/src/cache.rs deleted file mode 100644 index ca7ca7a..0000000 --- a/src/cache.rs +++ /dev/null @@ -1,792 +0,0 @@ -//! Caching Entities -//! -//! Coordinate and route caching for FIPS routing. The CoordCache stores -//! address-to-coordinate mappings populated by session setup, while -//! RouteCache stores coordinates learned from discovery queries. - -use crate::tree::TreeCoordinate; -use crate::NodeAddr; -use std::collections::HashMap; -use thiserror::Error; - -/// Default maximum entries in coordinate cache. -pub const DEFAULT_COORD_CACHE_SIZE: usize = 50_000; - -/// Default TTL for coordinate cache entries (5 minutes in milliseconds). -pub const DEFAULT_COORD_CACHE_TTL_MS: u64 = 300_000; - -/// Default maximum entries in route cache. -pub const DEFAULT_ROUTE_CACHE_SIZE: usize = 10_000; - -/// Errors related to cache operations. -#[derive(Debug, Error)] -pub enum CacheError { - #[error("cache full: max {max} entries")] - CacheFull { max: usize }, - - #[error("entry not found")] - NotFound, - - #[error("entry expired")] - Expired, -} - -/// A cached coordinate entry. -#[derive(Clone, Debug)] -pub struct CacheEntry { - /// The cached coordinates. - coords: TreeCoordinate, - /// When this entry was created (Unix milliseconds). - created_at: u64, - /// When this entry was last used (Unix milliseconds). - last_used: u64, - /// When this entry expires (Unix milliseconds). - expires_at: u64, -} - -impl CacheEntry { - /// Create a new cache entry. - pub fn new(coords: TreeCoordinate, current_time_ms: u64, ttl_ms: u64) -> Self { - Self { - coords, - created_at: current_time_ms, - last_used: current_time_ms, - expires_at: current_time_ms.saturating_add(ttl_ms), - } - } - - /// Get the cached coordinates. - pub fn coords(&self) -> &TreeCoordinate { - &self.coords - } - - /// Get the creation timestamp. - pub fn created_at(&self) -> u64 { - self.created_at - } - - /// Get the last used timestamp. - pub fn last_used(&self) -> u64 { - self.last_used - } - - /// Get the expiry timestamp. - pub fn expires_at(&self) -> u64 { - self.expires_at - } - - /// Check if this entry has expired. - pub fn is_expired(&self, current_time_ms: u64) -> bool { - current_time_ms > self.expires_at - } - - /// Touch the entry to update last_used time. - pub fn touch(&mut self, current_time_ms: u64) { - self.last_used = current_time_ms; - } - - /// Refresh the expiry time. - pub fn refresh(&mut self, current_time_ms: u64, ttl_ms: u64) { - self.expires_at = current_time_ms.saturating_add(ttl_ms); - self.last_used = current_time_ms; - } - - /// Update the coordinates and refresh timestamps. - pub fn update(&mut self, coords: TreeCoordinate, current_time_ms: u64, ttl_ms: u64) { - self.coords = coords; - self.last_used = current_time_ms; - self.expires_at = current_time_ms.saturating_add(ttl_ms); - } - - /// Time since last use (for LRU eviction). - pub fn idle_time(&self, current_time_ms: u64) -> u64 { - current_time_ms.saturating_sub(self.last_used) - } - - /// Age of the entry. - pub fn age(&self, current_time_ms: u64) -> u64 { - current_time_ms.saturating_sub(self.created_at) - } - - /// Time until expiry (0 if already expired). - pub fn time_to_expiry(&self, current_time_ms: u64) -> u64 { - self.expires_at.saturating_sub(current_time_ms) - } -} - -/// Coordinate cache for routing decisions. -/// -/// Maps node addresses to their tree coordinates, enabling data packets -/// to be routed without carrying coordinates in every packet. Populated -/// by SessionSetup packets. -#[derive(Clone, Debug)] -pub struct CoordCache { - /// NodeAddr -> coordinates mapping. - entries: HashMap, - /// Maximum number of entries. - max_entries: usize, - /// Default TTL for entries (milliseconds). - default_ttl_ms: u64, -} - -impl CoordCache { - /// Create a new coordinate cache. - pub fn new(max_entries: usize, default_ttl_ms: u64) -> Self { - Self { - entries: HashMap::with_capacity(max_entries.min(1000)), - max_entries, - default_ttl_ms, - } - } - - /// Create a cache with default parameters. - pub fn with_defaults() -> Self { - Self::new(DEFAULT_COORD_CACHE_SIZE, DEFAULT_COORD_CACHE_TTL_MS) - } - - /// Get the maximum capacity. - pub fn max_entries(&self) -> usize { - self.max_entries - } - - /// Get the default TTL. - pub fn default_ttl_ms(&self) -> u64 { - self.default_ttl_ms - } - - /// Set the default TTL. - pub fn set_default_ttl_ms(&mut self, ttl_ms: u64) { - self.default_ttl_ms = ttl_ms; - } - - /// Insert or update a cache entry. - pub fn insert(&mut self, addr: NodeAddr, coords: TreeCoordinate, current_time_ms: u64) { - // Update existing entry if present - if let Some(entry) = self.entries.get_mut(&addr) { - entry.update(coords, current_time_ms, self.default_ttl_ms); - return; - } - - // Evict if at capacity - if self.entries.len() >= self.max_entries { - self.evict_one(current_time_ms); - } - - let entry = CacheEntry::new(coords, current_time_ms, self.default_ttl_ms); - self.entries.insert(addr, entry); - } - - /// Insert with a custom TTL. - pub fn insert_with_ttl( - &mut self, - addr: NodeAddr, - coords: TreeCoordinate, - current_time_ms: u64, - ttl_ms: u64, - ) { - if let Some(entry) = self.entries.get_mut(&addr) { - entry.update(coords, current_time_ms, ttl_ms); - return; - } - - if self.entries.len() >= self.max_entries { - self.evict_one(current_time_ms); - } - - let entry = CacheEntry::new(coords, current_time_ms, ttl_ms); - self.entries.insert(addr, entry); - } - - /// Look up coordinates for an address (without touching). - pub fn get(&self, addr: &NodeAddr, current_time_ms: u64) -> Option<&TreeCoordinate> { - self.entries.get(addr).and_then(|entry| { - if entry.is_expired(current_time_ms) { - None - } else { - Some(entry.coords()) - } - }) - } - - /// Look up coordinates and touch (update last_used). - pub fn get_and_touch( - &mut self, - addr: &NodeAddr, - current_time_ms: u64, - ) -> Option<&TreeCoordinate> { - // Check and remove if expired - if let Some(entry) = self.entries.get(addr) - && entry.is_expired(current_time_ms) - { - self.entries.remove(addr); - return None; - } - - // Touch and return - if let Some(entry) = self.entries.get_mut(addr) { - entry.touch(current_time_ms); - Some(entry.coords()) - } else { - None - } - } - - /// Get the full cache entry. - pub fn get_entry(&self, addr: &NodeAddr) -> Option<&CacheEntry> { - self.entries.get(addr) - } - - /// Remove an entry. - pub fn remove(&mut self, addr: &NodeAddr) -> Option { - self.entries.remove(addr) - } - - /// Check if an address is cached (and not expired). - pub fn contains(&self, addr: &NodeAddr, current_time_ms: u64) -> bool { - self.get(addr, current_time_ms).is_some() - } - - /// Number of entries (including expired). - pub fn len(&self) -> usize { - self.entries.len() - } - - /// Check if empty. - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Remove all expired entries. - pub fn purge_expired(&mut self, current_time_ms: u64) -> usize { - let before = self.entries.len(); - self.entries - .retain(|_, entry| !entry.is_expired(current_time_ms)); - before - self.entries.len() - } - - /// Clear all entries. - pub fn clear(&mut self) { - self.entries.clear(); - } - - /// Evict one entry (expired first, then LRU). - fn evict_one(&mut self, current_time_ms: u64) { - // First try to evict an expired entry - let expired_key = self - .entries - .iter() - .find(|(_, e)| e.is_expired(current_time_ms)) - .map(|(k, _)| *k); - - if let Some(key) = expired_key { - self.entries.remove(&key); - return; - } - - // Otherwise evict LRU (oldest last_used) - let lru_key = self - .entries - .iter() - .max_by_key(|(_, e)| e.idle_time(current_time_ms)) - .map(|(k, _)| *k); - - if let Some(key) = lru_key { - self.entries.remove(&key); - } - } - - /// Get cache statistics. - pub fn stats(&self, current_time_ms: u64) -> CacheStats { - let mut expired = 0; - let mut total_age = 0u64; - - for entry in self.entries.values() { - if entry.is_expired(current_time_ms) { - expired += 1; - } - total_age += entry.age(current_time_ms); - } - - CacheStats { - entries: self.entries.len(), - max_entries: self.max_entries, - expired, - avg_age_ms: if self.entries.is_empty() { - 0 - } else { - total_age / self.entries.len() as u64 - }, - } - } -} - -impl Default for CoordCache { - fn default() -> Self { - Self::with_defaults() - } -} - -/// Cache statistics. -#[derive(Clone, Debug)] -pub struct CacheStats { - /// Current number of entries. - pub entries: usize, - /// Maximum capacity. - pub max_entries: usize, - /// Number of expired entries. - pub expired: usize, - /// Average entry age in milliseconds. - pub avg_age_ms: u64, -} - -impl CacheStats { - /// Fill ratio (entries / max_entries). - pub fn fill_ratio(&self) -> f64 { - if self.max_entries == 0 { - 0.0 - } else { - self.entries as f64 / self.max_entries as f64 - } - } -} - -/// A cached route from discovery. -#[derive(Clone, Debug)] -pub struct CachedCoords { - /// The coordinates discovered. - coords: TreeCoordinate, - /// When this was discovered (Unix milliseconds). - discovered_at: u64, - /// Last time we used this route (Unix milliseconds). - last_used: u64, -} - -impl CachedCoords { - /// Create a new cached route. - pub fn new(coords: TreeCoordinate, discovered_at: u64) -> Self { - Self { - coords, - discovered_at, - last_used: discovered_at, - } - } - - /// Get the coordinates. - pub fn coords(&self) -> &TreeCoordinate { - &self.coords - } - - /// Get the discovery timestamp. - pub fn discovered_at(&self) -> u64 { - self.discovered_at - } - - /// Get the last used timestamp. - pub fn last_used(&self) -> u64 { - self.last_used - } - - /// Touch (update last_used). - pub fn touch(&mut self, current_time_ms: u64) { - self.last_used = current_time_ms; - } - - /// Age since discovery. - pub fn age(&self, current_time_ms: u64) -> u64 { - current_time_ms.saturating_sub(self.discovered_at) - } - - /// Idle time since last use. - pub fn idle_time(&self, current_time_ms: u64) -> u64 { - current_time_ms.saturating_sub(self.last_used) - } - - /// Update coordinates (re-discovered). - pub fn update(&mut self, coords: TreeCoordinate, current_time_ms: u64) { - self.coords = coords; - self.discovered_at = current_time_ms; - self.last_used = current_time_ms; - } -} - -/// Route cache for discovered destinations. -/// -/// Separate from CoordCache, this stores routes learned from the discovery -/// protocol (LookupRequest/LookupResponse) rather than session establishment. -/// Keyed by NodeAddr. -#[derive(Clone, Debug)] -pub struct RouteCache { - /// NodeAddr -> discovered coordinates. - entries: HashMap, - /// Maximum entries. - max_entries: usize, -} - -impl RouteCache { - /// Create a new route cache. - pub fn new(max_entries: usize) -> Self { - Self { - entries: HashMap::with_capacity(max_entries.min(1000)), - max_entries, - } - } - - /// Create with default capacity. - pub fn with_defaults() -> Self { - Self::new(DEFAULT_ROUTE_CACHE_SIZE) - } - - /// Get the maximum capacity. - pub fn max_entries(&self) -> usize { - self.max_entries - } - - /// Insert a discovered route. - pub fn insert(&mut self, node_addr: NodeAddr, coords: TreeCoordinate, current_time_ms: u64) { - // Update existing - if let Some(entry) = self.entries.get_mut(&node_addr) { - entry.update(coords, current_time_ms); - return; - } - - // Evict if full - if self.entries.len() >= self.max_entries { - self.evict_lru(current_time_ms); - } - - self.entries - .insert(node_addr, CachedCoords::new(coords, current_time_ms)); - } - - /// Look up a route (without touching). - pub fn get(&self, node_addr: &NodeAddr) -> Option<&CachedCoords> { - self.entries.get(node_addr) - } - - /// Look up and touch. - pub fn get_and_touch( - &mut self, - node_addr: &NodeAddr, - current_time_ms: u64, - ) -> Option<&TreeCoordinate> { - if let Some(entry) = self.entries.get_mut(node_addr) { - entry.touch(current_time_ms); - Some(entry.coords()) - } else { - None - } - } - - /// Remove a route (e.g., after route failure). - pub fn invalidate(&mut self, node_addr: &NodeAddr) -> Option { - self.entries.remove(node_addr) - } - - /// Check if a node is cached. - pub fn contains(&self, node_addr: &NodeAddr) -> bool { - self.entries.contains_key(node_addr) - } - - /// Number of cached routes. - pub fn len(&self) -> usize { - self.entries.len() - } - - /// Check if empty. - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - /// Clear all routes. - pub fn clear(&mut self) { - self.entries.clear(); - } - - /// Evict routes older than a threshold. - pub fn evict_older_than(&mut self, max_age_ms: u64, current_time_ms: u64) -> usize { - let before = self.entries.len(); - self.entries - .retain(|_, entry| entry.age(current_time_ms) < max_age_ms); - before - self.entries.len() - } - - fn evict_lru(&mut self, current_time_ms: u64) { - let lru_id = self - .entries - .iter() - .max_by_key(|(_, e)| e.idle_time(current_time_ms)) - .map(|(k, _)| *k); - - if let Some(id) = lru_id { - self.entries.remove(&id); - } - } -} - -impl Default for RouteCache { - fn default() -> Self { - Self::with_defaults() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_node_addr(val: u8) -> NodeAddr { - let mut bytes = [0u8; 16]; - bytes[0] = val; - NodeAddr::from_bytes(bytes) - } - - fn make_coords(ids: &[u8]) -> TreeCoordinate { - TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() - } - - // ===== CacheEntry Tests ===== - - #[test] - fn test_cache_entry_expiry() { - let coords = make_coords(&[1, 0]); - let entry = CacheEntry::new(coords, 1000, 500); - - assert!(!entry.is_expired(1000)); - assert!(!entry.is_expired(1500)); // expires_at = 1500, not yet expired - assert!(entry.is_expired(1501)); // one ms after expiry - assert!(entry.is_expired(2000)); - } - - #[test] - fn test_cache_entry_refresh() { - let coords = make_coords(&[1, 0]); - let mut entry = CacheEntry::new(coords, 1000, 500); - - assert!(entry.is_expired(1501)); // expires_at = 1500 - - entry.refresh(1400, 500); // new expires_at = 1900 - - assert!(!entry.is_expired(1600)); - assert!(!entry.is_expired(1900)); // at exactly expiry, not expired - assert!(entry.is_expired(1901)); // one ms after expiry - } - - #[test] - fn test_cache_entry_times() { - let coords = make_coords(&[1, 0]); - let entry = CacheEntry::new(coords, 1000, 500); - - assert_eq!(entry.created_at(), 1000); - assert_eq!(entry.last_used(), 1000); - assert_eq!(entry.expires_at(), 1500); - assert_eq!(entry.age(1200), 200); - assert_eq!(entry.idle_time(1200), 200); - assert_eq!(entry.time_to_expiry(1200), 300); - assert_eq!(entry.time_to_expiry(1600), 0); - } - - // ===== CoordCache Tests ===== - - #[test] - fn test_coord_cache_basic() { - let mut cache = CoordCache::new(100, 1000); - let addr = make_node_addr(1); - let coords = make_coords(&[1, 0]); - - cache.insert(addr, coords.clone(), 0); - - assert!(cache.contains(&addr, 0)); - assert_eq!(cache.get(&addr, 0), Some(&coords)); - assert_eq!(cache.len(), 1); - } - - #[test] - fn test_coord_cache_expiry() { - let mut cache = CoordCache::new(100, 1000); - let addr = make_node_addr(1); - let coords = make_coords(&[1, 0]); - - cache.insert(addr, coords, 0); - - assert!(cache.contains(&addr, 500)); - assert!(!cache.contains(&addr, 1500)); - } - - #[test] - fn test_coord_cache_update() { - let mut cache = CoordCache::new(100, 1000); - let addr = make_node_addr(1); - - cache.insert(addr, make_coords(&[1, 0]), 0); - cache.insert(addr, make_coords(&[1, 2, 0]), 500); - - assert_eq!(cache.len(), 1); - let coords = cache.get(&addr, 500).unwrap(); - assert_eq!(coords.depth(), 2); - } - - #[test] - fn test_coord_cache_eviction() { - let mut cache = CoordCache::new(2, 10000); - - let addr1 = make_node_addr(1); - let addr2 = make_node_addr(2); - let addr3 = make_node_addr(3); - - cache.insert(addr1, make_coords(&[1, 0]), 0); - cache.insert(addr2, make_coords(&[2, 0]), 100); - - // Touch addr2 to make it more recent - let _ = cache.get_and_touch(&addr2, 200); - - // Insert addr3, should evict addr1 (LRU) - cache.insert(addr3, make_coords(&[3, 0]), 300); - - assert!(!cache.contains(&addr1, 300)); - assert!(cache.contains(&addr2, 300)); - assert!(cache.contains(&addr3, 300)); - } - - #[test] - fn test_coord_cache_evict_expired_first() { - let mut cache = CoordCache::new(2, 100); - - cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); - cache.insert(make_node_addr(2), make_coords(&[2, 0]), 50); - - // At time 150, addr1 is expired, addr2 is not - cache.insert(make_node_addr(3), make_coords(&[3, 0]), 150); - - // addr1 should be evicted (expired), not addr2 (LRU but not expired) - assert!(!cache.contains(&make_node_addr(1), 150)); - assert!(cache.contains(&make_node_addr(2), 150)); - assert!(cache.contains(&make_node_addr(3), 150)); - } - - #[test] - fn test_coord_cache_purge_expired() { - let mut cache = CoordCache::new(100, 100); - - cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); // expires at 100 - cache.insert(make_node_addr(2), make_coords(&[2, 0]), 50); // expires at 150 - cache.insert(make_node_addr(3), make_coords(&[3, 0]), 200); // expires at 300 - - assert_eq!(cache.len(), 3); - - let purged = cache.purge_expired(151); // both addr1 and addr2 expired - - // Entry 1 and 2 expired, entry 3 still valid - assert_eq!(purged, 2); - assert_eq!(cache.len(), 1); - assert!(cache.contains(&make_node_addr(3), 151)); - } - - #[test] - fn test_coord_cache_stats() { - let mut cache = CoordCache::new(100, 100); - - cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); - cache.insert(make_node_addr(2), make_coords(&[2, 0]), 50); - - let stats = cache.stats(150); - - assert_eq!(stats.entries, 2); - assert_eq!(stats.max_entries, 100); - assert_eq!(stats.expired, 1); // addr1 expired - assert!(stats.avg_age_ms > 0); - } - - // ===== CachedCoords Tests ===== - - #[test] - fn test_cached_coords() { - let coords = make_coords(&[1, 0]); - let mut cached = CachedCoords::new(coords.clone(), 1000); - - assert_eq!(cached.coords(), &coords); - assert_eq!(cached.discovered_at(), 1000); - assert_eq!(cached.last_used(), 1000); - - cached.touch(1500); - assert_eq!(cached.last_used(), 1500); - assert_eq!(cached.idle_time(1600), 100); - assert_eq!(cached.age(1600), 600); - } - - // ===== RouteCache Tests ===== - - #[test] - fn test_route_cache_basic() { - let mut cache = RouteCache::new(100); - let node = make_node_addr(1); - let coords = make_coords(&[1, 0]); - - cache.insert(node, coords.clone(), 0); - - assert!(cache.contains(&node)); - assert_eq!(cache.get(&node).unwrap().coords(), &coords); - } - - #[test] - fn test_route_cache_invalidate() { - let mut cache = RouteCache::new(100); - let node = make_node_addr(1); - let coords = make_coords(&[1, 0]); - - cache.insert(node, coords, 0); - assert!(cache.contains(&node)); - - cache.invalidate(&node); - assert!(!cache.contains(&node)); - } - - #[test] - fn test_route_cache_lru_eviction() { - let mut cache = RouteCache::new(2); - - let node1 = make_node_addr(1); - let node2 = make_node_addr(2); - let node3 = make_node_addr(3); - - cache.insert(node1, make_coords(&[1, 0]), 0); - cache.insert(node2, make_coords(&[2, 0]), 100); - - // Touch node2 - let _ = cache.get_and_touch(&node2, 200); - - // Insert node3 - cache.insert(node3, make_coords(&[3, 0]), 300); - - // node1 should be evicted - assert!(!cache.contains(&node1)); - assert!(cache.contains(&node2)); - assert!(cache.contains(&node3)); - } - - #[test] - fn test_route_cache_evict_older_than() { - let mut cache = RouteCache::new(100); - - cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); - cache.insert(make_node_addr(2), make_coords(&[2, 0]), 500); - cache.insert(make_node_addr(3), make_coords(&[3, 0]), 1000); - - let evicted = cache.evict_older_than(600, 1000); - - assert_eq!(evicted, 1); // node1 is > 600ms old - assert_eq!(cache.len(), 2); - } - - #[test] - fn test_route_cache_update() { - let mut cache = RouteCache::new(100); - let node = make_node_addr(1); - - cache.insert(node, make_coords(&[1, 0]), 0); - cache.insert(node, make_coords(&[1, 2, 0]), 500); - - assert_eq!(cache.len(), 1); - let cached = cache.get(&node).unwrap(); - assert_eq!(cached.coords().depth(), 2); - assert_eq!(cached.discovered_at(), 500); - } -} diff --git a/src/cache/coord_cache.rs b/src/cache/coord_cache.rs new file mode 100644 index 0000000..a02d5b2 --- /dev/null +++ b/src/cache/coord_cache.rs @@ -0,0 +1,477 @@ +//! Coordinate cache for routing decisions. +//! +//! Maps node addresses to their tree coordinates, enabling data packets +//! to be routed without carrying coordinates in every packet. Populated +//! by SessionSetup packets. + +use std::collections::HashMap; + +use super::entry::CacheEntry; +use super::CacheStats; +use crate::tree::TreeCoordinate; +use crate::NodeAddr; + +/// Default maximum entries in coordinate cache. +pub const DEFAULT_COORD_CACHE_SIZE: usize = 50_000; + +/// Default TTL for coordinate cache entries (5 minutes in milliseconds). +pub const DEFAULT_COORD_CACHE_TTL_MS: u64 = 300_000; + +/// Coordinate cache for routing decisions. +/// +/// Maps node addresses to their tree coordinates, enabling data packets +/// to be routed without carrying coordinates in every packet. Populated +/// by SessionSetup packets. +#[derive(Clone, Debug)] +pub struct CoordCache { + /// NodeAddr -> coordinates mapping. + entries: HashMap, + /// Maximum number of entries. + max_entries: usize, + /// Default TTL for entries (milliseconds). + default_ttl_ms: u64, +} + +impl CoordCache { + /// Create a new coordinate cache. + pub fn new(max_entries: usize, default_ttl_ms: u64) -> Self { + Self { + entries: HashMap::with_capacity(max_entries.min(1000)), + max_entries, + default_ttl_ms, + } + } + + /// Create a cache with default parameters. + pub fn with_defaults() -> Self { + Self::new(DEFAULT_COORD_CACHE_SIZE, DEFAULT_COORD_CACHE_TTL_MS) + } + + /// Get the maximum capacity. + pub fn max_entries(&self) -> usize { + self.max_entries + } + + /// Get the default TTL. + pub fn default_ttl_ms(&self) -> u64 { + self.default_ttl_ms + } + + /// Set the default TTL. + pub fn set_default_ttl_ms(&mut self, ttl_ms: u64) { + self.default_ttl_ms = ttl_ms; + } + + /// Insert or update a cache entry. + pub fn insert(&mut self, addr: NodeAddr, coords: TreeCoordinate, current_time_ms: u64) { + // Update existing entry if present + if let Some(entry) = self.entries.get_mut(&addr) { + entry.update(coords, current_time_ms, self.default_ttl_ms); + return; + } + + // Evict if at capacity + if self.entries.len() >= self.max_entries { + self.evict_one(current_time_ms); + } + + let entry = CacheEntry::new(coords, current_time_ms, self.default_ttl_ms); + self.entries.insert(addr, entry); + } + + /// Insert with a custom TTL. + pub fn insert_with_ttl( + &mut self, + addr: NodeAddr, + coords: TreeCoordinate, + current_time_ms: u64, + ttl_ms: u64, + ) { + if let Some(entry) = self.entries.get_mut(&addr) { + entry.update(coords, current_time_ms, ttl_ms); + return; + } + + if self.entries.len() >= self.max_entries { + self.evict_one(current_time_ms); + } + + let entry = CacheEntry::new(coords, current_time_ms, ttl_ms); + self.entries.insert(addr, entry); + } + + /// Look up coordinates for an address (without touching). + pub fn get(&self, addr: &NodeAddr, current_time_ms: u64) -> Option<&TreeCoordinate> { + self.entries.get(addr).and_then(|entry| { + if entry.is_expired(current_time_ms) { + None + } else { + Some(entry.coords()) + } + }) + } + + /// Look up coordinates and touch (update last_used). + pub fn get_and_touch( + &mut self, + addr: &NodeAddr, + current_time_ms: u64, + ) -> Option<&TreeCoordinate> { + // Check and remove if expired + if let Some(entry) = self.entries.get(addr) + && entry.is_expired(current_time_ms) + { + self.entries.remove(addr); + return None; + } + + // Touch and return + if let Some(entry) = self.entries.get_mut(addr) { + entry.touch(current_time_ms); + Some(entry.coords()) + } else { + None + } + } + + /// Get the full cache entry. + pub fn get_entry(&self, addr: &NodeAddr) -> Option<&CacheEntry> { + self.entries.get(addr) + } + + /// Remove an entry. + pub fn remove(&mut self, addr: &NodeAddr) -> Option { + self.entries.remove(addr) + } + + /// Check if an address is cached (and not expired). + pub fn contains(&self, addr: &NodeAddr, current_time_ms: u64) -> bool { + self.get(addr, current_time_ms).is_some() + } + + /// Number of entries (including expired). + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Check if empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Remove all expired entries. + pub fn purge_expired(&mut self, current_time_ms: u64) -> usize { + let before = self.entries.len(); + self.entries + .retain(|_, entry| !entry.is_expired(current_time_ms)); + before - self.entries.len() + } + + /// Clear all entries. + pub fn clear(&mut self) { + self.entries.clear(); + } + + /// Evict one entry (expired first, then LRU). + fn evict_one(&mut self, current_time_ms: u64) { + // First try to evict an expired entry + let expired_key = self + .entries + .iter() + .find(|(_, e)| e.is_expired(current_time_ms)) + .map(|(k, _)| *k); + + if let Some(key) = expired_key { + self.entries.remove(&key); + return; + } + + // Otherwise evict LRU (oldest last_used) + let lru_key = self + .entries + .iter() + .max_by_key(|(_, e)| e.idle_time(current_time_ms)) + .map(|(k, _)| *k); + + if let Some(key) = lru_key { + self.entries.remove(&key); + } + } + + /// Get cache statistics. + pub fn stats(&self, current_time_ms: u64) -> CacheStats { + let mut expired = 0; + let mut total_age = 0u64; + + for entry in self.entries.values() { + if entry.is_expired(current_time_ms) { + expired += 1; + } + total_age += entry.age(current_time_ms); + } + + CacheStats { + entries: self.entries.len(), + max_entries: self.max_entries, + expired, + avg_age_ms: if self.entries.is_empty() { + 0 + } else { + total_age / self.entries.len() as u64 + }, + } + } +} + +impl Default for CoordCache { + fn default() -> Self { + Self::with_defaults() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_node_addr(val: u8) -> NodeAddr { + let mut bytes = [0u8; 16]; + bytes[0] = val; + NodeAddr::from_bytes(bytes) + } + + fn make_coords(ids: &[u8]) -> TreeCoordinate { + TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() + } + + #[test] + fn test_coord_cache_basic() { + let mut cache = CoordCache::new(100, 1000); + let addr = make_node_addr(1); + let coords = make_coords(&[1, 0]); + + cache.insert(addr, coords.clone(), 0); + + assert!(cache.contains(&addr, 0)); + assert_eq!(cache.get(&addr, 0), Some(&coords)); + assert_eq!(cache.len(), 1); + } + + #[test] + fn test_coord_cache_expiry() { + let mut cache = CoordCache::new(100, 1000); + let addr = make_node_addr(1); + let coords = make_coords(&[1, 0]); + + cache.insert(addr, coords, 0); + + assert!(cache.contains(&addr, 500)); + assert!(!cache.contains(&addr, 1500)); + } + + #[test] + fn test_coord_cache_update() { + let mut cache = CoordCache::new(100, 1000); + let addr = make_node_addr(1); + + cache.insert(addr, make_coords(&[1, 0]), 0); + cache.insert(addr, make_coords(&[1, 2, 0]), 500); + + assert_eq!(cache.len(), 1); + let coords = cache.get(&addr, 500).unwrap(); + assert_eq!(coords.depth(), 2); + } + + #[test] + fn test_coord_cache_eviction() { + let mut cache = CoordCache::new(2, 10000); + + let addr1 = make_node_addr(1); + let addr2 = make_node_addr(2); + let addr3 = make_node_addr(3); + + cache.insert(addr1, make_coords(&[1, 0]), 0); + cache.insert(addr2, make_coords(&[2, 0]), 100); + + // Touch addr2 to make it more recent + let _ = cache.get_and_touch(&addr2, 200); + + // Insert addr3, should evict addr1 (LRU) + cache.insert(addr3, make_coords(&[3, 0]), 300); + + assert!(!cache.contains(&addr1, 300)); + assert!(cache.contains(&addr2, 300)); + assert!(cache.contains(&addr3, 300)); + } + + #[test] + fn test_coord_cache_evict_expired_first() { + let mut cache = CoordCache::new(2, 100); + + cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); + cache.insert(make_node_addr(2), make_coords(&[2, 0]), 50); + + // At time 150, addr1 is expired, addr2 is not + cache.insert(make_node_addr(3), make_coords(&[3, 0]), 150); + + // addr1 should be evicted (expired), not addr2 (LRU but not expired) + assert!(!cache.contains(&make_node_addr(1), 150)); + assert!(cache.contains(&make_node_addr(2), 150)); + assert!(cache.contains(&make_node_addr(3), 150)); + } + + #[test] + fn test_coord_cache_purge_expired() { + let mut cache = CoordCache::new(100, 100); + + cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); // expires at 100 + cache.insert(make_node_addr(2), make_coords(&[2, 0]), 50); // expires at 150 + cache.insert(make_node_addr(3), make_coords(&[3, 0]), 200); // expires at 300 + + assert_eq!(cache.len(), 3); + + let purged = cache.purge_expired(151); // both addr1 and addr2 expired + + // Entry 1 and 2 expired, entry 3 still valid + assert_eq!(purged, 2); + assert_eq!(cache.len(), 1); + assert!(cache.contains(&make_node_addr(3), 151)); + } + + #[test] + fn test_coord_cache_stats() { + let mut cache = CoordCache::new(100, 100); + + cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); + cache.insert(make_node_addr(2), make_coords(&[2, 0]), 50); + + let stats = cache.stats(150); + + assert_eq!(stats.entries, 2); + assert_eq!(stats.max_entries, 100); + assert_eq!(stats.expired, 1); // addr1 expired + assert!(stats.avg_age_ms > 0); + } + + #[test] + fn test_coord_cache_insert_with_ttl() { + let mut cache = CoordCache::new(100, 1000); + let addr = make_node_addr(1); + + cache.insert_with_ttl(addr, make_coords(&[1, 0]), 0, 200); + + // Should expire at 200, not the default 1000 + assert!(cache.contains(&addr, 100)); + assert!(!cache.contains(&addr, 201)); + } + + #[test] + fn test_coord_cache_insert_with_ttl_update() { + let mut cache = CoordCache::new(100, 1000); + let addr = make_node_addr(1); + + cache.insert_with_ttl(addr, make_coords(&[1, 0]), 0, 200); + cache.insert_with_ttl(addr, make_coords(&[1, 2, 0]), 100, 300); + + assert_eq!(cache.len(), 1); + let coords = cache.get(&addr, 100).unwrap(); + assert_eq!(coords.depth(), 2); + // New TTL: 100 + 300 = 400 + assert!(cache.contains(&addr, 399)); + assert!(!cache.contains(&addr, 401)); + } + + #[test] + fn test_coord_cache_get_and_touch_removes_expired() { + let mut cache = CoordCache::new(100, 100); + let addr = make_node_addr(1); + + cache.insert(addr, make_coords(&[1, 0]), 0); + assert_eq!(cache.len(), 1); + + // Entry expired at time 200 + let result = cache.get_and_touch(&addr, 200); + assert!(result.is_none()); + // Entry should be removed from the map + assert_eq!(cache.len(), 0); + } + + #[test] + fn test_coord_cache_get_entry() { + let mut cache = CoordCache::new(100, 1000); + let addr = make_node_addr(1); + + cache.insert(addr, make_coords(&[1, 0]), 500); + + let entry = cache.get_entry(&addr).unwrap(); + assert_eq!(entry.created_at(), 500); + assert_eq!(entry.expires_at(), 1500); + + assert!(cache.get_entry(&make_node_addr(99)).is_none()); + } + + #[test] + fn test_coord_cache_remove() { + let mut cache = CoordCache::new(100, 1000); + let addr = make_node_addr(1); + + cache.insert(addr, make_coords(&[1, 0]), 0); + assert_eq!(cache.len(), 1); + + let removed = cache.remove(&addr); + assert!(removed.is_some()); + assert_eq!(cache.len(), 0); + + // Removing again returns None + assert!(cache.remove(&addr).is_none()); + } + + #[test] + fn test_coord_cache_clear_and_is_empty() { + let mut cache = CoordCache::new(100, 1000); + + assert!(cache.is_empty()); + + cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); + cache.insert(make_node_addr(2), make_coords(&[2, 0]), 0); + + assert!(!cache.is_empty()); + + cache.clear(); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + } + + #[test] + fn test_coord_cache_default() { + let cache = CoordCache::default(); + + assert_eq!(cache.max_entries(), DEFAULT_COORD_CACHE_SIZE); + assert_eq!(cache.default_ttl_ms(), DEFAULT_COORD_CACHE_TTL_MS); + assert!(cache.is_empty()); + } + + #[test] + fn test_coord_cache_set_default_ttl() { + let mut cache = CoordCache::new(100, 1000); + let addr = make_node_addr(1); + + cache.set_default_ttl_ms(200); + assert_eq!(cache.default_ttl_ms(), 200); + + cache.insert(addr, make_coords(&[1, 0]), 0); + // New TTL applies: expires at 200 + assert!(cache.contains(&addr, 100)); + assert!(!cache.contains(&addr, 201)); + } + + #[test] + fn test_coord_cache_stats_empty() { + let cache = CoordCache::new(100, 1000); + let stats = cache.stats(0); + + assert_eq!(stats.entries, 0); + assert_eq!(stats.max_entries, 100); + assert_eq!(stats.expired, 0); + assert_eq!(stats.avg_age_ms, 0); + } +} diff --git a/src/cache/entry.rs b/src/cache/entry.rs new file mode 100644 index 0000000..c82f62d --- /dev/null +++ b/src/cache/entry.rs @@ -0,0 +1,176 @@ +//! Cache entry with TTL and LRU tracking. + +use crate::tree::TreeCoordinate; + +/// A cached coordinate entry. +#[derive(Clone, Debug)] +pub struct CacheEntry { + /// The cached coordinates. + coords: TreeCoordinate, + /// When this entry was created (Unix milliseconds). + created_at: u64, + /// When this entry was last used (Unix milliseconds). + last_used: u64, + /// When this entry expires (Unix milliseconds). + expires_at: u64, +} + +impl CacheEntry { + /// Create a new cache entry. + pub fn new(coords: TreeCoordinate, current_time_ms: u64, ttl_ms: u64) -> Self { + Self { + coords, + created_at: current_time_ms, + last_used: current_time_ms, + expires_at: current_time_ms.saturating_add(ttl_ms), + } + } + + /// Get the cached coordinates. + pub fn coords(&self) -> &TreeCoordinate { + &self.coords + } + + /// Get the creation timestamp. + pub fn created_at(&self) -> u64 { + self.created_at + } + + /// Get the last used timestamp. + pub fn last_used(&self) -> u64 { + self.last_used + } + + /// Get the expiry timestamp. + pub fn expires_at(&self) -> u64 { + self.expires_at + } + + /// Check if this entry has expired. + pub fn is_expired(&self, current_time_ms: u64) -> bool { + current_time_ms > self.expires_at + } + + /// Touch the entry to update last_used time. + pub fn touch(&mut self, current_time_ms: u64) { + self.last_used = current_time_ms; + } + + /// Refresh the expiry time. + pub fn refresh(&mut self, current_time_ms: u64, ttl_ms: u64) { + self.expires_at = current_time_ms.saturating_add(ttl_ms); + self.last_used = current_time_ms; + } + + /// Update the coordinates and refresh timestamps. + pub fn update(&mut self, coords: TreeCoordinate, current_time_ms: u64, ttl_ms: u64) { + self.coords = coords; + self.last_used = current_time_ms; + self.expires_at = current_time_ms.saturating_add(ttl_ms); + } + + /// Time since last use (for LRU eviction). + pub fn idle_time(&self, current_time_ms: u64) -> u64 { + current_time_ms.saturating_sub(self.last_used) + } + + /// Age of the entry. + pub fn age(&self, current_time_ms: u64) -> u64 { + current_time_ms.saturating_sub(self.created_at) + } + + /// Time until expiry (0 if already expired). + pub fn time_to_expiry(&self, current_time_ms: u64) -> u64 { + self.expires_at.saturating_sub(current_time_ms) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::NodeAddr; + + fn make_node_addr(val: u8) -> NodeAddr { + let mut bytes = [0u8; 16]; + bytes[0] = val; + NodeAddr::from_bytes(bytes) + } + + fn make_coords(ids: &[u8]) -> TreeCoordinate { + TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() + } + + #[test] + fn test_cache_entry_expiry() { + let coords = make_coords(&[1, 0]); + let entry = CacheEntry::new(coords, 1000, 500); + + assert!(!entry.is_expired(1000)); + assert!(!entry.is_expired(1500)); // expires_at = 1500, not yet expired + assert!(entry.is_expired(1501)); // one ms after expiry + assert!(entry.is_expired(2000)); + } + + #[test] + fn test_cache_entry_refresh() { + let coords = make_coords(&[1, 0]); + let mut entry = CacheEntry::new(coords, 1000, 500); + + assert!(entry.is_expired(1501)); // expires_at = 1500 + + entry.refresh(1400, 500); // new expires_at = 1900 + + assert!(!entry.is_expired(1600)); + assert!(!entry.is_expired(1900)); // at exactly expiry, not expired + assert!(entry.is_expired(1901)); // one ms after expiry + } + + #[test] + fn test_cache_entry_times() { + let coords = make_coords(&[1, 0]); + let entry = CacheEntry::new(coords, 1000, 500); + + assert_eq!(entry.created_at(), 1000); + assert_eq!(entry.last_used(), 1000); + assert_eq!(entry.expires_at(), 1500); + assert_eq!(entry.age(1200), 200); + assert_eq!(entry.idle_time(1200), 200); + assert_eq!(entry.time_to_expiry(1200), 300); + assert_eq!(entry.time_to_expiry(1600), 0); + } + + #[test] + fn test_cache_entry_touch() { + let coords = make_coords(&[1, 0]); + let mut entry = CacheEntry::new(coords, 1000, 500); + + assert_eq!(entry.last_used(), 1000); + entry.touch(1300); + assert_eq!(entry.last_used(), 1300); + // Touch doesn't affect expiry + assert_eq!(entry.expires_at(), 1500); + } + + #[test] + fn test_cache_entry_update() { + let mut entry = CacheEntry::new(make_coords(&[1, 0]), 1000, 500); + + let new_coords = make_coords(&[1, 2, 0]); + entry.update(new_coords.clone(), 2000, 600); + + assert_eq!(entry.coords(), &new_coords); + assert_eq!(entry.last_used(), 2000); + assert_eq!(entry.expires_at(), 2600); + // created_at is unchanged + assert_eq!(entry.created_at(), 1000); + } + + #[test] + fn test_cache_entry_saturating_add() { + let coords = make_coords(&[1, 0]); + let entry = CacheEntry::new(coords, u64::MAX - 10, 100); + + // Should saturate to u64::MAX rather than overflow + assert_eq!(entry.expires_at(), u64::MAX); + } +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..32f3ae9 --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,102 @@ +//! Caching Entities +//! +//! Coordinate and route caching for FIPS routing. The CoordCache stores +//! address-to-coordinate mappings populated by session setup, while +//! RouteCache stores coordinates learned from discovery queries. + +mod coord_cache; +mod entry; +mod route_cache; + +use thiserror::Error; + +pub use coord_cache::{CoordCache, DEFAULT_COORD_CACHE_SIZE, DEFAULT_COORD_CACHE_TTL_MS}; +pub use entry::CacheEntry; +pub use route_cache::{CachedCoords, RouteCache, DEFAULT_ROUTE_CACHE_SIZE}; + +/// Errors related to cache operations. +#[derive(Debug, Error)] +pub enum CacheError { + #[error("cache full: max {max} entries")] + CacheFull { max: usize }, + + #[error("entry not found")] + NotFound, + + #[error("entry expired")] + Expired, +} + +/// Cache statistics. +#[derive(Clone, Debug)] +pub struct CacheStats { + /// Current number of entries. + pub entries: usize, + /// Maximum capacity. + pub max_entries: usize, + /// Number of expired entries. + pub expired: usize, + /// Average entry age in milliseconds. + pub avg_age_ms: u64, +} + +impl CacheStats { + /// Fill ratio (entries / max_entries). + pub fn fill_ratio(&self) -> f64 { + if self.max_entries == 0 { + 0.0 + } else { + self.entries as f64 / self.max_entries as f64 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_stats_fill_ratio() { + let stats = CacheStats { + entries: 50, + max_entries: 100, + expired: 0, + avg_age_ms: 0, + }; + assert!((stats.fill_ratio() - 0.5).abs() < f64::EPSILON); + } + + #[test] + fn test_cache_stats_fill_ratio_zero_max() { + let stats = CacheStats { + entries: 0, + max_entries: 0, + expired: 0, + avg_age_ms: 0, + }; + assert!((stats.fill_ratio() - 0.0).abs() < f64::EPSILON); + } + + #[test] + fn test_cache_stats_fill_ratio_full() { + let stats = CacheStats { + entries: 100, + max_entries: 100, + expired: 10, + avg_age_ms: 500, + }; + assert!((stats.fill_ratio() - 1.0).abs() < f64::EPSILON); + } + + #[test] + fn test_cache_error_display() { + let full = CacheError::CacheFull { max: 1000 }; + assert_eq!(full.to_string(), "cache full: max 1000 entries"); + + let not_found = CacheError::NotFound; + assert_eq!(not_found.to_string(), "entry not found"); + + let expired = CacheError::Expired; + assert_eq!(expired.to_string(), "entry expired"); + } +} diff --git a/src/cache/route_cache.rs b/src/cache/route_cache.rs new file mode 100644 index 0000000..0d8533b --- /dev/null +++ b/src/cache/route_cache.rs @@ -0,0 +1,364 @@ +//! Route cache for discovered destinations. +//! +//! Separate from CoordCache, this stores routes learned from the discovery +//! protocol (LookupRequest/LookupResponse) rather than session establishment. + +use std::collections::HashMap; + +use crate::tree::TreeCoordinate; +use crate::NodeAddr; + +/// Default maximum entries in route cache. +pub const DEFAULT_ROUTE_CACHE_SIZE: usize = 10_000; + +/// A cached route from discovery. +#[derive(Clone, Debug)] +pub struct CachedCoords { + /// The coordinates discovered. + coords: TreeCoordinate, + /// When this was discovered (Unix milliseconds). + discovered_at: u64, + /// Last time we used this route (Unix milliseconds). + last_used: u64, +} + +impl CachedCoords { + /// Create a new cached route. + pub fn new(coords: TreeCoordinate, discovered_at: u64) -> Self { + Self { + coords, + discovered_at, + last_used: discovered_at, + } + } + + /// Get the coordinates. + pub fn coords(&self) -> &TreeCoordinate { + &self.coords + } + + /// Get the discovery timestamp. + pub fn discovered_at(&self) -> u64 { + self.discovered_at + } + + /// Get the last used timestamp. + pub fn last_used(&self) -> u64 { + self.last_used + } + + /// Touch (update last_used). + pub fn touch(&mut self, current_time_ms: u64) { + self.last_used = current_time_ms; + } + + /// Age since discovery. + pub fn age(&self, current_time_ms: u64) -> u64 { + current_time_ms.saturating_sub(self.discovered_at) + } + + /// Idle time since last use. + pub fn idle_time(&self, current_time_ms: u64) -> u64 { + current_time_ms.saturating_sub(self.last_used) + } + + /// Update coordinates (re-discovered). + pub fn update(&mut self, coords: TreeCoordinate, current_time_ms: u64) { + self.coords = coords; + self.discovered_at = current_time_ms; + self.last_used = current_time_ms; + } +} + +/// Route cache for discovered destinations. +/// +/// Separate from CoordCache, this stores routes learned from the discovery +/// protocol (LookupRequest/LookupResponse) rather than session establishment. +/// Keyed by NodeAddr. +#[derive(Clone, Debug)] +pub struct RouteCache { + /// NodeAddr -> discovered coordinates. + entries: HashMap, + /// Maximum entries. + max_entries: usize, +} + +impl RouteCache { + /// Create a new route cache. + pub fn new(max_entries: usize) -> Self { + Self { + entries: HashMap::with_capacity(max_entries.min(1000)), + max_entries, + } + } + + /// Create with default capacity. + pub fn with_defaults() -> Self { + Self::new(DEFAULT_ROUTE_CACHE_SIZE) + } + + /// Get the maximum capacity. + pub fn max_entries(&self) -> usize { + self.max_entries + } + + /// Insert a discovered route. + pub fn insert(&mut self, node_addr: NodeAddr, coords: TreeCoordinate, current_time_ms: u64) { + // Update existing + if let Some(entry) = self.entries.get_mut(&node_addr) { + entry.update(coords, current_time_ms); + return; + } + + // Evict if full + if self.entries.len() >= self.max_entries { + self.evict_lru(current_time_ms); + } + + self.entries + .insert(node_addr, CachedCoords::new(coords, current_time_ms)); + } + + /// Look up a route (without touching). + pub fn get(&self, node_addr: &NodeAddr) -> Option<&CachedCoords> { + self.entries.get(node_addr) + } + + /// Look up and touch. + pub fn get_and_touch( + &mut self, + node_addr: &NodeAddr, + current_time_ms: u64, + ) -> Option<&TreeCoordinate> { + if let Some(entry) = self.entries.get_mut(node_addr) { + entry.touch(current_time_ms); + Some(entry.coords()) + } else { + None + } + } + + /// Remove a route (e.g., after route failure). + pub fn invalidate(&mut self, node_addr: &NodeAddr) -> Option { + self.entries.remove(node_addr) + } + + /// Check if a node is cached. + pub fn contains(&self, node_addr: &NodeAddr) -> bool { + self.entries.contains_key(node_addr) + } + + /// Number of cached routes. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Check if empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + /// Clear all routes. + pub fn clear(&mut self) { + self.entries.clear(); + } + + /// Evict routes older than a threshold. + pub fn evict_older_than(&mut self, max_age_ms: u64, current_time_ms: u64) -> usize { + let before = self.entries.len(); + self.entries + .retain(|_, entry| entry.age(current_time_ms) < max_age_ms); + before - self.entries.len() + } + + fn evict_lru(&mut self, current_time_ms: u64) { + let lru_id = self + .entries + .iter() + .max_by_key(|(_, e)| e.idle_time(current_time_ms)) + .map(|(k, _)| *k); + + if let Some(id) = lru_id { + self.entries.remove(&id); + } + } +} + +impl Default for RouteCache { + fn default() -> Self { + Self::with_defaults() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_node_addr(val: u8) -> NodeAddr { + let mut bytes = [0u8; 16]; + bytes[0] = val; + NodeAddr::from_bytes(bytes) + } + + fn make_coords(ids: &[u8]) -> TreeCoordinate { + TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap() + } + + #[test] + fn test_cached_coords() { + let coords = make_coords(&[1, 0]); + let mut cached = CachedCoords::new(coords.clone(), 1000); + + assert_eq!(cached.coords(), &coords); + assert_eq!(cached.discovered_at(), 1000); + assert_eq!(cached.last_used(), 1000); + + cached.touch(1500); + assert_eq!(cached.last_used(), 1500); + assert_eq!(cached.idle_time(1600), 100); + assert_eq!(cached.age(1600), 600); + } + + #[test] + fn test_route_cache_basic() { + let mut cache = RouteCache::new(100); + let node = make_node_addr(1); + let coords = make_coords(&[1, 0]); + + cache.insert(node, coords.clone(), 0); + + assert!(cache.contains(&node)); + assert_eq!(cache.get(&node).unwrap().coords(), &coords); + } + + #[test] + fn test_route_cache_invalidate() { + let mut cache = RouteCache::new(100); + let node = make_node_addr(1); + let coords = make_coords(&[1, 0]); + + cache.insert(node, coords, 0); + assert!(cache.contains(&node)); + + cache.invalidate(&node); + assert!(!cache.contains(&node)); + } + + #[test] + fn test_route_cache_lru_eviction() { + let mut cache = RouteCache::new(2); + + let node1 = make_node_addr(1); + let node2 = make_node_addr(2); + let node3 = make_node_addr(3); + + cache.insert(node1, make_coords(&[1, 0]), 0); + cache.insert(node2, make_coords(&[2, 0]), 100); + + // Touch node2 + let _ = cache.get_and_touch(&node2, 200); + + // Insert node3 + cache.insert(node3, make_coords(&[3, 0]), 300); + + // node1 should be evicted + assert!(!cache.contains(&node1)); + assert!(cache.contains(&node2)); + assert!(cache.contains(&node3)); + } + + #[test] + fn test_route_cache_evict_older_than() { + let mut cache = RouteCache::new(100); + + cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); + cache.insert(make_node_addr(2), make_coords(&[2, 0]), 500); + cache.insert(make_node_addr(3), make_coords(&[3, 0]), 1000); + + let evicted = cache.evict_older_than(600, 1000); + + assert_eq!(evicted, 1); // node1 is > 600ms old + assert_eq!(cache.len(), 2); + } + + #[test] + fn test_route_cache_update() { + let mut cache = RouteCache::new(100); + let node = make_node_addr(1); + + cache.insert(node, make_coords(&[1, 0]), 0); + cache.insert(node, make_coords(&[1, 2, 0]), 500); + + assert_eq!(cache.len(), 1); + let cached = cache.get(&node).unwrap(); + assert_eq!(cached.coords().depth(), 2); + assert_eq!(cached.discovered_at(), 500); + } + + #[test] + fn test_cached_coords_update() { + let mut cached = CachedCoords::new(make_coords(&[1, 0]), 1000); + + let new_coords = make_coords(&[1, 2, 0]); + cached.update(new_coords.clone(), 2000); + + assert_eq!(cached.coords(), &new_coords); + assert_eq!(cached.discovered_at(), 2000); + assert_eq!(cached.last_used(), 2000); + } + + #[test] + fn test_route_cache_get_and_touch() { + let mut cache = RouteCache::new(100); + let node = make_node_addr(1); + let coords = make_coords(&[1, 0]); + + cache.insert(node, coords.clone(), 0); + + let result = cache.get_and_touch(&node, 500); + assert_eq!(result, Some(&coords)); + + // Verify last_used was updated + let entry = cache.get(&node).unwrap(); + assert_eq!(entry.last_used(), 500); + } + + #[test] + fn test_route_cache_get_and_touch_missing() { + let mut cache = RouteCache::new(100); + let result = cache.get_and_touch(&make_node_addr(99), 0); + assert!(result.is_none()); + } + + #[test] + fn test_route_cache_clear_and_is_empty() { + let mut cache = RouteCache::new(100); + + assert!(cache.is_empty()); + + cache.insert(make_node_addr(1), make_coords(&[1, 0]), 0); + cache.insert(make_node_addr(2), make_coords(&[2, 0]), 0); + + assert!(!cache.is_empty()); + assert_eq!(cache.len(), 2); + + cache.clear(); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + } + + #[test] + fn test_route_cache_default() { + let cache = RouteCache::default(); + + assert_eq!(cache.max_entries(), DEFAULT_ROUTE_CACHE_SIZE); + assert!(cache.is_empty()); + } + + #[test] + fn test_route_cache_invalidate_missing() { + let mut cache = RouteCache::new(100); + let result = cache.invalidate(&make_node_addr(99)); + assert!(result.is_none()); + } +} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index 1cae67f..0000000 --- a/src/config.rs +++ /dev/null @@ -1,1318 +0,0 @@ -//! 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..." -//! ``` - -use crate::{Identity, IdentityError}; -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; -use thiserror::Error; - -/// 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, -} - -// ============================================================================ -// Node Configuration Subsections -// ============================================================================ - -/// Resource limits (`node.limits.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LimitsConfig { - /// Max handshake-phase connections (`node.limits.max_connections`). - #[serde(default = "LimitsConfig::default_max_connections")] - pub max_connections: usize, - /// Max authenticated peers (`node.limits.max_peers`). - #[serde(default = "LimitsConfig::default_max_peers")] - pub max_peers: usize, - /// Max active links (`node.limits.max_links`). - #[serde(default = "LimitsConfig::default_max_links")] - pub max_links: usize, - /// Max pending inbound handshakes (`node.limits.max_pending_inbound`). - #[serde(default = "LimitsConfig::default_max_pending_inbound")] - pub max_pending_inbound: usize, -} - -impl Default for LimitsConfig { - fn default() -> Self { - Self { - max_connections: 256, - max_peers: 128, - max_links: 256, - max_pending_inbound: 1000, - } - } -} - -impl LimitsConfig { - fn default_max_connections() -> usize { 256 } - fn default_max_peers() -> usize { 128 } - fn default_max_links() -> usize { 256 } - fn default_max_pending_inbound() -> usize { 1000 } -} - -/// Rate limiting (`node.rate_limit.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RateLimitConfig { - /// Token bucket burst capacity (`node.rate_limit.handshake_burst`). - #[serde(default = "RateLimitConfig::default_handshake_burst")] - pub handshake_burst: u32, - /// Tokens/sec refill rate (`node.rate_limit.handshake_rate`). - #[serde(default = "RateLimitConfig::default_handshake_rate")] - pub handshake_rate: f64, - /// Stale handshake cleanup timeout in seconds (`node.rate_limit.handshake_timeout_secs`). - #[serde(default = "RateLimitConfig::default_handshake_timeout_secs")] - pub handshake_timeout_secs: u64, -} - -impl Default for RateLimitConfig { - fn default() -> Self { - Self { - handshake_burst: 100, - handshake_rate: 10.0, - handshake_timeout_secs: 30, - } - } -} - -impl RateLimitConfig { - fn default_handshake_burst() -> u32 { 100 } - fn default_handshake_rate() -> f64 { 10.0 } - fn default_handshake_timeout_secs() -> u64 { 30 } -} - -/// Retry/backoff configuration (`node.retry.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct RetryConfig { - /// Max connection retry attempts (`node.retry.max_retries`). - #[serde(default = "RetryConfig::default_max_retries")] - pub max_retries: u32, - /// Base backoff interval in seconds (`node.retry.base_interval_secs`). - #[serde(default = "RetryConfig::default_base_interval_secs")] - pub base_interval_secs: u64, - /// Cap on exponential backoff in seconds (`node.retry.max_backoff_secs`). - #[serde(default = "RetryConfig::default_max_backoff_secs")] - pub max_backoff_secs: u64, -} - -impl Default for RetryConfig { - fn default() -> Self { - Self { - max_retries: 5, - base_interval_secs: 5, - max_backoff_secs: 300, - } - } -} - -impl RetryConfig { - fn default_max_retries() -> u32 { 5 } - fn default_base_interval_secs() -> u64 { 5 } - fn default_max_backoff_secs() -> u64 { 300 } -} - -/// Cache parameters (`node.cache.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CacheConfig { - /// Max entries in coord cache (`node.cache.coord_size`). - #[serde(default = "CacheConfig::default_coord_size")] - pub coord_size: usize, - /// Coord cache entry TTL in seconds (`node.cache.coord_ttl_secs`). - #[serde(default = "CacheConfig::default_coord_ttl_secs")] - pub coord_ttl_secs: u64, - /// Max entries in route cache (`node.cache.route_size`). - #[serde(default = "CacheConfig::default_route_size")] - pub route_size: usize, -} - -impl Default for CacheConfig { - fn default() -> Self { - Self { - coord_size: 50_000, - coord_ttl_secs: 300, - route_size: 10_000, - } - } -} - -impl CacheConfig { - fn default_coord_size() -> usize { 50_000 } - fn default_coord_ttl_secs() -> u64 { 300 } - fn default_route_size() -> usize { 10_000 } -} - -/// Discovery protocol (`node.discovery.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct DiscoveryConfig { - /// Hop limit for LookupRequest flood (`node.discovery.ttl`). - #[serde(default = "DiscoveryConfig::default_ttl")] - pub ttl: u8, - /// Lookup completion timeout in seconds (`node.discovery.timeout_secs`). - #[serde(default = "DiscoveryConfig::default_timeout_secs")] - pub timeout_secs: u64, - /// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`). - #[serde(default = "DiscoveryConfig::default_recent_expiry_secs")] - pub recent_expiry_secs: u64, -} - -impl Default for DiscoveryConfig { - fn default() -> Self { - Self { - ttl: 64, - timeout_secs: 10, - recent_expiry_secs: 10, - } - } -} - -impl DiscoveryConfig { - fn default_ttl() -> u8 { 64 } - fn default_timeout_secs() -> u64 { 10 } - fn default_recent_expiry_secs() -> u64 { 10 } -} - -/// Spanning tree (`node.tree.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct TreeConfig { - /// Root self-announcement interval in seconds (`node.tree.root_refresh_secs`). - #[serde(default = "TreeConfig::default_root_refresh_secs")] - pub root_refresh_secs: u64, - /// Per-peer TreeAnnounce rate limit in ms (`node.tree.announce_min_interval_ms`). - #[serde(default = "TreeConfig::default_announce_min_interval_ms")] - pub announce_min_interval_ms: u64, - /// Min depth improvement to switch parents (`node.tree.parent_switch_threshold`). - #[serde(default = "TreeConfig::default_parent_switch_threshold")] - pub parent_switch_threshold: usize, -} - -impl Default for TreeConfig { - fn default() -> Self { - Self { - root_refresh_secs: 1800, - announce_min_interval_ms: 500, - parent_switch_threshold: 1, - } - } -} - -impl TreeConfig { - fn default_root_refresh_secs() -> u64 { 1800 } - fn default_announce_min_interval_ms() -> u64 { 500 } - fn default_parent_switch_threshold() -> usize { 1 } -} - -/// Bloom filter (`node.bloom.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BloomConfig { - /// Debounce interval for filter updates in ms (`node.bloom.update_debounce_ms`). - #[serde(default = "BloomConfig::default_update_debounce_ms")] - pub update_debounce_ms: u64, -} - -impl Default for BloomConfig { - fn default() -> Self { - Self { update_debounce_ms: 500 } - } -} - -impl BloomConfig { - fn default_update_debounce_ms() -> u64 { 500 } -} - -/// Session/data plane (`node.session.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SessionConfig { - /// Default SessionDatagram hop limit (`node.session.default_hop_limit`). - #[serde(default = "SessionConfig::default_hop_limit")] - pub default_hop_limit: u8, - /// Queue depth per dest during session establishment (`node.session.pending_packets_per_dest`). - #[serde(default = "SessionConfig::default_pending_packets_per_dest")] - pub pending_packets_per_dest: usize, - /// Max destinations with pending packets (`node.session.pending_max_destinations`). - #[serde(default = "SessionConfig::default_pending_max_destinations")] - pub pending_max_destinations: usize, -} - -impl Default for SessionConfig { - fn default() -> Self { - Self { - default_hop_limit: 64, - pending_packets_per_dest: 16, - pending_max_destinations: 256, - } - } -} - -impl SessionConfig { - fn default_hop_limit() -> u8 { 64 } - fn default_pending_packets_per_dest() -> usize { 16 } - fn default_pending_max_destinations() -> usize { 256 } -} - -/// Internal buffers (`node.buffers.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct BuffersConfig { - /// Transport→Node packet channel capacity (`node.buffers.packet_channel`). - #[serde(default = "BuffersConfig::default_packet_channel")] - pub packet_channel: usize, - /// TUN→Node outbound channel capacity (`node.buffers.tun_channel`). - #[serde(default = "BuffersConfig::default_tun_channel")] - pub tun_channel: usize, - /// DNS→Node identity channel capacity (`node.buffers.dns_channel`). - #[serde(default = "BuffersConfig::default_dns_channel")] - pub dns_channel: usize, -} - -impl Default for BuffersConfig { - fn default() -> Self { - Self { - packet_channel: 1024, - tun_channel: 1024, - dns_channel: 64, - } - } -} - -impl BuffersConfig { - fn default_packet_channel() -> usize { 1024 } - fn default_tun_channel() -> usize { 1024 } - fn default_dns_channel() -> usize { 64 } -} - -// ============================================================================ -// Node Configuration (Root) -// ============================================================================ - -/// Node configuration (`node.*`). -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct NodeConfig { - /// Identity configuration (`node.identity.*`). - #[serde(default)] - pub identity: IdentityConfig, - - /// Leaf-only mode (`node.leaf_only`). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub leaf_only: bool, - - /// RX loop maintenance tick period in seconds (`node.tick_interval_secs`). - #[serde(default = "NodeConfig::default_tick_interval_secs")] - pub tick_interval_secs: u64, - - /// Initial RTT estimate for new links in ms (`node.base_rtt_ms`). - #[serde(default = "NodeConfig::default_base_rtt_ms")] - pub base_rtt_ms: u64, - - /// Resource limits (`node.limits.*`). - #[serde(default)] - pub limits: LimitsConfig, - - /// Rate limiting (`node.rate_limit.*`). - #[serde(default)] - pub rate_limit: RateLimitConfig, - - /// Retry/backoff (`node.retry.*`). - #[serde(default)] - pub retry: RetryConfig, - - /// Cache parameters (`node.cache.*`). - #[serde(default)] - pub cache: CacheConfig, - - /// Discovery protocol (`node.discovery.*`). - #[serde(default)] - pub discovery: DiscoveryConfig, - - /// Spanning tree (`node.tree.*`). - #[serde(default)] - pub tree: TreeConfig, - - /// Bloom filter (`node.bloom.*`). - #[serde(default)] - pub bloom: BloomConfig, - - /// Session/data plane (`node.session.*`). - #[serde(default)] - pub session: SessionConfig, - - /// Internal buffers (`node.buffers.*`). - #[serde(default)] - pub buffers: BuffersConfig, -} - -impl Default for NodeConfig { - fn default() -> Self { - Self { - identity: IdentityConfig::default(), - leaf_only: false, - tick_interval_secs: 1, - base_rtt_ms: 100, - limits: LimitsConfig::default(), - rate_limit: RateLimitConfig::default(), - retry: RetryConfig::default(), - cache: CacheConfig::default(), - discovery: DiscoveryConfig::default(), - tree: TreeConfig::default(), - bloom: BloomConfig::default(), - session: SessionConfig::default(), - buffers: BuffersConfig::default(), - } - } -} - -impl NodeConfig { - fn default_tick_interval_secs() -> u64 { 1 } - fn default_base_rtt_ms() -> u64 { 100 } -} - -/// Default TUN device name. -const DEFAULT_TUN_NAME: &str = "fips0"; - -/// Default TUN MTU (IPv6 minimum). -const DEFAULT_TUN_MTU: u16 = 1280; - -/// Default DNS responder bind address. -const DEFAULT_DNS_BIND_ADDR: &str = "127.0.0.1"; - -/// Default DNS responder port. -const DEFAULT_DNS_PORT: u16 = 5354; - -/// Default DNS record TTL in seconds (5 minutes). -const DEFAULT_DNS_TTL: u32 = 300; - -/// DNS responder configuration (`dns.*`). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct DnsConfig { - /// Enable DNS responder (`dns.enabled`). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub enabled: bool, - - /// Bind address (`dns.bind_addr`). Defaults to "127.0.0.1". - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bind_addr: Option, - - /// Listen port (`dns.port`). Defaults to 5354. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub port: Option, - - /// AAAA record TTL in seconds (`dns.ttl`). Defaults to 300. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub ttl: Option, -} - -impl DnsConfig { - /// Get the bind address, using default if not configured. - pub fn bind_addr(&self) -> &str { - self.bind_addr.as_deref().unwrap_or(DEFAULT_DNS_BIND_ADDR) - } - - /// Get the port, using default if not configured. - pub fn port(&self) -> u16 { - self.port.unwrap_or(DEFAULT_DNS_PORT) - } - - /// Get the TTL, using default if not configured. - pub fn ttl(&self) -> u32 { - self.ttl.unwrap_or(DEFAULT_DNS_TTL) - } -} - -/// Default UDP bind address. -const DEFAULT_UDP_BIND_ADDR: &str = "0.0.0.0:4000"; - -/// Default UDP MTU (IPv6 minimum). -const DEFAULT_UDP_MTU: u16 = 1280; - -/// TUN interface configuration (`tun.*`). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TunConfig { - /// Enable TUN interface (`tun.enabled`). - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub enabled: bool, - - /// TUN device name (`tun.name`). Defaults to "fips0". - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - - /// TUN MTU (`tun.mtu`). Defaults to 1280 (IPv6 minimum). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mtu: Option, - -} - -impl TunConfig { - /// Get the TUN device name, using default if not configured. - pub fn name(&self) -> &str { - self.name.as_deref().unwrap_or(DEFAULT_TUN_NAME) - } - - /// Get the TUN MTU, using default if not configured. - pub fn mtu(&self) -> u16 { - self.mtu.unwrap_or(DEFAULT_TUN_MTU) - } -} - -/// UDP transport instance configuration. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct UdpConfig { - /// Bind address (`bind_addr`). Defaults to "0.0.0.0:4000". - #[serde(default, skip_serializing_if = "Option::is_none")] - pub bind_addr: Option, - - /// UDP MTU (`mtu`). Defaults to 1280 (IPv6 minimum). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub mtu: Option, -} - -impl UdpConfig { - /// Get the bind address, using default if not configured. - pub fn bind_addr(&self) -> &str { - self.bind_addr.as_deref().unwrap_or(DEFAULT_UDP_BIND_ADDR) - } - - /// Get the UDP MTU, using default if not configured. - pub fn mtu(&self) -> u16 { - self.mtu.unwrap_or(DEFAULT_UDP_MTU) - } -} - -// ============================================================================ -// Transport Configuration -// ============================================================================ - -use std::collections::HashMap; - -/// Transport instances - either a single config or named instances. -/// -/// Allows both simple single-instance config: -/// ```yaml -/// transports: -/// udp: -/// bind_addr: "0.0.0.0:4000" -/// ``` -/// -/// And multiple named instances: -/// ```yaml -/// transports: -/// udp: -/// main: -/// bind_addr: "0.0.0.0:4000" -/// backup: -/// bind_addr: "192.168.1.100:4001" -/// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(untagged)] -pub enum TransportInstances { - /// Single unnamed instance (config fields directly under transport type). - Single(T), - /// Multiple named instances. - Named(HashMap), -} - -impl TransportInstances { - /// Get the number of instances. - pub fn len(&self) -> usize { - match self { - TransportInstances::Single(_) => 1, - TransportInstances::Named(map) => map.len(), - } - } - - /// Check if there are no instances. - pub fn is_empty(&self) -> bool { - match self { - TransportInstances::Single(_) => false, - TransportInstances::Named(map) => map.is_empty(), - } - } - - /// Iterate over all instances as (name, config) pairs. - /// - /// Single instances have `None` as the name. - /// Named instances have `Some(name)`. - pub fn iter(&self) -> impl Iterator, &T)> { - match self { - TransportInstances::Single(config) => { - vec![(None, config)].into_iter() - } - TransportInstances::Named(map) => { - map.iter() - .map(|(k, v)| (Some(k.as_str()), v)) - .collect::>() - .into_iter() - } - } - } -} - -impl Default for TransportInstances { - fn default() -> Self { - TransportInstances::Named(HashMap::new()) - } -} - -/// Transports configuration section. -/// -/// Each transport type can have either a single instance (config directly -/// under the type name) or multiple named instances. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct TransportsConfig { - /// UDP transport instances. - #[serde(default, skip_serializing_if = "is_transport_empty")] - pub udp: TransportInstances, - - // Future transport types: - // #[serde(default, skip_serializing_if = "is_transport_empty")] - // pub tcp: TransportInstances, - // - // #[serde(default, skip_serializing_if = "is_transport_empty")] - // pub tor: TransportInstances, -} - -/// Helper for skip_serializing_if on TransportInstances. -fn is_transport_empty(instances: &TransportInstances) -> bool { - instances.is_empty() -} - -impl TransportsConfig { - /// Check if any transports are configured. - pub fn is_empty(&self) -> bool { - self.udp.is_empty() - // && self.tcp.is_empty() - // && self.tor.is_empty() - } - - /// Merge another TransportsConfig into this one. - /// - /// Non-empty transport sections from `other` replace those in `self`. - pub fn merge(&mut self, other: TransportsConfig) { - if !other.udp.is_empty() { - self.udp = other.udp; - } - // Future: same for tcp, tor, etc. - } -} - -// ============================================================================ -// Peer Configuration -// ============================================================================ - -/// Connection policy for a peer. -/// -/// Determines when and how to establish a connection to a peer. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ConnectPolicy { - /// Connect to this peer automatically on node startup. - /// This is the only policy supported in the initial implementation. - #[default] - AutoConnect, - - /// Connect only when traffic needs to be routed through this peer (future). - OnDemand, - - /// Wait for explicit API call to connect (future). - Manual, -} - -/// A transport-specific address for reaching a peer. -/// -/// Each peer can have multiple addresses across different transports, -/// allowing fallback if one transport is unavailable. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PeerAddress { - /// Transport type (e.g., "udp", "tor", "ethernet"). - pub transport: String, - - /// Transport-specific address string. - /// - /// Format depends on transport type: - /// - UDP: "host:port" (e.g., "192.168.1.1:4000") - /// - Tor: "onion_address:port" (e.g., "xyz...abc.onion:4000") - /// - Ethernet: "interface/mac" (future) - pub addr: String, - - /// Priority for address selection (lower = preferred). - /// When multiple addresses are available, lower priority addresses - /// are tried first. - #[serde(default = "default_priority")] - pub priority: u8, -} - -fn default_priority() -> u8 { - 100 -} - -impl PeerAddress { - /// Create a new peer address. - pub fn new(transport: impl Into, addr: impl Into) -> Self { - Self { - transport: transport.into(), - addr: addr.into(), - priority: default_priority(), - } - } - - /// Create a new peer address with priority. - pub fn with_priority(transport: impl Into, addr: impl Into, priority: u8) -> Self { - Self { - transport: transport.into(), - addr: addr.into(), - priority, - } - } -} - -/// Configuration for a known peer. -/// -/// Peers are identified by their Nostr public key (npub) and can have -/// multiple transport addresses for reaching them. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PeerConfig { - /// The peer's Nostr public key in npub (bech32) or hex format. - pub npub: String, - - /// Human-readable alias for the peer (optional). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub alias: Option, - - /// Transport addresses for reaching this peer. - /// At least one address is required. - pub addresses: Vec, - - /// Connection policy for this peer. - #[serde(default)] - pub connect_policy: ConnectPolicy, -} - -impl PeerConfig { - /// Create a new peer config with a single address. - pub fn new(npub: impl Into, transport: impl Into, addr: impl Into) -> Self { - Self { - npub: npub.into(), - alias: None, - addresses: vec![PeerAddress::new(transport, addr)], - connect_policy: ConnectPolicy::default(), - } - } - - /// Set an alias for the peer. - pub fn with_alias(mut self, alias: impl Into) -> Self { - self.alias = Some(alias.into()); - self - } - - /// Add an additional address for the peer. - pub fn with_address(mut self, addr: PeerAddress) -> Self { - self.addresses.push(addr); - self - } - - /// Get addresses sorted by priority (lowest first). - pub fn addresses_by_priority(&self) -> Vec<&PeerAddress> { - let mut addrs: Vec<_> = self.addresses.iter().collect(); - addrs.sort_by_key(|a| a.priority); - addrs - } - - /// Check if this peer should auto-connect on startup. - pub fn is_auto_connect(&self) -> bool { - matches!(self.connect_policy, ConnectPolicy::AutoConnect) - } -} - -/// 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, -} - -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), 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), 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 { - 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 { - 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 - if other.dns.enabled { - self.dns.enabled = true; - } - 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 { - 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 { - self.peers.iter().filter(|p| p.is_auto_connect()) - } - - /// Serialize this configuration to YAML. - pub fn to_yaml(&self) -> Result { - serde_yaml::to_string(self) - } -} - -#[cfg(test)] -mod tests { - use super::*; - 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:4000" - 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:4000"); - 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:4000" - backup: - bind_addr: "192.168.1.100:4001" - 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:4000"); - assert_eq!(instances[&Some("backup")].bind_addr(), "192.168.1.100:4001"); - 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:4000".to_string()), - mtu: None, - }); - 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:4000" - priority: 1 - - transport: tor - addr: "xyz.onion:4000" - 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:4000" -"#; - 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:4000" - - npub: "npub1peer2" - addresses: - - transport: udp - addr: "10.0.0.2:4000" - 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:4000") - .with_alias("test-peer") - .with_address(PeerAddress::with_priority("tor", "xyz.onion:4000", 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()); - } -} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..54b1a02 --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,620 @@ +//! 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, DiscoveryConfig, LimitsConfig, NodeConfig, + RateLimitConfig, RetryConfig, SessionConfig, TreeConfig, +}; +pub use peer::{ConnectPolicy, PeerAddress, PeerConfig}; +pub use transport::{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, +} + +/// 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, +} + +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), 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), 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 { + 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 { + 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 + if other.dns.enabled { + self.dns.enabled = true; + } + 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 { + 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 { + self.peers.iter().filter(|p| p.is_auto_connect()) + } + + /// Serialize this configuration to YAML. + pub fn to_yaml(&self) -> Result { + 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:4000" + 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:4000"); + 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:4000" + backup: + bind_addr: "192.168.1.100:4001" + 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:4000"); + assert_eq!(instances[&Some("backup")].bind_addr(), "192.168.1.100:4001"); + 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:4000".to_string()), + mtu: None, + }); + 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:4000" + priority: 1 + - transport: tor + addr: "xyz.onion:4000" + 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:4000" +"#; + 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:4000" + - npub: "npub1peer2" + addresses: + - transport: udp + addr: "10.0.0.2:4000" + 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:4000") + .with_alias("test-peer") + .with_address(PeerAddress::with_priority("tor", "xyz.onion:4000", 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()); + } +} diff --git a/src/config/node.rs b/src/config/node.rs new file mode 100644 index 0000000..2b52159 --- /dev/null +++ b/src/config/node.rs @@ -0,0 +1,361 @@ +//! Node configuration subsections. +//! +//! All the `node.*` configuration parameters: resource limits, rate limiting, +//! retry/backoff, cache sizing, discovery, spanning tree, bloom filters, +//! session management, and internal buffers. + +use serde::{Deserialize, Serialize}; + +use super::IdentityConfig; + +// ============================================================================ +// Node Configuration Subsections +// ============================================================================ + +/// Resource limits (`node.limits.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LimitsConfig { + /// Max handshake-phase connections (`node.limits.max_connections`). + #[serde(default = "LimitsConfig::default_max_connections")] + pub max_connections: usize, + /// Max authenticated peers (`node.limits.max_peers`). + #[serde(default = "LimitsConfig::default_max_peers")] + pub max_peers: usize, + /// Max active links (`node.limits.max_links`). + #[serde(default = "LimitsConfig::default_max_links")] + pub max_links: usize, + /// Max pending inbound handshakes (`node.limits.max_pending_inbound`). + #[serde(default = "LimitsConfig::default_max_pending_inbound")] + pub max_pending_inbound: usize, +} + +impl Default for LimitsConfig { + fn default() -> Self { + Self { + max_connections: 256, + max_peers: 128, + max_links: 256, + max_pending_inbound: 1000, + } + } +} + +impl LimitsConfig { + fn default_max_connections() -> usize { 256 } + fn default_max_peers() -> usize { 128 } + fn default_max_links() -> usize { 256 } + fn default_max_pending_inbound() -> usize { 1000 } +} + +/// Rate limiting (`node.rate_limit.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RateLimitConfig { + /// Token bucket burst capacity (`node.rate_limit.handshake_burst`). + #[serde(default = "RateLimitConfig::default_handshake_burst")] + pub handshake_burst: u32, + /// Tokens/sec refill rate (`node.rate_limit.handshake_rate`). + #[serde(default = "RateLimitConfig::default_handshake_rate")] + pub handshake_rate: f64, + /// Stale handshake cleanup timeout in seconds (`node.rate_limit.handshake_timeout_secs`). + #[serde(default = "RateLimitConfig::default_handshake_timeout_secs")] + pub handshake_timeout_secs: u64, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + handshake_burst: 100, + handshake_rate: 10.0, + handshake_timeout_secs: 30, + } + } +} + +impl RateLimitConfig { + fn default_handshake_burst() -> u32 { 100 } + fn default_handshake_rate() -> f64 { 10.0 } + fn default_handshake_timeout_secs() -> u64 { 30 } +} + +/// Retry/backoff configuration (`node.retry.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RetryConfig { + /// Max connection retry attempts (`node.retry.max_retries`). + #[serde(default = "RetryConfig::default_max_retries")] + pub max_retries: u32, + /// Base backoff interval in seconds (`node.retry.base_interval_secs`). + #[serde(default = "RetryConfig::default_base_interval_secs")] + pub base_interval_secs: u64, + /// Cap on exponential backoff in seconds (`node.retry.max_backoff_secs`). + #[serde(default = "RetryConfig::default_max_backoff_secs")] + pub max_backoff_secs: u64, +} + +impl Default for RetryConfig { + fn default() -> Self { + Self { + max_retries: 5, + base_interval_secs: 5, + max_backoff_secs: 300, + } + } +} + +impl RetryConfig { + fn default_max_retries() -> u32 { 5 } + fn default_base_interval_secs() -> u64 { 5 } + fn default_max_backoff_secs() -> u64 { 300 } +} + +/// Cache parameters (`node.cache.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CacheConfig { + /// Max entries in coord cache (`node.cache.coord_size`). + #[serde(default = "CacheConfig::default_coord_size")] + pub coord_size: usize, + /// Coord cache entry TTL in seconds (`node.cache.coord_ttl_secs`). + #[serde(default = "CacheConfig::default_coord_ttl_secs")] + pub coord_ttl_secs: u64, + /// Max entries in route cache (`node.cache.route_size`). + #[serde(default = "CacheConfig::default_route_size")] + pub route_size: usize, +} + +impl Default for CacheConfig { + fn default() -> Self { + Self { + coord_size: 50_000, + coord_ttl_secs: 300, + route_size: 10_000, + } + } +} + +impl CacheConfig { + fn default_coord_size() -> usize { 50_000 } + fn default_coord_ttl_secs() -> u64 { 300 } + fn default_route_size() -> usize { 10_000 } +} + +/// Discovery protocol (`node.discovery.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DiscoveryConfig { + /// Hop limit for LookupRequest flood (`node.discovery.ttl`). + #[serde(default = "DiscoveryConfig::default_ttl")] + pub ttl: u8, + /// Lookup completion timeout in seconds (`node.discovery.timeout_secs`). + #[serde(default = "DiscoveryConfig::default_timeout_secs")] + pub timeout_secs: u64, + /// Dedup cache expiry in seconds (`node.discovery.recent_expiry_secs`). + #[serde(default = "DiscoveryConfig::default_recent_expiry_secs")] + pub recent_expiry_secs: u64, +} + +impl Default for DiscoveryConfig { + fn default() -> Self { + Self { + ttl: 64, + timeout_secs: 10, + recent_expiry_secs: 10, + } + } +} + +impl DiscoveryConfig { + fn default_ttl() -> u8 { 64 } + fn default_timeout_secs() -> u64 { 10 } + fn default_recent_expiry_secs() -> u64 { 10 } +} + +/// Spanning tree (`node.tree.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TreeConfig { + /// Root self-announcement interval in seconds (`node.tree.root_refresh_secs`). + #[serde(default = "TreeConfig::default_root_refresh_secs")] + pub root_refresh_secs: u64, + /// Per-peer TreeAnnounce rate limit in ms (`node.tree.announce_min_interval_ms`). + #[serde(default = "TreeConfig::default_announce_min_interval_ms")] + pub announce_min_interval_ms: u64, + /// Min depth improvement to switch parents (`node.tree.parent_switch_threshold`). + #[serde(default = "TreeConfig::default_parent_switch_threshold")] + pub parent_switch_threshold: usize, +} + +impl Default for TreeConfig { + fn default() -> Self { + Self { + root_refresh_secs: 1800, + announce_min_interval_ms: 500, + parent_switch_threshold: 1, + } + } +} + +impl TreeConfig { + fn default_root_refresh_secs() -> u64 { 1800 } + fn default_announce_min_interval_ms() -> u64 { 500 } + fn default_parent_switch_threshold() -> usize { 1 } +} + +/// Bloom filter (`node.bloom.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BloomConfig { + /// Debounce interval for filter updates in ms (`node.bloom.update_debounce_ms`). + #[serde(default = "BloomConfig::default_update_debounce_ms")] + pub update_debounce_ms: u64, +} + +impl Default for BloomConfig { + fn default() -> Self { + Self { update_debounce_ms: 500 } + } +} + +impl BloomConfig { + fn default_update_debounce_ms() -> u64 { 500 } +} + +/// Session/data plane (`node.session.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionConfig { + /// Default SessionDatagram hop limit (`node.session.default_hop_limit`). + #[serde(default = "SessionConfig::default_hop_limit")] + pub default_hop_limit: u8, + /// Queue depth per dest during session establishment (`node.session.pending_packets_per_dest`). + #[serde(default = "SessionConfig::default_pending_packets_per_dest")] + pub pending_packets_per_dest: usize, + /// Max destinations with pending packets (`node.session.pending_max_destinations`). + #[serde(default = "SessionConfig::default_pending_max_destinations")] + pub pending_max_destinations: usize, +} + +impl Default for SessionConfig { + fn default() -> Self { + Self { + default_hop_limit: 64, + pending_packets_per_dest: 16, + pending_max_destinations: 256, + } + } +} + +impl SessionConfig { + fn default_hop_limit() -> u8 { 64 } + fn default_pending_packets_per_dest() -> usize { 16 } + fn default_pending_max_destinations() -> usize { 256 } +} + +/// Internal buffers (`node.buffers.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuffersConfig { + /// Transport→Node packet channel capacity (`node.buffers.packet_channel`). + #[serde(default = "BuffersConfig::default_packet_channel")] + pub packet_channel: usize, + /// TUN→Node outbound channel capacity (`node.buffers.tun_channel`). + #[serde(default = "BuffersConfig::default_tun_channel")] + pub tun_channel: usize, + /// DNS→Node identity channel capacity (`node.buffers.dns_channel`). + #[serde(default = "BuffersConfig::default_dns_channel")] + pub dns_channel: usize, +} + +impl Default for BuffersConfig { + fn default() -> Self { + Self { + packet_channel: 1024, + tun_channel: 1024, + dns_channel: 64, + } + } +} + +impl BuffersConfig { + fn default_packet_channel() -> usize { 1024 } + fn default_tun_channel() -> usize { 1024 } + fn default_dns_channel() -> usize { 64 } +} + +// ============================================================================ +// Node Configuration (Root) +// ============================================================================ + +/// Node configuration (`node.*`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeConfig { + /// Identity configuration (`node.identity.*`). + #[serde(default)] + pub identity: IdentityConfig, + + /// Leaf-only mode (`node.leaf_only`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub leaf_only: bool, + + /// RX loop maintenance tick period in seconds (`node.tick_interval_secs`). + #[serde(default = "NodeConfig::default_tick_interval_secs")] + pub tick_interval_secs: u64, + + /// Initial RTT estimate for new links in ms (`node.base_rtt_ms`). + #[serde(default = "NodeConfig::default_base_rtt_ms")] + pub base_rtt_ms: u64, + + /// Resource limits (`node.limits.*`). + #[serde(default)] + pub limits: LimitsConfig, + + /// Rate limiting (`node.rate_limit.*`). + #[serde(default)] + pub rate_limit: RateLimitConfig, + + /// Retry/backoff (`node.retry.*`). + #[serde(default)] + pub retry: RetryConfig, + + /// Cache parameters (`node.cache.*`). + #[serde(default)] + pub cache: CacheConfig, + + /// Discovery protocol (`node.discovery.*`). + #[serde(default)] + pub discovery: DiscoveryConfig, + + /// Spanning tree (`node.tree.*`). + #[serde(default)] + pub tree: TreeConfig, + + /// Bloom filter (`node.bloom.*`). + #[serde(default)] + pub bloom: BloomConfig, + + /// Session/data plane (`node.session.*`). + #[serde(default)] + pub session: SessionConfig, + + /// Internal buffers (`node.buffers.*`). + #[serde(default)] + pub buffers: BuffersConfig, +} + +impl Default for NodeConfig { + fn default() -> Self { + Self { + identity: IdentityConfig::default(), + leaf_only: false, + tick_interval_secs: 1, + base_rtt_ms: 100, + limits: LimitsConfig::default(), + rate_limit: RateLimitConfig::default(), + retry: RetryConfig::default(), + cache: CacheConfig::default(), + discovery: DiscoveryConfig::default(), + tree: TreeConfig::default(), + bloom: BloomConfig::default(), + session: SessionConfig::default(), + buffers: BuffersConfig::default(), + } + } +} + +impl NodeConfig { + fn default_tick_interval_secs() -> u64 { 1 } + fn default_base_rtt_ms() -> u64 { 100 } +} diff --git a/src/config/peer.rs b/src/config/peer.rs new file mode 100644 index 0000000..ede2a3f --- /dev/null +++ b/src/config/peer.rs @@ -0,0 +1,131 @@ +//! Peer configuration types. +//! +//! Known peer definitions with transport addresses and connection policies. + +use serde::{Deserialize, Serialize}; + +/// Connection policy for a peer. +/// +/// Determines when and how to establish a connection to a peer. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ConnectPolicy { + /// Connect to this peer automatically on node startup. + /// This is the only policy supported in the initial implementation. + #[default] + AutoConnect, + + /// Connect only when traffic needs to be routed through this peer (future). + OnDemand, + + /// Wait for explicit API call to connect (future). + Manual, +} + +/// A transport-specific address for reaching a peer. +/// +/// Each peer can have multiple addresses across different transports, +/// allowing fallback if one transport is unavailable. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PeerAddress { + /// Transport type (e.g., "udp", "tor", "ethernet"). + pub transport: String, + + /// Transport-specific address string. + /// + /// Format depends on transport type: + /// - UDP: "host:port" (e.g., "192.168.1.1:4000") + /// - Tor: "onion_address:port" (e.g., "xyz...abc.onion:4000") + /// - Ethernet: "interface/mac" (future) + pub addr: String, + + /// Priority for address selection (lower = preferred). + /// When multiple addresses are available, lower priority addresses + /// are tried first. + #[serde(default = "default_priority")] + pub priority: u8, +} + +fn default_priority() -> u8 { + 100 +} + +impl PeerAddress { + /// Create a new peer address. + pub fn new(transport: impl Into, addr: impl Into) -> Self { + Self { + transport: transport.into(), + addr: addr.into(), + priority: default_priority(), + } + } + + /// Create a new peer address with priority. + pub fn with_priority(transport: impl Into, addr: impl Into, priority: u8) -> Self { + Self { + transport: transport.into(), + addr: addr.into(), + priority, + } + } +} + +/// Configuration for a known peer. +/// +/// Peers are identified by their Nostr public key (npub) and can have +/// multiple transport addresses for reaching them. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PeerConfig { + /// The peer's Nostr public key in npub (bech32) or hex format. + pub npub: String, + + /// Human-readable alias for the peer (optional). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, + + /// Transport addresses for reaching this peer. + /// At least one address is required. + pub addresses: Vec, + + /// Connection policy for this peer. + #[serde(default)] + pub connect_policy: ConnectPolicy, +} + +impl PeerConfig { + /// Create a new peer config with a single address. + pub fn new(npub: impl Into, transport: impl Into, addr: impl Into) -> Self { + Self { + npub: npub.into(), + alias: None, + addresses: vec![PeerAddress::new(transport, addr)], + connect_policy: ConnectPolicy::default(), + } + } + + /// Set an alias for the peer. + pub fn with_alias(mut self, alias: impl Into) -> Self { + self.alias = Some(alias.into()); + self + } + + /// Add an additional address for the peer. + pub fn with_address(mut self, addr: PeerAddress) -> Self { + self.addresses.push(addr); + self + } + + /// Get addresses sorted by priority (lowest first). + pub fn addresses_by_priority(&self) -> Vec<&PeerAddress> { + let mut addrs: Vec<_> = self.addresses.iter().collect(); + addrs.sort_by_key(|a| a.priority); + addrs + } + + /// Check if this peer should auto-connect on startup. + pub fn is_auto_connect(&self) -> bool { + matches!(self.connect_policy, ConnectPolicy::AutoConnect) + } +} diff --git a/src/config/transport.rs b/src/config/transport.rs new file mode 100644 index 0000000..32a4b78 --- /dev/null +++ b/src/config/transport.rs @@ -0,0 +1,150 @@ +//! Transport configuration types. +//! +//! Generic transport instance handling (single vs. named) and +//! transport-specific configuration structs. + +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +/// Default UDP bind address. +const DEFAULT_UDP_BIND_ADDR: &str = "0.0.0.0:4000"; + +/// Default UDP MTU (IPv6 minimum). +const DEFAULT_UDP_MTU: u16 = 1280; + +/// UDP transport instance configuration. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UdpConfig { + /// Bind address (`bind_addr`). Defaults to "0.0.0.0:4000". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bind_addr: Option, + + /// UDP MTU (`mtu`). Defaults to 1280 (IPv6 minimum). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mtu: Option, +} + +impl UdpConfig { + /// Get the bind address, using default if not configured. + pub fn bind_addr(&self) -> &str { + self.bind_addr.as_deref().unwrap_or(DEFAULT_UDP_BIND_ADDR) + } + + /// Get the UDP MTU, using default if not configured. + pub fn mtu(&self) -> u16 { + self.mtu.unwrap_or(DEFAULT_UDP_MTU) + } +} + +/// Transport instances - either a single config or named instances. +/// +/// Allows both simple single-instance config: +/// ```yaml +/// transports: +/// udp: +/// bind_addr: "0.0.0.0:4000" +/// ``` +/// +/// And multiple named instances: +/// ```yaml +/// transports: +/// udp: +/// main: +/// bind_addr: "0.0.0.0:4000" +/// backup: +/// bind_addr: "192.168.1.100:4001" +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum TransportInstances { + /// Single unnamed instance (config fields directly under transport type). + Single(T), + /// Multiple named instances. + Named(HashMap), +} + +impl TransportInstances { + /// Get the number of instances. + pub fn len(&self) -> usize { + match self { + TransportInstances::Single(_) => 1, + TransportInstances::Named(map) => map.len(), + } + } + + /// Check if there are no instances. + pub fn is_empty(&self) -> bool { + match self { + TransportInstances::Single(_) => false, + TransportInstances::Named(map) => map.is_empty(), + } + } + + /// Iterate over all instances as (name, config) pairs. + /// + /// Single instances have `None` as the name. + /// Named instances have `Some(name)`. + pub fn iter(&self) -> impl Iterator, &T)> { + match self { + TransportInstances::Single(config) => { + vec![(None, config)].into_iter() + } + TransportInstances::Named(map) => { + map.iter() + .map(|(k, v)| (Some(k.as_str()), v)) + .collect::>() + .into_iter() + } + } + } +} + +impl Default for TransportInstances { + fn default() -> Self { + TransportInstances::Named(HashMap::new()) + } +} + +/// Transports configuration section. +/// +/// Each transport type can have either a single instance (config directly +/// under the type name) or multiple named instances. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TransportsConfig { + /// UDP transport instances. + #[serde(default, skip_serializing_if = "is_transport_empty")] + pub udp: TransportInstances, + + // Future transport types: + // #[serde(default, skip_serializing_if = "is_transport_empty")] + // pub tcp: TransportInstances, + // + // #[serde(default, skip_serializing_if = "is_transport_empty")] + // pub tor: TransportInstances, +} + +/// Helper for skip_serializing_if on TransportInstances. +fn is_transport_empty(instances: &TransportInstances) -> bool { + instances.is_empty() +} + +impl TransportsConfig { + /// Check if any transports are configured. + pub fn is_empty(&self) -> bool { + self.udp.is_empty() + // && self.tcp.is_empty() + // && self.tor.is_empty() + } + + /// Merge another TransportsConfig into this one. + /// + /// Non-empty transport sections from `other` replace those in `self`. + pub fn merge(&mut self, other: TransportsConfig) { + if !other.udp.is_empty() { + self.udp = other.udp; + } + // Future: same for tcp, tor, etc. + } +} diff --git a/src/lib.rs b/src/lib.rs index 3a0effd..b09fbe7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,8 +7,8 @@ pub mod bloom; pub mod cache; pub mod config; pub mod identity; -pub mod index; pub mod noise; +pub mod utils; pub mod node; pub mod peer; pub mod protocol; @@ -23,7 +23,8 @@ pub use identity::{ }; // Re-export config types -pub use config::{Config, ConfigError, DnsConfig, IdentityConfig, TunConfig, UdpConfig}; +pub use config::{Config, ConfigError, IdentityConfig, UdpConfig}; +pub use upper::config::{DnsConfig, TunConfig}; // Re-export tree types pub use tree::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError, TreeState}; diff --git a/src/node/mod.rs b/src/node/mod.rs index a86c9c8..c6ca73f 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -17,7 +17,7 @@ mod tests; use crate::bloom::BloomState; use crate::cache::{CoordCache, RouteCache}; -use crate::index::IndexAllocator; +use crate::utils::index::IndexAllocator; use crate::node::session::SessionEntry; use crate::peer::{ActivePeer, PeerConnection}; use self::rate_limit::HandshakeRateLimiter; diff --git a/src/node/tests/mod.rs b/src/node/tests/mod.rs index 2245029..7361230 100644 --- a/src/node/tests/mod.rs +++ b/src/node/tests/mod.rs @@ -1,5 +1,5 @@ use super::*; -use crate::index::SessionIndex; +use crate::utils::index::SessionIndex; use crate::transport::{packet_channel, LinkDirection, TransportAddr}; use crate::PeerIdentity; use std::time::Duration; diff --git a/src/node/wire.rs b/src/node/wire.rs index db757c9..8716bb6 100644 --- a/src/node/wire.rs +++ b/src/node/wire.rs @@ -11,7 +11,7 @@ //! | 0x01 | Noise IK msg1 | 87 bytes | Handshake initiation | //! | 0x02 | Noise IK msg2 | 42 bytes | Handshake response | -use crate::index::SessionIndex; +use crate::utils::index::SessionIndex; use crate::noise::{HANDSHAKE_MSG1_SIZE, HANDSHAKE_MSG2_SIZE, TAG_SIZE}; // ============================================================================ diff --git a/src/peer/active.rs b/src/peer/active.rs index 48d7e00..8c6ba1e 100644 --- a/src/peer/active.rs +++ b/src/peer/active.rs @@ -4,7 +4,7 @@ //! ActivePeer holds tree state, Bloom filter, and routing information. use crate::bloom::BloomFilter; -use crate::index::SessionIndex; +use crate::utils::index::SessionIndex; use crate::noise::NoiseSession; use crate::transport::{LinkId, LinkStats, TransportAddr, TransportId}; use crate::tree::{ParentDeclaration, TreeCoordinate}; diff --git a/src/peer/connection.rs b/src/peer/connection.rs index c1aa650..1662973 100644 --- a/src/peer/connection.rs +++ b/src/peer/connection.rs @@ -4,7 +4,7 @@ //! PeerConnection tracks the Noise IK handshake state and transitions to //! ActivePeer upon successful authentication. -use crate::index::SessionIndex; +use crate::utils::index::SessionIndex; use crate::noise::{self, NoiseError, NoiseSession}; use crate::transport::{LinkDirection, LinkId, LinkStats, TransportAddr, TransportId}; use crate::PeerIdentity; diff --git a/src/upper/config.rs b/src/upper/config.rs new file mode 100644 index 0000000..3286b61 --- /dev/null +++ b/src/upper/config.rs @@ -0,0 +1,86 @@ +//! Upper layer configuration types. +//! +//! Configuration for the IPv6 adaptation layer components: TUN interface +//! and DNS responder. + +use serde::{Deserialize, Serialize}; + +/// Default TUN device name. +const DEFAULT_TUN_NAME: &str = "fips0"; + +/// Default TUN MTU (IPv6 minimum). +const DEFAULT_TUN_MTU: u16 = 1280; + +/// Default DNS responder bind address. +const DEFAULT_DNS_BIND_ADDR: &str = "127.0.0.1"; + +/// Default DNS responder port. +const DEFAULT_DNS_PORT: u16 = 5354; + +/// Default DNS record TTL in seconds (5 minutes). +const DEFAULT_DNS_TTL: u32 = 300; + +/// DNS responder configuration (`dns.*`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DnsConfig { + /// Enable DNS responder (`dns.enabled`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub enabled: bool, + + /// Bind address (`dns.bind_addr`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bind_addr: Option, + + /// Port (`dns.port`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, + + /// Record TTL in seconds (`dns.ttl`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ttl: Option, +} + +impl DnsConfig { + /// Get the bind address (default: 127.0.0.1). + pub fn bind_addr(&self) -> &str { + self.bind_addr.as_deref().unwrap_or(DEFAULT_DNS_BIND_ADDR) + } + + /// Get the port (default: 5354). + pub fn port(&self) -> u16 { + self.port.unwrap_or(DEFAULT_DNS_PORT) + } + + /// Get the TTL in seconds (default: 300). + pub fn ttl(&self) -> u32 { + self.ttl.unwrap_or(DEFAULT_DNS_TTL) + } +} + +/// TUN interface configuration (`tun.*`). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TunConfig { + /// Enable TUN interface (`tun.enabled`). + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub enabled: bool, + + /// Device name (`tun.name`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// MTU (`tun.mtu`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mtu: Option, +} + +impl TunConfig { + /// Get the device name (default: "fips0"). + pub fn name(&self) -> &str { + self.name.as_deref().unwrap_or(DEFAULT_TUN_NAME) + } + + /// Get the MTU (default: 1280). + pub fn mtu(&self) -> u16 { + self.mtu.unwrap_or(DEFAULT_TUN_MTU) + } +} diff --git a/src/upper/mod.rs b/src/upper/mod.rs index 91752ee..c3d57dd 100644 --- a/src/upper/mod.rs +++ b/src/upper/mod.rs @@ -5,6 +5,7 @@ //! responder (.fips domain resolution), and ICMPv6 handling (error //! signaling and neighbor discovery). +pub mod config; pub mod dns; pub mod icmp; pub mod tun; diff --git a/src/index.rs b/src/utils/index.rs similarity index 100% rename from src/index.rs rename to src/utils/index.rs diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..1fd66c3 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,6 @@ +//! Utility modules. +//! +//! Shared infrastructure that doesn't belong to a specific protocol layer: +//! session index allocation and other cross-cutting concerns. + +pub mod index;