diff --git a/Cargo.lock b/Cargo.lock index c632966..6473808 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -98,6 +98,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arc-swap" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" +dependencies = [ + "rustversion", +] + [[package]] name = "arrayvec" version = "0.7.6" @@ -1063,6 +1072,7 @@ checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" name = "fips" version = "0.4.0-dev" dependencies = [ + "arc-swap", "bech32", "bluer", "clap", diff --git a/Cargo.toml b/Cargo.toml index dd1c5d6..9faad4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ portable-atomic = { version = "1", features = ["std"] } nostr = { version = "0.44", features = ["std", "nip59"] } nostr-sdk = "0.44" +arc-swap = "1" [target.'cfg(unix)'.dependencies] tun = { version = "0.8.7", features = ["async"] } diff --git a/src/node/mod.rs b/src/node/mod.rs index 760ccf9..479f951 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -15,6 +15,7 @@ mod handlers; mod lifecycle; mod rate_limit; pub(crate) mod reject; +mod reloadable; mod retry; mod routing_error_rate_limit; pub(crate) mod session; @@ -28,6 +29,7 @@ pub(crate) mod wire; use self::discovery_rate_limit::{DiscoveryBackoff, DiscoveryForwardRateLimiter}; use self::rate_limit::HandshakeRateLimiter; +use self::reloadable::Reloadable; use self::routing_error_rate_limit::RoutingErrorRateLimiter; /// Half-range of the symmetric jitter applied to the per-session rekey timer. @@ -501,8 +503,9 @@ pub struct Node { // === Host Map === /// Static hostname → npub mapping for DNS resolution. - /// Built at construction from peer aliases and /etc/fips/hosts. - host_map: Arc, + /// Built at construction from peer aliases and /etc/fips/hosts, and + /// published through a lock-free snapshot for the display path. + host_map: reloadable::HostMapReloadable, /// Off-task FMP-encrypt + UDP-send worker pool. Unix-only — /// the worker issues direct sendmmsg(2) / sendmsg+UDP_GSO calls @@ -595,13 +598,9 @@ impl Node { let forward_min_interval_secs = config.node.discovery.forward_min_interval_secs; let base_host_map = HostMap::from_peer_configs(config.peers()); - let mut host_map = base_host_map.clone(); let hosts_path = std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH); - let hosts_file = HostMap::load_hosts_file(std::path::Path::new( - crate::upper::hosts::DEFAULT_HOSTS_PATH, - )); - host_map.merge(hosts_file); - let host_map = Arc::new(host_map); + let host_map = + reloadable::HostMapReloadable::new(base_host_map.clone(), hosts_path.clone()); let peer_acl = acl::PeerAclReloader::with_alias_sources( std::path::PathBuf::from(acl::DEFAULT_PEERS_ALLOW_PATH), std::path::PathBuf::from(acl::DEFAULT_PEERS_DENY_PATH), @@ -744,16 +743,14 @@ impl Node { let coords_response_interval_ms = config.node.session.coords_response_interval_ms; let base_host_map = HostMap::from_peer_configs(config.peers()); - let mut host_map = base_host_map.clone(); - host_map.merge(HostMap::load_hosts_file(std::path::Path::new( - crate::upper::hosts::DEFAULT_HOSTS_PATH, - ))); - let host_map = Arc::new(host_map); + let hosts_path = std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH); + let host_map = + reloadable::HostMapReloadable::new(base_host_map.clone(), hosts_path.clone()); let peer_acl = acl::PeerAclReloader::with_alias_sources( std::path::PathBuf::from(acl::DEFAULT_PEERS_ALLOW_PATH), std::path::PathBuf::from(acl::DEFAULT_PEERS_DENY_PATH), base_host_map, - std::path::PathBuf::from(crate::upper::hosts::DEFAULT_HOSTS_PATH), + hosts_path, ); #[cfg(unix)] @@ -1084,7 +1081,8 @@ impl Node { /// 4. Session endpoint's short npub (end-to-end, may not be direct peer) /// 5. Truncated NodeAddr hex (unknown address) pub(crate) fn peer_display_name(&self, addr: &NodeAddr) -> String { - if let Some(hostname) = self.host_map.lookup_hostname(addr) { + let hosts = self.host_map.load(); + if let Some(hostname) = hosts.lookup_hostname(addr) { return hostname.to_string(); } if let Some(name) = self.peer_aliases.get(addr) { diff --git a/src/node/reloadable.rs b/src/node/reloadable.rs new file mode 100644 index 0000000..738d613 --- /dev/null +++ b/src/node/reloadable.rs @@ -0,0 +1,308 @@ +//! Lock-free reloadable configuration / resource snapshots. +//! +//! Several node-owned resources are loaded from disk at startup and may be +//! re-read when the backing file changes (for example the `/etc/fips/hosts` +//! map). Historically each one carried its own ad-hoc reloader with a +//! slightly different shape. The [`Reloadable`] trait normalizes them onto a +//! single contract built around an [`arc_swap::ArcSwap`] snapshot. +//! +//! # Canonical Arc-wrapper template +//! +//! These resources follow a single-writer / many-reader pattern: the node +//! tick task is the only writer, while the hot path reads the current value +//! frequently and must never block. +//! +//! - The reader-facing immutable snapshot lives in an +//! [`arc_swap::ArcSwap`]. Readers call [`Reloadable::load`], which yields +//! a lock-free [`arc_swap::Guard>`] that derefs straight to the +//! snapshot — no mutex, no clone on the read path. +//! - The owning struct also holds the change-detection state (file mtime, +//! immutable base data, source path). That state is touched only by +//! [`Reloadable::reload`], which runs on the single writer task. +//! - `reload` builds a brand-new `T` and then stores `Arc::new(new)` into the +//! `ArcSwap`, so a reader either sees the entire old snapshot or the entire +//! new one — never a partial update. +//! - Construction performs the initial synchronous load so the snapshot is +//! valid before the node starts serving reads. +//! +//! `reload` returns `bool` rather than a value or `Result`: the underlying +//! loaders already absorb I/O errors internally (a missing or unreadable +//! source degrades to an empty/base snapshot plus a warning), so callers have +//! nothing to handle. The return flag reports only whether the snapshot was +//! replaced, which is all the tick loop needs for logging. + +use std::sync::Arc; + +use crate::upper::hosts::{HostMap, file_mtime}; + +/// A resource backed by a lock-free [`arc_swap::ArcSwap`] snapshot that can be +/// re-read from its source on demand. +/// +/// See the [module documentation](self) for the canonical Arc-wrapper +/// template that implementors follow. +pub trait Reloadable: Send { + /// The immutable snapshot type readers observe. + type Snapshot; + + /// Re-read the backing source and replace the snapshot if it changed. + /// + /// Returns `true` if a new snapshot was stored, `false` if nothing + /// changed. I/O errors are absorbed internally (degrading to an + /// empty/base snapshot with a warning) rather than surfaced. + /// + /// Not yet driven from the node tick — the host map snapshot is still + /// taken once at construction and not polled. Wiring the periodic poll + /// into the tick is a follow-up. + #[cfg_attr(not(test), allow(dead_code))] + async fn reload(&mut self) -> bool; + + /// Acquire a lock-free guard over the current snapshot. + /// + /// This is the hot-path read: it performs no locking and no allocation. + fn load(&self) -> arc_swap::Guard>; +} + +/// Reloadable hostname → npub map (base peer aliases merged with the operator +/// hosts file). +/// +/// Holds the immutable base map (from peer-config aliases) plus the +/// change-detection state for the hosts file. The effective map (base merged +/// with the hosts file) is published through an [`arc_swap::ArcSwap`] so the +/// display path can read it without locking. +pub struct HostMapReloadable { + /// Reader-facing effective snapshot (base merged with hosts file). + snapshot: arc_swap::ArcSwap, + /// Base map from peer-config aliases (never changes). Read only by + /// `reload`, which is not yet driven from the node tick. + #[cfg_attr(not(test), allow(dead_code))] + base: HostMap, + /// Path to the operator hosts file. Read only by `reload`. + #[cfg_attr(not(test), allow(dead_code))] + path: std::path::PathBuf, + /// Last observed modification time of the hosts file (`None` if absent). + /// Read only by `reload`. + #[cfg_attr(not(test), allow(dead_code))] + last_mtime: Option, +} + +impl HostMapReloadable { + /// Create a reloadable host map. + /// + /// Performs the initial load of the hosts file and merges it over the + /// base map so the published snapshot is valid immediately. + pub fn new(base: HostMap, path: std::path::PathBuf) -> Self { + let last_mtime = file_mtime(&path); + let hosts_file = HostMap::load_hosts_file(&path); + let mut effective = base.clone(); + effective.merge(hosts_file); + + Self { + snapshot: arc_swap::ArcSwap::from(Arc::new(effective)), + base, + path, + last_mtime, + } + } +} + +impl Reloadable for HostMapReloadable { + type Snapshot = HostMap; + + async fn reload(&mut self) -> bool { + let current_mtime = file_mtime(&self.path); + + if current_mtime == self.last_mtime { + return false; + } + + // File appeared, disappeared, or was modified. + self.last_mtime = current_mtime; + let hosts_file = HostMap::load_hosts_file(&self.path); + let mut new_effective = self.base.clone(); + new_effective.merge(hosts_file); + + let count = new_effective.len(); + self.snapshot.store(Arc::new(new_effective)); + + tracing::info!( + path = %self.path.display(), + entries = count, + "Reloaded hosts file" + ); + true + } + + fn load(&self) -> arc_swap::Guard> { + self.snapshot.load() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Identity; + + #[tokio::test] + async fn test_initial_load_base_and_file() { + let id_base = Identity::generate(); + let id_file = Identity::generate(); + + let mut base = HostMap::new(); + base.insert("core", &id_base.npub()).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hosts"); + std::fs::write(&path, format!("gateway {}\n", id_file.npub())).unwrap(); + + let reloadable = HostMapReloadable::new(base, path); + let snapshot = reloadable.load(); + assert_eq!(snapshot.len(), 2); + assert!(snapshot.lookup_npub("core").is_some()); + assert!(snapshot.lookup_npub("gateway").is_some()); + } + + #[tokio::test] + async fn test_initial_load_no_file_base_only() { + let id = Identity::generate(); + let mut base = HostMap::new(); + base.insert("core", &id.npub()).unwrap(); + + let reloadable = + HostMapReloadable::new(base, std::path::PathBuf::from("/nonexistent/hosts")); + let snapshot = reloadable.load(); + assert_eq!(snapshot.len(), 1); + assert!(snapshot.lookup_npub("core").is_some()); + } + + #[tokio::test] + async fn test_reload_detects_file_change() { + let id1 = Identity::generate(); + let id2 = Identity::generate(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hosts"); + std::fs::write(&path, format!("gateway {}\n", id1.npub())).unwrap(); + + let mut reloadable = HostMapReloadable::new(HostMap::new(), path.clone()); + assert_eq!(reloadable.load().len(), 1); + assert_eq!( + reloadable.load().lookup_npub("gateway"), + Some(id1.npub().as_str()) + ); + + // No change yet. + assert!(!reloadable.reload().await); + + // Bump mtime by rewriting; sleep for filesystem mtime granularity. + std::thread::sleep(std::time::Duration::from_millis(50)); + std::fs::write( + &path, + format!("gateway {}\nnew-host {}\n", id1.npub(), id2.npub()), + ) + .unwrap(); + + assert!(reloadable.reload().await); + let snapshot = reloadable.load(); + assert_eq!(snapshot.len(), 2); + assert!(snapshot.lookup_npub("new-host").is_some()); + } + + #[tokio::test] + async fn test_reload_no_change_returns_false() { + let id = Identity::generate(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hosts"); + std::fs::write(&path, format!("gateway {}\n", id.npub())).unwrap(); + + let mut reloadable = HostMapReloadable::new(HostMap::new(), path); + assert!(!reloadable.reload().await); + assert!(!reloadable.reload().await); + } + + #[tokio::test] + async fn test_reload_detects_file_deletion() { + let id = Identity::generate(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hosts"); + std::fs::write(&path, format!("gateway {}\n", id.npub())).unwrap(); + + let mut reloadable = HostMapReloadable::new(HostMap::new(), path.clone()); + assert_eq!(reloadable.load().len(), 1); + + std::fs::remove_file(&path).unwrap(); + + assert!(reloadable.reload().await); + assert!(reloadable.load().is_empty()); + } + + #[tokio::test] + async fn test_reload_detects_file_creation() { + let id = Identity::generate(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hosts"); + + let mut reloadable = HostMapReloadable::new(HostMap::new(), path.clone()); + assert!(reloadable.load().is_empty()); + + std::fs::write(&path, format!("gateway {}\n", id.npub())).unwrap(); + + assert!(reloadable.reload().await); + let snapshot = reloadable.load(); + assert_eq!(snapshot.len(), 1); + assert!(snapshot.lookup_npub("gateway").is_some()); + } + + #[tokio::test] + async fn test_reload_preserves_base() { + let id_base = Identity::generate(); + let id_file = Identity::generate(); + + let mut base = HostMap::new(); + base.insert("core", &id_base.npub()).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hosts"); + std::fs::write(&path, format!("gateway {}\n", id_file.npub())).unwrap(); + + let mut reloadable = HostMapReloadable::new(base, path.clone()); + assert_eq!(reloadable.load().len(), 2); + + std::fs::remove_file(&path).unwrap(); + assert!(reloadable.reload().await); + let snapshot = reloadable.load(); + assert_eq!(snapshot.len(), 1); + assert!(snapshot.lookup_npub("core").is_some()); + assert!(snapshot.lookup_npub("gateway").is_none()); + } + + /// The initial published snapshot must be byte-for-byte equivalent to the + /// pre-migration `Arc` built by `base.clone()` + `merge(file)`. + #[tokio::test] + async fn test_initial_snapshot_matches_pre_migration_construction() { + let id_base = Identity::generate(); + let id_file = Identity::generate(); + + let mut base = HostMap::new(); + base.insert("core", &id_base.npub()).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("hosts"); + std::fs::write(&path, format!("gateway {}\n", id_file.npub())).unwrap(); + + // Pre-migration construction. + let mut expected = base.clone(); + expected.merge(HostMap::load_hosts_file(&path)); + + // Post-migration construction. + let reloadable = HostMapReloadable::new(base, path); + let snapshot = reloadable.load(); + + assert_eq!(snapshot.len(), expected.len()); + for key in ["core", "gateway"] { + assert_eq!(snapshot.lookup_npub(key), expected.lookup_npub(key)); + } + } +}