Merge branch 'master' into next

Integrates PR #50 (peer ACL enforcement) into the XX handshake
architecture. ACL enforcement points adapted for XX's deferred
identity learning: InboundHandshake check moves from handle_msg1
to handle_msg3 (responder learns initiator identity), OutboundHandshake
check remains in handle_msg2 (initiator learns responder identity).
Borrow scopes restructured to release connection borrows before
authorize_peer calls.
This commit is contained in:
Johnathan Corgan
2026-04-16 06:11:03 +00:00
19 changed files with 2094 additions and 79 deletions
+954
View File
@@ -0,0 +1,954 @@
//! Peer access control lists (ACLs) keyed by npub or alias.
//!
//! Evaluation follows TCP Wrappers ordering:
//! 1. If `peers.allow` matches a peer, allow it.
//! 2. Otherwise, if `peers.deny` matches a peer, deny it.
//! 3. Otherwise, allow it.
//!
//! `ALL` acts as a wildcard entry in either file. Because allow rules are
//! evaluated first, an allowlist match overrides a denylist match for the
//! same peer.
use crate::node::{Node, NodeError};
use crate::transport::{TransportAddr, TransportId};
use crate::upper::hosts::{DEFAULT_HOSTS_PATH, HostMap, HostMapReloader, file_mtime};
use crate::{NodeAddr, PeerIdentity};
use serde::Serialize;
use std::collections::{BTreeSet, HashSet};
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use tracing::{debug, info, warn};
/// Default path for the peer allow list.
pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/etc/fips/peers.allow";
/// Default path for the peer deny list.
pub const DEFAULT_PEERS_DENY_PATH: &str = "/etc/fips/peers.deny";
/// Result of evaluating a peer against the ACL.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeerAclDecision {
/// Explicitly permitted by `peers.allow`.
AllowList,
/// Explicitly rejected by `peers.deny`.
DenyList,
/// No rule matched after evaluating allow and deny rules.
DefaultAllow,
}
impl PeerAclDecision {
/// Whether the peer is allowed.
pub fn allowed(self) -> bool {
matches!(self, Self::AllowList | Self::DefaultAllow)
}
}
impl fmt::Display for PeerAclDecision {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::AllowList => write!(f, "allowlist match"),
Self::DenyList => write!(f, "denylist match"),
Self::DefaultAllow => write!(f, "default allow"),
}
}
}
/// Runtime context for ACL enforcement logging.
#[derive(Debug, Clone, Copy)]
pub enum PeerAclContext {
OutboundConnect,
InboundHandshake,
OutboundHandshake,
}
/// Snapshot of the currently loaded ACL state.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PeerAclStatus {
pub allow_file: String,
pub deny_file: String,
pub enforcement_active: bool,
pub effective_mode: String,
pub default_decision: String,
pub allow_all: bool,
pub deny_all: bool,
pub allow_file_entries: Vec<String>,
pub deny_file_entries: Vec<String>,
pub allow_entries: Vec<String>,
pub deny_entries: Vec<String>,
}
impl fmt::Display for PeerAclContext {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::OutboundConnect => write!(f, "outbound_connect"),
Self::InboundHandshake => write!(f, "inbound_handshake"),
Self::OutboundHandshake => write!(f, "outbound_handshake"),
}
}
}
/// Loaded peer ACL state.
#[derive(Debug, Clone, Default)]
pub struct PeerAcl {
allow: HashSet<NodeAddr>,
deny: HashSet<NodeAddr>,
allow_file_entries: BTreeSet<String>,
deny_file_entries: BTreeSet<String>,
allow_npubs: BTreeSet<String>,
deny_npubs: BTreeSet<String>,
allow_all: bool,
deny_all: bool,
}
impl PeerAcl {
/// Create an empty ACL.
pub fn new() -> Self {
Self::default()
}
/// Load the allow/deny files into a new ACL.
#[cfg(test)]
pub fn load_files(allow_path: &Path, deny_path: &Path) -> Self {
let hosts = HostMap::new();
Self::load_files_with_hosts(allow_path, deny_path, &hosts)
}
/// Load the allow/deny files into a new ACL using alias resolution.
pub fn load_files_with_hosts(allow_path: &Path, deny_path: &Path, hosts: &HostMap) -> Self {
let mut acl = Self::new();
acl.load_file(allow_path, true, hosts);
acl.load_file(deny_path, false, hosts);
if !acl.is_empty() {
debug!(
allow_entries = acl.allow.len(),
deny_entries = acl.deny.len(),
allow_all = acl.allow_all,
deny_all = acl.deny_all,
"Loaded peer ACL files"
);
}
acl
}
/// Evaluate whether a peer is allowed.
pub fn check(&self, peer: &PeerIdentity) -> PeerAclDecision {
let addr = peer.node_addr();
if self.allow_all || self.allow.contains(addr) {
PeerAclDecision::AllowList
} else if self.deny_all || self.deny.contains(addr) {
PeerAclDecision::DenyList
} else {
PeerAclDecision::DefaultAllow
}
}
/// Whether the ACL has no entries or wildcards.
pub fn is_empty(&self) -> bool {
self.allow.is_empty() && self.deny.is_empty() && !self.allow_all && !self.deny_all
}
/// Return the effective ACL mode after applying precedence rules.
pub fn effective_mode(&self) -> &'static str {
if self.allow_all {
"allow_all"
} else if !self.allow.is_empty() && self.deny_all {
"allow_then_deny_all"
} else if !self.allow.is_empty() && !self.deny.is_empty() {
"allow_then_deny"
} else if !self.allow.is_empty() {
"allowlist"
} else if self.deny_all {
"deny_all"
} else if !self.deny.is_empty() {
"denylist"
} else {
"default_open"
}
}
/// Return the decision applied to peers that are not named in either file.
pub fn default_decision(&self) -> &'static str {
if self.allow_all || (self.deny.is_empty() && !self.deny_all && self.allow.is_empty()) {
"allow"
} else if self.deny_all {
"deny"
} else {
"allow"
}
}
/// Return the loaded allowlist entries as npubs.
pub fn allow_entries(&self) -> Vec<String> {
self.allow_npubs.iter().cloned().collect()
}
/// Return the loaded allowlist tokens exactly as written in the ACL file.
pub fn allow_file_entries(&self) -> Vec<String> {
self.allow_file_entries.iter().cloned().collect()
}
/// Return the loaded denylist entries as npubs.
pub fn deny_entries(&self) -> Vec<String> {
self.deny_npubs.iter().cloned().collect()
}
/// Return the loaded denylist tokens exactly as written in the ACL file.
pub fn deny_file_entries(&self) -> Vec<String> {
self.deny_file_entries.iter().cloned().collect()
}
fn load_file(&mut self, path: &Path, is_allow: bool, hosts: &HostMap) {
let contents = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!(path = %path.display(), "No ACL file found, skipping");
return;
}
Err(e) => {
warn!(path = %path.display(), error = %e, "Failed to read ACL file");
return;
}
};
for (line_num, line) in contents.lines().enumerate() {
let trimmed = line.split('#').next().unwrap_or("").trim();
if trimmed.is_empty() {
continue;
}
let fields: Vec<&str> = trimmed.split_whitespace().collect();
if fields.len() != 1 {
warn!(
path = %path.display(),
line = line_num + 1,
content = %trimmed,
"Expected one ACL entry per line, skipping"
);
continue;
}
let entry = fields[0];
if entry.eq_ignore_ascii_case("ALL") {
if is_allow {
self.allow_all = true;
} else {
self.deny_all = true;
}
continue;
}
let (peer, resolved_npub) = match Self::resolve_entry(entry, hosts) {
Ok(resolved) => resolved,
Err(e) => {
warn!(
path = %path.display(),
line = line_num + 1,
entry = %entry,
error = %e,
"Skipping invalid ACL entry"
);
continue;
}
};
if is_allow {
self.allow.insert(*peer.node_addr());
self.allow_file_entries.insert(entry.to_string());
self.allow_npubs.insert(resolved_npub);
} else {
self.deny.insert(*peer.node_addr());
self.deny_file_entries.insert(entry.to_string());
self.deny_npubs.insert(resolved_npub);
}
}
}
fn resolve_entry(entry: &str, hosts: &HostMap) -> Result<(PeerIdentity, String), String> {
if let Ok(peer) = PeerIdentity::from_npub(entry) {
return Ok((peer, entry.to_string()));
}
let mapped = hosts
.lookup_npub(entry)
.ok_or_else(|| "unknown alias or invalid npub".to_string())?;
let peer = PeerIdentity::from_npub(mapped)
.map_err(|e| format!("alias resolves to invalid npub: {e}"))?;
Ok((peer, mapped.to_string()))
}
}
/// Tracks peer ACL files and reloads them on mtime changes.
pub struct PeerAclReloader {
acl: PeerAcl,
hosts: HostMapReloader,
allow_path: PathBuf,
deny_path: PathBuf,
last_allow_mtime: Option<SystemTime>,
last_deny_mtime: Option<SystemTime>,
}
impl PeerAclReloader {
/// Create a reloader using the standard ACL file locations.
#[allow(dead_code)]
pub fn new() -> Self {
Self::with_alias_sources(
PathBuf::from(DEFAULT_PEERS_ALLOW_PATH),
PathBuf::from(DEFAULT_PEERS_DENY_PATH),
HostMap::new(),
PathBuf::from(DEFAULT_HOSTS_PATH),
)
}
/// Create a reloader for explicit ACL file paths.
#[cfg(test)]
pub(crate) fn with_paths(allow_path: PathBuf, deny_path: PathBuf) -> Self {
Self::with_alias_sources(
allow_path,
deny_path,
HostMap::new(),
PathBuf::from(DEFAULT_HOSTS_PATH),
)
}
/// Create a reloader with explicit ACL paths and alias sources.
pub(crate) fn with_alias_sources(
allow_path: PathBuf,
deny_path: PathBuf,
base_hosts: HostMap,
hosts_path: PathBuf,
) -> Self {
let last_allow_mtime = file_mtime(&allow_path);
let last_deny_mtime = file_mtime(&deny_path);
let hosts = HostMapReloader::new(base_hosts, hosts_path);
let acl = PeerAcl::load_files_with_hosts(&allow_path, &deny_path, hosts.hosts());
Self {
acl,
hosts,
allow_path,
deny_path,
last_allow_mtime,
last_deny_mtime,
}
}
/// Get the current ACL.
pub fn acl(&self) -> &PeerAcl {
&self.acl
}
/// Return a human-readable snapshot of the loaded ACL state.
pub fn status(&self) -> PeerAclStatus {
PeerAclStatus {
allow_file: self.allow_path.display().to_string(),
deny_file: self.deny_path.display().to_string(),
enforcement_active: !self.acl.is_empty(),
effective_mode: self.acl.effective_mode().to_string(),
default_decision: self.acl.default_decision().to_string(),
allow_all: self.acl.allow_all,
deny_all: self.acl.deny_all,
allow_file_entries: self.acl.allow_file_entries(),
deny_file_entries: self.acl.deny_file_entries(),
allow_entries: self.acl.allow_entries(),
deny_entries: self.acl.deny_entries(),
}
}
/// Check whether ACL or hosts alias sources changed and reload if needed.
pub fn check_reload(&mut self) -> bool {
let allow_mtime = file_mtime(&self.allow_path);
let deny_mtime = file_mtime(&self.deny_path);
let hosts_changed = self.hosts.check_reload();
if allow_mtime == self.last_allow_mtime
&& deny_mtime == self.last_deny_mtime
&& !hosts_changed
{
return false;
}
self.last_allow_mtime = allow_mtime;
self.last_deny_mtime = deny_mtime;
self.acl =
PeerAcl::load_files_with_hosts(&self.allow_path, &self.deny_path, self.hosts.hosts());
info!(
allow_file = %self.allow_path.display(),
deny_file = %self.deny_path.display(),
allow_entries = self.acl.allow.len(),
deny_entries = self.acl.deny.len(),
alias_entries = self.hosts.hosts().len(),
allow_all = self.acl.allow_all,
deny_all = self.acl.deny_all,
"Reloaded peer ACL files"
);
true
}
}
impl Node {
/// Reload the peer ACL if the ACL or hosts files changed.
pub(crate) fn reload_peer_acl(&mut self) -> bool {
self.peer_acl.check_reload()
}
/// Return a control-plane snapshot of the current peer ACL.
pub(crate) fn peer_acl_status(&self) -> PeerAclStatus {
self.peer_acl.status()
}
/// Reject a peer if the current ACL denies it.
pub(crate) fn authorize_peer(
&self,
peer_identity: &PeerIdentity,
context: PeerAclContext,
transport_id: TransportId,
remote_addr: &TransportAddr,
) -> Result<(), NodeError> {
let decision = self.peer_acl.acl().check(peer_identity);
if decision.allowed() {
return Ok(());
}
let peer_node_addr = *peer_identity.node_addr();
warn!(
peer = %self.peer_display_name(&peer_node_addr),
npub = %peer_identity.npub(),
transport_id = %transport_id,
remote_addr = %remote_addr,
context = %context,
decision = %decision,
"Rejected peer by ACL"
);
Err(NodeError::AccessDenied(format!(
"peer {} rejected by ACL: {}",
peer_identity.npub(),
decision
)))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Identity;
fn test_npub() -> String {
Identity::generate().npub()
}
fn test_peer(npub: &str) -> PeerIdentity {
PeerIdentity::from_npub(npub).unwrap()
}
fn test_node_addr() -> NodeAddr {
*test_peer(&test_npub()).node_addr()
}
fn write_file(path: &Path, contents: &str) {
std::fs::write(path, contents).unwrap();
}
fn acl_with_shape(has_allow: bool, has_deny: bool, allow_all: bool, deny_all: bool) -> PeerAcl {
let mut acl = PeerAcl::default();
if has_allow {
acl.allow.insert(test_node_addr());
}
if has_deny {
acl.deny.insert(test_node_addr());
}
acl.allow_all = allow_all;
acl.deny_all = deny_all;
acl
}
#[test]
fn test_acl_decision_allowed_and_display() {
assert!(PeerAclDecision::AllowList.allowed());
assert!(!PeerAclDecision::DenyList.allowed());
assert!(PeerAclDecision::DefaultAllow.allowed());
assert_eq!(PeerAclDecision::AllowList.to_string(), "allowlist match");
assert_eq!(PeerAclDecision::DenyList.to_string(), "denylist match");
assert_eq!(PeerAclDecision::DefaultAllow.to_string(), "default allow");
}
#[test]
fn test_acl_context_display() {
assert_eq!(
PeerAclContext::OutboundConnect.to_string(),
"outbound_connect"
);
assert_eq!(
PeerAclContext::InboundHandshake.to_string(),
"inbound_handshake"
);
assert_eq!(
PeerAclContext::OutboundHandshake.to_string(),
"outbound_handshake"
);
}
#[test]
fn test_acl_missing_files_default_open() {
let acl = PeerAcl::load_files(
Path::new("/nonexistent/allow"),
Path::new("/nonexistent/deny"),
);
let peer = PeerIdentity::from_npub(&test_npub()).unwrap();
assert_eq!(acl.check(&peer), PeerAclDecision::DefaultAllow);
assert!(acl.is_empty());
}
#[test]
fn test_acl_allow_match_wins() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let npub = test_npub();
std::fs::write(&allow, format!("{npub}\n")).unwrap();
std::fs::write(&deny, format!("ALL\n{npub}\n")).unwrap();
let acl = PeerAcl::load_files(&allow, &deny);
let peer = PeerIdentity::from_npub(&npub).unwrap();
assert_eq!(acl.check(&peer), PeerAclDecision::AllowList);
}
#[test]
fn test_acl_allow_all_overrides_deny_all_and_specific_entries() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let npub = test_npub();
write_file(&allow, "aLl # wildcard\n");
write_file(&deny, &format!("ALL\n{npub}\n"));
let acl = PeerAcl::load_files(&allow, &deny);
let peer = test_peer(&npub);
assert_eq!(acl.check(&peer), PeerAclDecision::AllowList);
assert_eq!(acl.effective_mode(), "allow_all");
assert_eq!(acl.default_decision(), "allow");
assert!(acl.allow_file_entries().is_empty());
assert_eq!(acl.deny_file_entries(), vec![npub.clone()]);
assert!(acl.allow_entries().is_empty());
assert_eq!(acl.deny_entries(), vec![npub]);
}
#[test]
fn test_acl_allowlist_miss_falls_through_to_default_allow() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let allowed = test_npub();
let denied = test_npub();
std::fs::write(&allow, format!("{allowed}\n")).unwrap();
let acl = PeerAcl::load_files(&allow, &deny);
assert_eq!(
acl.check(&PeerIdentity::from_npub(&allowed).unwrap()),
PeerAclDecision::AllowList
);
assert_eq!(
acl.check(&PeerIdentity::from_npub(&denied).unwrap()),
PeerAclDecision::DefaultAllow
);
}
#[test]
fn test_acl_deny_only() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let denied = test_npub();
let other = test_npub();
std::fs::write(&deny, format!("{denied}\n")).unwrap();
let acl = PeerAcl::load_files(&allow, &deny);
assert_eq!(
acl.check(&PeerIdentity::from_npub(&denied).unwrap()),
PeerAclDecision::DenyList
);
assert_eq!(
acl.check(&PeerIdentity::from_npub(&other).unwrap()),
PeerAclDecision::DefaultAllow
);
}
#[test]
fn test_acl_deny_all() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
std::fs::write(&deny, "ALL\n").unwrap();
let acl = PeerAcl::load_files(&allow, &deny);
let peer = PeerIdentity::from_npub(&test_npub()).unwrap();
assert_eq!(acl.check(&peer), PeerAclDecision::DenyList);
}
#[test]
fn test_acl_deny_applies_after_allowlist_miss() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let allowed = test_npub();
let denied = test_npub();
std::fs::write(&allow, format!("{allowed}\n")).unwrap();
std::fs::write(&deny, format!("{denied}\n")).unwrap();
let acl = PeerAcl::load_files(&allow, &deny);
assert_eq!(
acl.check(&PeerIdentity::from_npub(&denied).unwrap()),
PeerAclDecision::DenyList
);
}
#[test]
fn test_acl_effective_mode_and_default_decision_matrix() {
let default_open = acl_with_shape(false, false, false, false);
assert_eq!(default_open.effective_mode(), "default_open");
assert_eq!(default_open.default_decision(), "allow");
let allowlist = acl_with_shape(true, false, false, false);
assert_eq!(allowlist.effective_mode(), "allowlist");
assert_eq!(allowlist.default_decision(), "allow");
let denylist = acl_with_shape(false, true, false, false);
assert_eq!(denylist.effective_mode(), "denylist");
assert_eq!(denylist.default_decision(), "allow");
let allow_then_deny = acl_with_shape(true, true, false, false);
assert_eq!(allow_then_deny.effective_mode(), "allow_then_deny");
assert_eq!(allow_then_deny.default_decision(), "allow");
let deny_all = acl_with_shape(false, false, false, true);
assert_eq!(deny_all.effective_mode(), "deny_all");
assert_eq!(deny_all.default_decision(), "deny");
let allow_then_deny_all = acl_with_shape(true, false, false, true);
assert_eq!(allow_then_deny_all.effective_mode(), "allow_then_deny_all");
assert_eq!(allow_then_deny_all.default_decision(), "deny");
let allow_all = acl_with_shape(false, false, true, false);
assert_eq!(allow_all.effective_mode(), "allow_all");
assert_eq!(allow_all.default_decision(), "allow");
}
#[test]
fn test_acl_inline_comments_and_bad_lines() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let npub = test_npub();
std::fs::write(
&allow,
format!("# comment\n{npub} # inline comment\ninvalid entry here\n"),
)
.unwrap();
let acl = PeerAcl::load_files(&allow, &deny);
assert_eq!(
acl.check(&PeerIdentity::from_npub(&npub).unwrap()),
PeerAclDecision::AllowList
);
}
#[test]
fn test_acl_unknown_alias_and_invalid_entries_do_not_activate_enforcement() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
write_file(
&allow,
"# comment only\nunknown-alias\nnot-a-valid-npub\ninvalid entry here\n",
);
let acl = PeerAcl::load_files(&allow, &deny);
assert!(acl.is_empty());
assert!(acl.allow_file_entries().is_empty());
assert!(acl.allow_entries().is_empty());
}
#[test]
fn test_acl_read_error_is_ignored() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
std::fs::create_dir(&allow).unwrap();
let acl = PeerAcl::load_files(&allow, &deny);
assert!(acl.is_empty());
assert_eq!(
acl.check(&test_peer(&test_npub())),
PeerAclDecision::DefaultAllow
);
}
#[test]
fn test_acl_alias_lookup_is_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let npub = test_npub();
let mut hosts = HostMap::new();
hosts.insert("node-a", &npub).unwrap();
write_file(&allow, "NODE-A\n");
let acl = PeerAcl::load_files_with_hosts(&allow, &deny, &hosts);
assert_eq!(acl.allow_file_entries(), vec!["NODE-A".to_string()]);
assert_eq!(acl.allow_entries(), vec![npub.clone()]);
assert_eq!(acl.check(&test_peer(&npub)), PeerAclDecision::AllowList);
}
#[test]
fn test_acl_alias_and_npub_for_same_peer_deduplicate_effective_entries() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let npub = test_npub();
let mut hosts = HostMap::new();
hosts.insert("node-a", &npub).unwrap();
write_file(&allow, &format!("node-a\n{npub}\nnode-a\n"));
let acl = PeerAcl::load_files_with_hosts(&allow, &deny, &hosts);
assert_eq!(
acl.allow_file_entries(),
vec!["node-a".to_string(), npub.clone()]
);
assert_eq!(acl.allow_entries(), vec![npub.clone()]);
assert_eq!(acl.check(&test_peer(&npub)), PeerAclDecision::AllowList);
}
#[test]
fn test_acl_reloader_detects_change() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let denied = test_npub();
let mut reloader = PeerAclReloader::with_paths(allow.clone(), deny.clone());
assert!(!reloader.check_reload());
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::write(&deny, format!("{denied}\n")).unwrap();
assert!(reloader.check_reload());
assert_eq!(
reloader
.acl()
.check(&PeerIdentity::from_npub(&denied).unwrap()),
PeerAclDecision::DenyList
);
}
#[test]
fn test_acl_reloader_detects_allow_file_removal() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let allowed = test_npub();
write_file(&allow, &format!("{allowed}\n"));
let mut reloader = PeerAclReloader::with_paths(allow.clone(), deny);
assert_eq!(
reloader.acl().check(&test_peer(&allowed)),
PeerAclDecision::AllowList
);
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::remove_file(&allow).unwrap();
assert!(reloader.check_reload());
assert!(reloader.acl().is_empty());
assert_eq!(
reloader.acl().check(&test_peer(&allowed)),
PeerAclDecision::DefaultAllow
);
}
#[test]
fn test_acl_status_reports_effective_state_and_entries() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let allowed = test_npub();
let denied = test_npub();
std::fs::write(&allow, format!("{allowed}\n")).unwrap();
std::fs::write(&deny, format!("{denied}\n")).unwrap();
let reloader = PeerAclReloader::with_paths(allow.clone(), deny.clone());
let status = reloader.status();
assert_eq!(status.allow_file, allow.display().to_string());
assert_eq!(status.deny_file, deny.display().to_string());
assert!(status.enforcement_active);
assert_eq!(status.effective_mode, "allow_then_deny");
assert_eq!(status.default_decision, "allow");
assert_eq!(status.allow_file_entries, vec![allowed.clone()]);
assert_eq!(status.deny_file_entries, vec![denied.clone()]);
assert_eq!(status.allow_entries, vec![allowed]);
assert_eq!(status.deny_entries, vec![denied]);
}
#[test]
fn test_acl_status_reports_default_open_state() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let reloader = PeerAclReloader::with_paths(allow, deny);
let status = reloader.status();
assert!(!status.enforcement_active);
assert_eq!(status.effective_mode, "default_open");
assert_eq!(status.default_decision, "allow");
assert!(!status.allow_all);
assert!(!status.deny_all);
assert!(status.allow_file_entries.is_empty());
assert!(status.deny_file_entries.is_empty());
assert!(status.allow_entries.is_empty());
assert!(status.deny_entries.is_empty());
}
#[test]
fn test_acl_status_reports_allow_all_state() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
write_file(&allow, "ALL\n");
let reloader = PeerAclReloader::with_paths(allow, deny);
let status = reloader.status();
assert!(status.enforcement_active);
assert_eq!(status.effective_mode, "allow_all");
assert_eq!(status.default_decision, "allow");
assert!(status.allow_all);
assert!(!status.deny_all);
assert!(status.allow_file_entries.is_empty());
assert!(status.allow_entries.is_empty());
}
#[test]
fn test_acl_status_reports_deny_all_default_decision() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
std::fs::write(&deny, "ALL\n").unwrap();
let reloader = PeerAclReloader::with_paths(allow, deny);
let status = reloader.status();
assert_eq!(status.effective_mode, "deny_all");
assert_eq!(status.default_decision, "deny");
}
#[test]
fn test_acl_alias_resolves_from_host_map() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let npub = test_npub();
let mut hosts = HostMap::new();
hosts.insert("node-a", &npub).unwrap();
std::fs::write(&allow, "node-a\n").unwrap();
let acl = PeerAcl::load_files_with_hosts(&allow, &deny, &hosts);
let peer = PeerIdentity::from_npub(&npub).unwrap();
assert_eq!(acl.allow_file_entries(), vec!["node-a".to_string()]);
assert_eq!(acl.allow_entries(), vec![npub]);
assert_eq!(acl.check(&peer), PeerAclDecision::AllowList);
}
#[test]
fn test_acl_reloader_detects_hosts_change_for_alias_entry() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let hosts = dir.path().join("hosts");
let npub = test_npub();
std::fs::write(&allow, "node-a\n").unwrap();
let mut reloader =
PeerAclReloader::with_alias_sources(allow.clone(), deny, HostMap::new(), hosts.clone());
assert!(reloader.acl().is_empty());
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::write(&hosts, format!("node-a {npub}\n")).unwrap();
assert!(reloader.check_reload());
assert_eq!(
reloader.acl().allow_file_entries(),
vec!["node-a".to_string()]
);
assert_eq!(reloader.acl().allow_entries(), vec![npub.clone()]);
assert_eq!(
reloader
.acl()
.check(&PeerIdentity::from_npub(&npub).unwrap()),
PeerAclDecision::AllowList
);
}
#[test]
fn test_acl_reloader_detects_hosts_removal_for_alias_entry() {
let dir = tempfile::tempdir().unwrap();
let allow = dir.path().join("peers.allow");
let deny = dir.path().join("peers.deny");
let hosts = dir.path().join("hosts");
let npub = test_npub();
write_file(&allow, "node-a\n");
write_file(&hosts, &format!("node-a {npub}\n"));
let mut reloader =
PeerAclReloader::with_alias_sources(allow, deny, HostMap::new(), hosts.clone());
assert_eq!(
reloader.acl().check(&test_peer(&npub)),
PeerAclDecision::AllowList
);
std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::remove_file(&hosts).unwrap();
assert!(reloader.check_reload());
assert!(reloader.acl().is_empty());
assert_eq!(
reloader.acl().check(&test_peer(&npub)),
PeerAclDecision::DefaultAllow
);
}
}
+116 -72
View File
@@ -6,6 +6,7 @@
//! - msg3 (initiator → responder): initiator identity + epoch + negotiation
use crate::PeerIdentity;
use crate::node::acl::PeerAclContext;
use crate::node::wire::{Msg1Header, Msg2Header, Msg3Header, build_msg2, build_msg3};
use crate::node::{Node, NodeError};
use crate::peer::{ActivePeer, PeerConnection, PromotionResult, cross_connection_winner};
@@ -377,19 +378,23 @@ impl Node {
return;
}
let Some(conn) = self.connections.get_mut(&link_id) else {
warn!(link_id = %link_id, "Connection removed during msg2 processing");
self.pending_outbound.remove(&key);
return;
};
let (peer_identity, msg3_bytes, our_index) = {
let Some(conn) = self.connections.get_mut(&link_id) else {
warn!(link_id = %link_id, "Connection removed during msg2 processing");
self.pending_outbound.remove(&key);
return;
};
// Create FMP negotiation payload for msg3 (includes profile, MMP bits, bloom TLV)
let neg_payload = NegotiationPayload::fmp(1, 1, self.node_profile).encode();
// Create FMP negotiation payload for msg3 (includes profile, MMP bits, bloom TLV)
let neg_payload = NegotiationPayload::fmp(1, 1, self.node_profile).encode();
// Process Noise msg2 and generate msg3
let noise_msg2 = &packet.data[header.noise_msg2_offset..];
let (msg3_bytes, received_negotiation) =
match conn.complete_handshake(noise_msg2, Some(&neg_payload), packet.timestamp_ms) {
// Process Noise msg2 and generate msg3
let noise_msg2 = &packet.data[header.noise_msg2_offset..];
let (msg3_bytes, received_negotiation) = match conn.complete_handshake(
noise_msg2,
Some(&neg_payload),
packet.timestamp_ms,
) {
Ok(result) => result,
Err(e) => {
warn!(
@@ -402,37 +407,55 @@ impl Node {
}
};
// Process peer's FMP negotiation payload from msg2
if let Some(neg_bytes) = &received_negotiation {
match process_fmp_negotiation(self.node_profile, conn, neg_bytes) {
Ok(()) => {}
Err(e) => {
warn!(link_id = %link_id, our_profile = %self.node_profile, error = %e, "FMP negotiation failed");
conn.mark_failed();
return;
// Process peer's FMP negotiation payload from msg2
if let Some(neg_bytes) = &received_negotiation {
match process_fmp_negotiation(self.node_profile, conn, neg_bytes) {
Ok(()) => {}
Err(e) => {
warn!(link_id = %link_id, our_profile = %self.node_profile, error = %e, "FMP negotiation failed");
conn.mark_failed();
return;
}
}
}
}
// Store their index
conn.set_their_index(header.sender_idx);
conn.set_source_addr(packet.remote_addr.clone());
// Store their index
conn.set_their_index(header.sender_idx);
conn.set_source_addr(packet.remote_addr.clone());
// Get peer identity for promotion (learned from msg2 in XX)
let peer_identity = match conn.expected_identity() {
Some(id) => *id,
None => {
warn!(link_id = %link_id, "No identity after handshake");
return;
}
// Get peer identity for promotion (learned from msg2 in XX)
let peer_identity = match conn.expected_identity() {
Some(id) => *id,
None => {
warn!(link_id = %link_id, "No identity after handshake");
return;
}
};
let our_index = conn.our_index();
(peer_identity, msg3_bytes, our_index)
};
let peer_node_addr = *peer_identity.node_addr();
// Post-handshake identity filtering hook (IDEA-0047).
// With XX, shared-media transports discover peers without identity;
// this is the first point where the initiator knows the responder.
// Future: check allow/deny list here, abort if denied.
// ACL check: with XX, this is the first point where the initiator
// knows the responder's identity.
if self
.authorize_peer(
&peer_identity,
PeerAclContext::OutboundHandshake,
packet.transport_id,
&packet.remote_addr,
)
.is_err()
{
self.pending_outbound.remove(&key);
self.connections.remove(&link_id);
self.remove_link(&link_id);
return;
}
if peer_node_addr == *self.identity.node_addr() {
debug!(link_id = %link_id, "Discovered self via shared-media beacon, dropping");
self.connections.remove(&link_id);
@@ -440,7 +463,7 @@ impl Node {
}
// Build and send msg3
let our_index = conn.our_index().unwrap_or(header.receiver_idx);
let our_index = our_index.unwrap_or(header.receiver_idx);
let wire_msg3 = build_msg3(our_index, header.sender_idx, &msg3_bytes);
if let Some(transport) = self.transports.get(&packet.transport_id) {
@@ -716,22 +739,24 @@ impl Node {
}
};
// Get the pending connection
let conn = match self.connections.get_mut(&link_id) {
Some(c) => c,
None => {
debug!(
link_id = %link_id,
"No pending connection for msg3"
);
return;
}
};
let (peer_identity, our_index, remote_epoch) = {
// Get the pending connection
let conn = match self.connections.get_mut(&link_id) {
Some(c) => c,
None => {
debug!(
link_id = %link_id,
"No pending connection for msg3"
);
return;
}
};
// Process msg3 — learns initiator's identity and epoch
let noise_msg3 = &packet.data[header.noise_msg3_offset..];
let received_negotiation =
match conn.complete_handshake_msg3(noise_msg3, packet.timestamp_ms) {
// Process msg3 — learns initiator's identity and epoch
let noise_msg3 = &packet.data[header.noise_msg3_offset..];
let received_negotiation = match conn
.complete_handshake_msg3(noise_msg3, packet.timestamp_ms)
{
Ok(neg) => neg,
Err(e) => {
warn!(
@@ -749,35 +774,54 @@ impl Node {
}
};
// Process peer's FMP negotiation payload from msg3
if let Some(neg_bytes) = &received_negotiation {
match process_fmp_negotiation(self.node_profile, conn, neg_bytes) {
Ok(()) => {}
Err(e) => {
warn!(link_id = %link_id, our_profile = %self.node_profile, error = %e, "FMP negotiation failed");
// Process peer's FMP negotiation payload from msg3
if let Some(neg_bytes) = &received_negotiation {
match process_fmp_negotiation(self.node_profile, conn, neg_bytes) {
Ok(()) => {}
Err(e) => {
warn!(link_id = %link_id, our_profile = %self.node_profile, error = %e, "FMP negotiation failed");
self.connections.remove(&link_id);
self.remove_link(&link_id);
return;
}
}
}
// Learn peer identity from msg3
let peer_identity = match conn.expected_identity() {
Some(id) => *id,
None => {
warn!("Identity not learned from msg3");
self.connections.remove(&link_id);
self.remove_link(&link_id);
return;
}
}
}
};
// Learn peer identity from msg3
let peer_identity = match conn.expected_identity() {
Some(id) => *id,
None => {
warn!("Identity not learned from msg3");
self.connections.remove(&link_id);
self.remove_link(&link_id);
return;
}
let our_index = conn.our_index();
let remote_epoch = conn.remote_epoch();
(peer_identity, our_index, remote_epoch)
};
let peer_node_addr = *peer_identity.node_addr();
// Post-handshake identity filtering hook (IDEA-0047).
// With XX, this is the first point where the responder knows
// the initiator's identity. Future: check allow/deny list here.
// ACL check: with XX, this is the first point where the responder
// knows the initiator's identity.
if self
.authorize_peer(
&peer_identity,
PeerAclContext::InboundHandshake,
packet.transport_id,
&packet.remote_addr,
)
.is_err()
{
self.connections.remove(&link_id);
self.remove_link(&link_id);
return;
}
if peer_node_addr == *self.identity.node_addr() {
debug!(link_id = %link_id, "Received msg3 from self, dropping");
self.connections.remove(&link_id);
@@ -785,14 +829,14 @@ impl Node {
return;
}
let our_index = conn.our_index().unwrap_or(header.receiver_idx);
let our_index = our_index.unwrap_or(header.receiver_idx);
// Identity-based restart/rekey detection.
//
// Now that we know the initiator's identity from msg3, perform the
// same checks that the old handle_msg1 used to do after decrypting msg1.
if let Some(existing_peer) = self.peers.get(&peer_node_addr) {
let new_epoch = conn.remote_epoch();
let new_epoch = remote_epoch;
let existing_epoch = existing_peer.remote_epoch();
match (existing_epoch, new_epoch) {
+1
View File
@@ -118,6 +118,7 @@ impl Node {
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.reload_peer_acl();
self.poll_pending_connects().await;
self.resend_pending_handshakes(now_ms).await;
self.resend_pending_rekeys(now_ms).await;
+11
View File
@@ -1,6 +1,7 @@
//! Node lifecycle management: start, stop, and peer connection initiation.
use super::{Node, NodeError, NodeState};
use crate::node::acl::PeerAclContext;
use crate::node::wire::build_msg1;
use crate::peer::PeerConnection;
use crate::protocol::{Disconnect, DisconnectReason};
@@ -168,6 +169,7 @@ impl Node {
.await
{
Ok(()) => return Ok(()),
Err(e @ NodeError::AccessDenied(_)) => return Err(e),
Err(e) => {
debug!(
npub = %peer_config.npub,
@@ -203,6 +205,15 @@ impl Node {
remote_addr: TransportAddr,
peer_identity: Option<PeerIdentity>,
) -> Result<(), NodeError> {
if let Some(ref identity) = peer_identity {
self.authorize_peer(
identity,
PeerAclContext::OutboundConnect,
transport_id,
&remote_addr,
)?;
}
let is_connection_oriented = self
.transports
.get(&transport_id)
+30 -2
View File
@@ -4,6 +4,7 @@
//! holds all state required for mesh routing: identity, tree state,
//! Bloom filters, coordinate caches, transports, links, and peers.
mod acl;
mod bloom;
mod discovery_rate_limit;
mod handlers;
@@ -88,6 +89,9 @@ pub enum NodeError {
#[error("invalid peer npub '{npub}': {reason}")]
InvalidPeerNpub { npub: String, reason: String },
#[error("access denied: {0}")]
AccessDenied(String),
#[error("max connections exceeded: {max}")]
MaxConnectionsExceeded { max: usize },
@@ -452,6 +456,9 @@ pub struct Node {
/// Populated at startup from peer config.
peer_aliases: HashMap<NodeAddr, String>,
/// Reloadable peer ACL state from standard allow/deny files.
peer_acl: acl::PeerAclReloader,
// === Host Map ===
/// Static hostname → npub mapping for DNS resolution.
/// Built at construction from peer aliases and /etc/fips/hosts.
@@ -513,12 +520,20 @@ impl Node {
let backoff_max_secs = config.node.discovery.backoff_max_secs;
let forward_min_interval_secs = config.node.discovery.forward_min_interval_secs;
let mut host_map = HostMap::from_peer_configs(config.peers());
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 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,
hosts_path,
);
Ok(Self {
identity,
@@ -582,6 +597,7 @@ impl Node {
estimated_mesh_size: None,
last_mesh_size_log: None,
peer_aliases: HashMap::new(),
peer_acl,
host_map,
})
}
@@ -630,7 +646,18 @@ impl Node {
let max_links = config.node.limits.max_links;
let coords_response_interval_ms = config.node.session.coords_response_interval_ms;
let host_map = Arc::new(HostMap::new());
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 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),
);
Self {
identity,
@@ -692,6 +719,7 @@ impl Node {
estimated_mesh_size: None,
last_mesh_size_log: None,
peer_aliases: HashMap::new(),
peer_acl,
host_map,
}
}
+152
View File
@@ -0,0 +1,152 @@
use super::*;
use crate::ReceivedPacket;
use crate::node::acl::PeerAclReloader;
use crate::node::wire::{build_msg1, build_msg2};
use crate::utils::index::SessionIndex;
use std::path::PathBuf;
use std::time::Duration;
fn make_acl_node() -> (tempfile::TempDir, Node) {
let dir = tempfile::tempdir().unwrap();
let mut node = Node::new(Config::new()).unwrap();
node.peer_acl = PeerAclReloader::with_paths(
dir.path().join("peers.allow"),
dir.path().join("peers.deny"),
);
(dir, node)
}
fn allow_path(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join("peers.allow")
}
fn deny_path(dir: &tempfile::TempDir) -> PathBuf {
dir.path().join("peers.deny")
}
#[tokio::test]
async fn test_outbound_connect_denied_by_denylist() {
let (dir, mut node) = make_acl_node();
let denied = Identity::generate();
std::fs::write(deny_path(&dir), format!("{}\n", denied.npub())).unwrap();
node.reload_peer_acl();
let result = node
.initiate_connection(
TransportId::new(1),
TransportAddr::from_string("127.0.0.1:9000"),
PeerIdentity::from_pubkey_full(denied.pubkey_full()),
)
.await;
assert!(matches!(result, Err(NodeError::AccessDenied(_))));
assert_eq!(node.link_count(), 0);
assert_eq!(node.connection_count(), 0);
assert_eq!(node.peer_count(), 0);
}
#[tokio::test]
async fn test_inbound_msg1_denied_by_acl() {
let (dir, mut node_b) = make_acl_node();
let node_a = make_node();
std::fs::write(deny_path(&dir), format!("{}\n", node_a.npub())).unwrap();
node_b.reload_peer_acl();
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full());
let mut conn_a = PeerConnection::outbound(LinkId::new(1), peer_b_identity, 1000);
let noise_msg1 = conn_a
.start_handshake(node_a.identity.keypair(), node_a.startup_epoch, 1000)
.unwrap();
let wire_msg1 = build_msg1(SessionIndex::new(7), &noise_msg1);
let packet = ReceivedPacket::with_timestamp(
TransportId::new(1),
TransportAddr::from_string("127.0.0.1:5000"),
wire_msg1,
1000,
);
node_b.handle_msg1(packet).await;
assert_eq!(node_b.peer_count(), 0);
assert_eq!(node_b.connection_count(), 0);
assert_eq!(node_b.link_count(), 0);
}
#[tokio::test]
async fn test_outbound_msg2_denied_after_acl_reload() {
let (dir, mut node_a) = make_acl_node();
let node_b = make_node();
let transport_id = TransportId::new(1);
let remote_addr = TransportAddr::from_string("127.0.0.1:5001");
let peer_b_identity = PeerIdentity::from_pubkey_full(node_b.identity.pubkey_full());
let link_id_a = node_a.allocate_link_id();
let mut conn_a = PeerConnection::outbound(link_id_a, peer_b_identity, 1000);
let our_index_a = node_a.index_allocator.allocate().unwrap();
let noise_msg1 = conn_a
.start_handshake(node_a.identity.keypair(), node_a.startup_epoch, 1000)
.unwrap();
conn_a.set_our_index(our_index_a);
conn_a.set_transport_id(transport_id);
conn_a.set_source_addr(remote_addr.clone());
let link_a = Link::connectionless(
link_id_a,
transport_id,
remote_addr.clone(),
LinkDirection::Outbound,
Duration::from_millis(100),
);
node_a.links.insert(link_id_a, link_a);
node_a
.addr_to_link
.insert((transport_id, remote_addr.clone()), link_id_a);
node_a.connections.insert(link_id_a, conn_a);
node_a
.pending_outbound
.insert((transport_id, our_index_a.as_u32()), link_id_a);
let mut conn_b = PeerConnection::inbound(LinkId::new(2), 1000);
let responder_epoch = [0x11; 8];
let noise_msg2 = conn_b
.receive_handshake_init(
node_b.identity.keypair(),
responder_epoch,
&noise_msg1,
1000,
)
.unwrap();
let our_index_b = SessionIndex::new(9);
let wire_msg2 = build_msg2(our_index_b, our_index_a, &noise_msg2);
std::fs::write(deny_path(&dir), format!("{}\n", node_b.npub())).unwrap();
assert!(node_a.reload_peer_acl());
let packet = ReceivedPacket::with_timestamp(transport_id, remote_addr, wire_msg2, 1100);
node_a.handle_msg2(packet).await;
assert_eq!(node_a.peer_count(), 0);
assert_eq!(node_a.connection_count(), 0);
assert_eq!(node_a.link_count(), 0);
assert!(node_a.pending_outbound.is_empty());
}
#[tokio::test]
async fn test_outbound_connect_not_denied_by_allowlist_miss() {
let (dir, mut node) = make_acl_node();
let denied = Identity::generate();
let allowed = Identity::generate();
std::fs::write(allow_path(&dir), format!("{}\n", allowed.npub())).unwrap();
node.reload_peer_acl();
let result = node
.initiate_connection(
TransportId::new(1),
TransportAddr::from_string("127.0.0.1:9000"),
PeerIdentity::from_pubkey_full(denied.pubkey_full()),
)
.await;
assert!(!matches!(result, Err(NodeError::AccessDenied(_))));
}
+2
View File
@@ -439,6 +439,8 @@ async fn test_bloom_filter_split_horizon() {
/// 100-node random graph: bloom filter exchange at scale.
#[tokio::test]
async fn test_bloom_filter_convergence_100_nodes() {
let _guard = lock_large_network_test().await;
const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250;
const SEED: u64 = 42;
+4 -2
View File
@@ -9,8 +9,8 @@ use crate::node::RecentRequest;
use crate::protocol::{LookupRequest, LookupResponse};
use crate::tree::TreeCoordinate;
use spanning_tree::{
cleanup_nodes, generate_random_edges, process_available_packets, run_tree_test,
run_tree_test_with_mtus, verify_tree_convergence,
cleanup_nodes, generate_random_edges, lock_large_network_test, process_available_packets,
run_tree_test, run_tree_test_with_mtus, verify_tree_convergence,
};
// ============================================================================
@@ -513,6 +513,8 @@ async fn test_request_dedup_convergent_paths() {
#[tokio::test]
#[ignore] // Long-running (~2 min): run explicitly with --ignored
async fn test_discovery_100_nodes() {
let _guard = lock_large_network_test().await;
// Set up a 100-node random topology (same seed as other 100-node tests).
// Each node initiates lookups to a sample of other nodes in batches,
// processing packets between batches to avoid flooding the network.
+5 -1
View File
@@ -8,7 +8,7 @@ use crate::bloom::BloomFilter;
use crate::tree::{ParentDeclaration, TreeCoordinate};
use spanning_tree::{
TestNode, cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake,
make_test_node, run_tree_test, verify_tree_convergence,
lock_large_network_test, make_test_node, run_tree_test, verify_tree_convergence,
};
use std::collections::HashSet;
@@ -579,6 +579,8 @@ fn simulate_forwarding(
/// without loops.
#[tokio::test]
async fn test_routing_reachability_100_nodes() {
let _guard = lock_large_network_test().await;
const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250;
const SEED: u64 = 42;
@@ -897,6 +899,8 @@ async fn test_routing_bloom_only_transit() {
/// non-adjacent nodes. Direct peer adjacency handles the last hop.
#[tokio::test]
async fn test_routing_source_only_coords_100_nodes() {
let _guard = lock_large_network_test().await;
const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250;
const SEED: u64 = 42;
+4 -2
View File
@@ -3,8 +3,8 @@
use super::*;
use crate::node::session::EndToEndState;
use crate::node::tests::spanning_tree::{
TestNode, cleanup_nodes, generate_random_edges, process_available_packets, run_tree_test,
run_tree_test_with_mtus, verify_tree_convergence,
TestNode, cleanup_nodes, generate_random_edges, lock_large_network_test,
process_available_packets, run_tree_test, run_tree_test_with_mtus, verify_tree_convergence,
};
use crate::protocol::{SessionAck, SessionDatagram};
@@ -509,6 +509,8 @@ async fn drain_to_quiescence(nodes: &mut [TestNode]) {
#[tokio::test]
async fn test_session_100_nodes() {
let _guard = lock_large_network_test().await;
use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng};
use std::sync::mpsc;
+9
View File
@@ -8,6 +8,13 @@ use super::*;
use crate::protocol::TreeAnnounce;
use crate::tree::{CoordEntry, ParentDeclaration, TreeCoordinate};
static LARGE_NETWORK_TEST_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
pub(super) async fn lock_large_network_test() -> tokio::sync::MutexGuard<'static, ()> {
LARGE_NETWORK_TEST_LOCK.lock().await
}
/// A test node bundling a Node with its transport and packet channel.
pub(super) struct TestNode {
pub(super) node: Node,
@@ -669,6 +676,8 @@ pub(super) async fn cleanup_nodes(nodes: &mut [TestNode]) {
/// consistent spanning tree with the correct root.
#[tokio::test]
async fn test_spanning_tree_convergence_100_nodes() {
let _guard = lock_large_network_test().await;
const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250;
const SEED: u64 = 42;