mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 00:26:59 +00:00
Relocate the overlay peer-rendezvous subsystem out of the overloaded src/discovery/ tree into two focused, independent homes: src/nostr/ (relay-mediated overlay endpoint advertise/resolve/auto-mesh plus NAT traversal) and src/mdns/ (link-local DNS-SD rendezvous). The two subsystems are independent, so they get separate homes rather than sharing one. Drop the ambiguous "Discovery" stem from their identifiers in favor of "Rendezvous": NostrDiscovery -> NostrRendezvous, LanDiscovery -> LanRendezvous, and the matching config, policy, field, and method names. The former src/discovery.rs handoff types (EstablishedTraversal, BootstrapHandoffResult, the punch-packet helpers) fold into src/nostr/handoff and stay reachable via the crate-root re-exports. Pure relocation and rename: no logic, wire-format, config-key, metric, or tracing-target changes. The operator-facing node.rendezvous.nostr.* and node.rendezvous.lan.* config keys and the fips-overlay-v1 advert namespace are byte-identical. cargo fmt/build/clippy clean; lib test suite 1547 passing (baseline unchanged).
323 lines
12 KiB
Rust
323 lines
12 KiB
Rust
//! 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<T>`]. Readers call [`Reloadable::load`], which yields
|
|
//! a lock-free [`arc_swap::Guard<Arc<T>>`] 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.
|
|
//!
|
|
//! # What is and isn't `Reloadable`
|
|
//!
|
|
//! Two node-owned resources are deliberately *not* `Reloadable` because
|
|
//! neither has a "re-read from a backing source" concept:
|
|
//!
|
|
//! - `path_mtu_lookup` is an event-driven cache (`Arc<RwLock<HashMap>>`)
|
|
//! populated from observed path-MTU discovery traffic, not loaded from a
|
|
//! file. There is nothing to poll. (Its read side could adopt the same
|
|
//! lock-free `ArcSwap` shape in the future, but that is an optimization, not
|
|
//! a reload.)
|
|
//! - `nostr_rendezvous` is an async spawned subsystem, not a snapshot of disk
|
|
//! state.
|
|
//!
|
|
//! Both [`HostMapReloadable`] and the peer ACL reloader currently stat
|
|
//! `/etc/fips/hosts` independently each tick (the ACL reloader embeds its own
|
|
//! hosts reloader for alias resolution). A single small-file `stat` per tick
|
|
//! is cheap, so the duplicate is left in place; collapsing the two onto a
|
|
//! single shared mtime observation is a possible future cleanup.
|
|
|
|
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.
|
|
///
|
|
/// Driven once per node tick from the rx loop, alongside the other
|
|
/// reloadable resources.
|
|
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<Arc<Self::Snapshot>>;
|
|
}
|
|
|
|
/// 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<HostMap>,
|
|
/// Base map from peer-config aliases (never changes). Read only by
|
|
/// `reload` on the tick task.
|
|
base: HostMap,
|
|
/// Path to the operator hosts file. Read only by `reload`.
|
|
path: std::path::PathBuf,
|
|
/// Last observed modification time of the hosts file (`None` if absent).
|
|
/// Read only by `reload`.
|
|
last_mtime: Option<std::time::SystemTime>,
|
|
}
|
|
|
|
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<Arc<HostMap>> {
|
|
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<HostMap>` 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));
|
|
}
|
|
}
|
|
}
|