From 745b523ac6a9c9b469348baa4c92cc4bfc18fd75 Mon Sep 17 00:00:00 2001 From: Alex Xie Date: Thu, 16 Apr 2026 05:13:07 +0000 Subject: [PATCH] Add peer ACL enforcement with reloadable allow/deny files (#50) Implement TCP Wrappers-style peer access control using /etc/fips/peers.allow and /etc/fips/peers.deny files. Evaluation order: allow overrides deny, default permit when no files exist. Three enforcement points: outbound connect (before dialing), inbound handshake (msg1 receipt, after restart/rekey classification), and outbound handshake completion (msg2, before peer promotion). Files support npub, hex pubkey, host alias, and ALL wildcard entries with automatic mtime-based reload. Adds fipsctl acl show query, 954-line acl module with unit tests, and a 6-node Docker integration harness (testing/acl-allowlist/) exercising insider, outsider, and allowed-remote scenarios. CI matrix entry included. Closes #50 Co-authored-by: Johnathan Corgan --- .github/workflows/ci.yml | 17 + src/bin/fipsctl.rs | 37 + src/control/queries.rs | 20 + src/node/acl.rs | 954 ++++++++++++++++++++++ src/node/handlers/handshake.rs | 84 +- src/node/handlers/rx_loop.rs | 1 + src/node/lifecycle.rs | 9 + src/node/mod.rs | 32 +- src/node/tests/acl.rs | 152 ++++ src/node/tests/bloom.rs | 2 + src/node/tests/discovery.rs | 6 +- src/node/tests/routing.rs | 6 +- src/node/tests/session.rs | 6 +- src/node/tests/spanning_tree.rs | 9 + testing/acl-allowlist/.gitignore | 1 + testing/acl-allowlist/README.md | 164 ++++ testing/acl-allowlist/docker-compose.yml | 111 +++ testing/acl-allowlist/generate-configs.sh | 306 +++++++ testing/acl-allowlist/test.sh | 150 ++++ 19 files changed, 2038 insertions(+), 29 deletions(-) create mode 100644 src/node/acl.rs create mode 100644 src/node/tests/acl.rs create mode 100644 testing/acl-allowlist/.gitignore create mode 100644 testing/acl-allowlist/README.md create mode 100644 testing/acl-allowlist/docker-compose.yml create mode 100755 testing/acl-allowlist/generate-configs.sh create mode 100755 testing/acl-allowlist/test.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52848fc..adeb062 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -258,6 +258,8 @@ jobs: - suite: rekey type: rekey topology: rekey + - suite: acl-allowlist + type: acl-allowlist # ── Chaos / stochastic scenarios ─────────────────────────────────── - suite: chaos-smoke-10 type: chaos @@ -379,6 +381,21 @@ jobs: docker compose -f testing/static/docker-compose.yml \ --profile rekey down --volumes --remove-orphans + # ── ACL allowlist integration test ───────────────────────────────────── + - name: Run ACL allowlist integration test + if: matrix.type == 'acl-allowlist' + run: bash testing/acl-allowlist/test.sh --skip-build --keep-up + + - name: Collect logs on failure (acl-allowlist) + if: matrix.type == 'acl-allowlist' && failure() + run: | + docker compose -f testing/acl-allowlist/docker-compose.yml logs --no-color + + - name: Stop containers (acl-allowlist) + if: matrix.type == 'acl-allowlist' && always() + run: | + docker compose -f testing/acl-allowlist/docker-compose.yml down --volumes --remove-orphans + # ── Chaos simulation ─────────────────────────────────────────────────── - name: Install Python deps (chaos) if: matrix.type == 'chaos' diff --git a/src/bin/fipsctl.rs b/src/bin/fipsctl.rs index 3dc2376..bf621e6 100644 --- a/src/bin/fipsctl.rs +++ b/src/bin/fipsctl.rs @@ -40,6 +40,11 @@ enum Commands { #[command(subcommand)] what: ShowCommands, }, + /// Show peer ACL information + Acl { + #[command(subcommand)] + what: AclCommands, + }, /// Generate a new FIPS identity keypair Keygen { /// Output directory for fips.key and fips.pub @@ -128,6 +133,12 @@ enum ShowCommands { IdentityCache, } +#[derive(Subcommand, Debug)] +enum AclCommands { + /// Loaded peer ACL state + Show, +} + impl ShowCommands { fn command_name(&self) -> &'static str { match self { @@ -147,6 +158,14 @@ impl ShowCommands { } } +impl AclCommands { + fn command_name(&self) -> &'static str { + match self { + AclCommands::Show => "show_acl", + } + } +} + fn default_socket_path() -> PathBuf { fips::config::default_control_path() } @@ -403,6 +422,7 @@ fn main() { let request = match &cli.command { Commands::Show { what } => build_query(what.command_name()), + Commands::Acl { what } => build_query(what.command_name()), Commands::Connect { peer, address, @@ -584,6 +604,23 @@ fn sparkline(values: &[f64], min: f64, max: f64) -> String { mod tests { use super::*; + #[test] + fn test_acl_show_command_name() { + assert_eq!(AclCommands::Show.command_name(), "show_acl"); + } + + #[test] + fn test_cli_parses_acl_show() { + let cli = Cli::try_parse_from(["fipsctl", "acl", "show"]).unwrap(); + + assert!(matches!( + cli.command, + Commands::Acl { + what: AclCommands::Show + } + )); + } + #[test] fn detects_bare_ula_literal() { assert!(is_fips_mesh_address("fd9d:abcd::1")); diff --git a/src/control/queries.rs b/src/control/queries.rs index 908dd15..16e6c6d 100644 --- a/src/control/queries.rs +++ b/src/control/queries.rs @@ -88,6 +88,25 @@ pub fn show_status(node: &Node) -> Value { }) } +/// `show_acl` — Loaded peer ACL state. +pub fn show_acl(node: &Node) -> Value { + let status = node.peer_acl_status(); + + json!({ + "allow_file": status.allow_file, + "deny_file": status.deny_file, + "enforcement_active": status.enforcement_active, + "effective_mode": status.effective_mode, + "default_decision": status.default_decision, + "allow_all": status.allow_all, + "deny_all": status.deny_all, + "allow_file_entries": status.allow_file_entries, + "deny_file_entries": status.deny_file_entries, + "allow_entries": status.allow_entries, + "deny_entries": status.deny_entries, + }) +} + /// `show_peers` — Authenticated peers. pub fn show_peers(node: &Node) -> Value { let tree = node.tree_state(); @@ -1050,6 +1069,7 @@ pub fn show_stats_history_all_peers( /// Dispatch a command string to the appropriate query function. pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response { match command { + "show_acl" => super::protocol::Response::ok(show_acl(node)), "show_status" => super::protocol::Response::ok(show_status(node)), "show_peers" => super::protocol::Response::ok(show_peers(node)), "show_links" => super::protocol::Response::ok(show_links(node)), diff --git a/src/node/acl.rs b/src/node/acl.rs new file mode 100644 index 0000000..3d56274 --- /dev/null +++ b/src/node/acl.rs @@ -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, + pub deny_file_entries: Vec, + pub allow_entries: Vec, + pub deny_entries: Vec, +} + +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, + deny: HashSet, + allow_file_entries: BTreeSet, + deny_file_entries: BTreeSet, + allow_npubs: BTreeSet, + deny_npubs: BTreeSet, + 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 { + 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 { + self.allow_file_entries.iter().cloned().collect() + } + + /// Return the loaded denylist entries as npubs. + pub fn deny_entries(&self) -> Vec { + 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 { + 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, + last_deny_mtime: Option, +} + +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 + ); + } +} diff --git a/src/node/handlers/handshake.rs b/src/node/handlers/handshake.rs index 18bafe2..76dc85c 100644 --- a/src/node/handlers/handshake.rs +++ b/src/node/handlers/handshake.rs @@ -1,6 +1,7 @@ //! Handshake handlers and connection promotion. use crate::PeerIdentity; +use crate::node::acl::PeerAclContext; use crate::node::wire::{Msg1Header, Msg2Header, build_msg2}; use crate::node::{Node, NodeError}; use crate::peer::{ActivePeer, PeerConnection, PromotionResult, cross_connection_winner}; @@ -344,6 +345,19 @@ impl Node { // If possible_restart was true but peer is no longer in self.peers // (removed by another path), fall through to process as new connection. + if self + .authorize_peer( + &peer_identity, + PeerAclContext::InboundHandshake, + packet.transport_id, + &packet.remote_addr, + ) + .is_err() + { + self.msg1_rate_limiter.complete_handshake(); + return; + } + // Note: we don't early-return if peer is already in self.peers here. // promote_connection handles cross-connection resolution via tie-breaker. @@ -608,33 +622,59 @@ impl Node { return; } - let conn = self.connections.get_mut(&link_id).unwrap(); + let (peer_identity, our_index) = { + let conn = self.connections.get_mut(&link_id).unwrap(); - // Process Noise msg2 - let noise_msg2 = &packet.data[header.noise_msg2_offset..]; - if let Err(e) = conn.complete_handshake(noise_msg2, packet.timestamp_ms) { - warn!( - link_id = %link_id, - error = %e, - "Handshake completion failed" - ); - conn.mark_failed(); - return; - } - - // Store their index - conn.set_their_index(header.sender_idx); - conn.set_source_addr(packet.remote_addr.clone()); - - // Get peer identity for promotion - let peer_identity = match conn.expected_identity() { - Some(id) => *id, - None => { - warn!(link_id = %link_id, "No identity after handshake"); + let noise_msg2 = &packet.data[header.noise_msg2_offset..]; + if let Err(e) = conn.complete_handshake(noise_msg2, packet.timestamp_ms) { + warn!( + link_id = %link_id, + error = %e, + "Handshake completion failed" + ); + conn.mark_failed(); return; } + + conn.set_their_index(header.sender_idx); + conn.set_source_addr(packet.remote_addr.clone()); + + let peer_identity = match conn.expected_identity() { + Some(id) => *id, + None => { + warn!(link_id = %link_id, "No identity after handshake"); + return; + } + }; + + (peer_identity, conn.our_index()) }; + if self + .authorize_peer( + &peer_identity, + PeerAclContext::OutboundHandshake, + packet.transport_id, + &packet.remote_addr, + ) + .is_err() + { + self.pending_outbound.remove(&key); + if let Some(link) = self.links.get(&link_id) { + let tid = link.transport_id(); + let addr = link.remote_addr().clone(); + if let Some(transport) = self.transports.get(&tid) { + transport.close_connection(&addr).await; + } + } + self.connections.remove(&link_id); + self.remove_link(&link_id); + if let Some(idx) = our_index { + let _ = self.index_allocator.free(idx); + } + return; + } + let peer_node_addr = *peer_identity.node_addr(); debug!( diff --git a/src/node/handlers/rx_loop.rs b/src/node/handlers/rx_loop.rs index d165624..abf04d4 100644 --- a/src/node/handlers/rx_loop.rs +++ b/src/node/handlers/rx_loop.rs @@ -116,6 +116,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; diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 77beb95..600c8e0 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -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, @@ -205,6 +207,13 @@ impl Node { ) -> Result<(), NodeError> { let peer_node_addr = *peer_identity.node_addr(); + self.authorize_peer( + &peer_identity, + PeerAclContext::OutboundConnect, + transport_id, + &remote_addr, + )?; + let is_connection_oriented = self .transports .get(&transport_id) diff --git a/src/node/mod.rs b/src/node/mod.rs index 297bc59..a29bd23 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -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 }, @@ -443,6 +447,9 @@ pub struct Node { /// Populated at startup from peer config. peer_aliases: HashMap, + /// 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. @@ -503,12 +510,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, @@ -570,6 +585,7 @@ impl Node { estimated_mesh_size: None, last_mesh_size_log: None, peer_aliases: HashMap::new(), + peer_acl, host_map, }) } @@ -618,7 +634,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, @@ -678,6 +705,7 @@ impl Node { estimated_mesh_size: None, last_mesh_size_log: None, peer_aliases: HashMap::new(), + peer_acl, host_map, } } diff --git a/src/node/tests/acl.rs b/src/node/tests/acl.rs new file mode 100644 index 0000000..f19ca2c --- /dev/null +++ b/src/node/tests/acl.rs @@ -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(_)))); +} diff --git a/src/node/tests/bloom.rs b/src/node/tests/bloom.rs index f2df5ac..fdf9fc4 100644 --- a/src/node/tests/bloom.rs +++ b/src/node/tests/bloom.rs @@ -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; diff --git a/src/node/tests/discovery.rs b/src/node/tests/discovery.rs index 905eb1e..0179cbf 100644 --- a/src/node/tests/discovery.rs +++ b/src/node/tests/discovery.rs @@ -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, - verify_tree_convergence, + cleanup_nodes, generate_random_edges, lock_large_network_test, process_available_packets, + run_tree_test, verify_tree_convergence, }; // ============================================================================ @@ -521,6 +521,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. diff --git a/src/node/tests/routing.rs b/src/node/tests/routing.rs index 015b606..838d209 100644 --- a/src/node/tests/routing.rs +++ b/src/node/tests/routing.rs @@ -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; diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index bfd86aa..d56e3f5 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -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}; @@ -510,6 +510,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; diff --git a/src/node/tests/spanning_tree.rs b/src/node/tests/spanning_tree.rs index ee7c796..3fba433 100644 --- a/src/node/tests/spanning_tree.rs +++ b/src/node/tests/spanning_tree.rs @@ -8,6 +8,13 @@ use super::*; use crate::protocol::TreeAnnounce; use crate::tree::{CoordEntry, ParentDeclaration, TreeCoordinate}; +static LARGE_NETWORK_TEST_LOCK: std::sync::LazyLock> = + 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, @@ -667,6 +674,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; diff --git a/testing/acl-allowlist/.gitignore b/testing/acl-allowlist/.gitignore new file mode 100644 index 0000000..31315f6 --- /dev/null +++ b/testing/acl-allowlist/.gitignore @@ -0,0 +1 @@ +generated-configs diff --git a/testing/acl-allowlist/README.md b/testing/acl-allowlist/README.md new file mode 100644 index 0000000..6c5c231 --- /dev/null +++ b/testing/acl-allowlist/README.md @@ -0,0 +1,164 @@ +# ACL Allowlist Test + +Six Docker nodes use per-node ACL files mounted at the hardcoded runtime paths: + +- `node-a` and `node-b` carry the insider allowlist (`node-a`, `node-b`, `node-e`, `node-f`) +- `node-c` and `node-d` each carry a broad allowlist containing every node alias +- `node-e` and `node-f` do not mount any ACL files locally +- every node gets a generated `/etc/fips/hosts` with aliases for `node-a` through `node-f` + +This lets us test three different node behaviors at once: + +- insiders (`a`, `b`) explicitly allow `a`, `b`, `e`, and `f` +- outsiders (`c`, `d`) allow everyone locally, but still cannot join because insiders reject them +- allowed remotes (`e`, `f`) rely on the insider ACLs and do not need local ACL files + +## Test Identities + +Allowed: + +- `node-a` + - `npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m` + - `0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20` +- `node-b` + - `npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le` + - `b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0` + +Denied: + +- `node-c` + - `npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6` + - `c102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fc0` +- `node-d` + - `npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl` + - `d102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fd0` + +Additional allowed: + +- `node-e` + - `npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6` + - `nsec1egyrmekfw3u4l88v8zhrak9uht503s2kvn9v49tqgp6c5l2yuxgsv386l0` +- `node-f` + - `npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c` + - `nsec1afh3nysthqh47awpdewcw59wvvp499f8dvlyclmnv4gvpxdk56dsa6eqsn` + +The generated `fips.key` fixtures use a mix of bare hex and `nsec1...` values. +FIPS accepts either format in key files. + +## Run + +Build the Linux binaries and test image: + +```bash +./testing/scripts/build.sh --no-docker +``` + +Start the ACL test mesh: + +```bash +./testing/acl-allowlist/generate-configs.sh +docker compose -f testing/acl-allowlist/docker-compose.yml up -d --build +``` + +Or run the full integration check: + +```bash +./testing/acl-allowlist/test.sh +``` + +`test.sh` regenerates the ACL fixtures automatically before starting Docker. +The generated ACL files use alias names, and the generated hosts file makes +those aliases resolvable at runtime. + +The ACL harness pins the expected test entrypoint explicitly so it does not +accidentally reuse an older `fips-test:latest` image with a different startup +script. + +Docker service/container/hostname identifiers in this harness intentionally use +`service-*`, `fips-acl-container-*`, and `host-*` names so they do not collide with the logical FIPS aliases +`node-a` through `node-f`. For data-plane checks and operator examples, use the +explicit FIPS names such as `node-a.fips` and `node-d.fips`. + +ACL paths are fixed in this branch: + +- `/etc/fips/peers.allow` +- `/etc/fips/peers.deny` + +Mounted ACL files in this harness: + +- `node-a` and `node-b`: insider allowlist plus `ALL` deny fallback +- `node-c` and `node-d`: broad local allowlist used by outsider nodes trying to blend in +- `node-e` and `node-f`: no ACL files mounted +- all nodes: `/etc/fips/hosts` aliases for `node-a` through `node-f` + +Generated fixture location: + +- `testing/acl-allowlist/generated-configs/` + +Inspect peer state: + +```bash +docker exec fips-acl-container-a fipsctl show peers +docker exec fips-acl-container-b fipsctl show peers +docker exec fips-acl-container-c fipsctl show peers +docker exec fips-acl-container-d fipsctl show peers +docker exec fips-acl-container-e fipsctl show peers +docker exec fips-acl-container-f fipsctl show peers +``` + +Inspect the loaded ACL state directly: + +```bash +docker exec fips-acl-container-a fipsctl acl show +``` + +Use explicit `.fips` names when checking reachability through the FIPS overlay: + +```bash +docker exec fips-acl-container-a ping node-d.fips +docker exec fips-acl-container-a ping npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl.fips +``` + +The output shows both the original ACL file entries and the resolved +effective npub entries. + +Expected: + +- `node-a` sees `node-b`, `node-e`, and `node-f` +- `node-b` sees `node-a` +- `node-c` sees no peers +- `node-d` sees no peers +- `node-e` sees `node-a` +- `node-f` sees `node-a` + +Visible rejection logs: + +```bash +docker compose -f testing/acl-allowlist/docker-compose.yml logs -f service-a service-b service-c service-d service-e service-f +``` + +On startup, `node-c` and `node-d` immediately try their configured outbound +static connection to `node-a`. Their own ACLs permit that attempt, but the +insider ACL on `node-a` still rejects both peers. Because `node-a` also has +static peer stanzas for `node-c` and `node-d`, you may see both +`outbound_connect` and `inbound_handshake` rejection messages during startup. +The outsider-initiated path emits messages like: + +```text +Rejected peer by ACL ... context=inbound_handshake decision=denylist match +``` + +Those messages are now emitted at debug level. This harness enables +`RUST_LOG=info,fips::node=debug` so the ACL rejection details stay visible in +test logs, and operators can temporarily raise log level the same way when +diagnosing ACL issues locally. + +A later `ping6` from `node-c.fips` does not emit a new `inbound_handshake` message. +The ping uses the data-plane session path, and since no peer session to +`node-a.fips` was established, it just times out. + +Stop and clean up: + +```bash +docker compose -f testing/acl-allowlist/docker-compose.yml down +``` diff --git a/testing/acl-allowlist/docker-compose.yml b/testing/acl-allowlist/docker-compose.yml new file mode 100644 index 0000000..14ffc57 --- /dev/null +++ b/testing/acl-allowlist/docker-compose.yml @@ -0,0 +1,111 @@ +networks: + acl-net: + driver: bridge + ipam: + config: + - subnet: 172.31.0.0/24 + +x-fips-common: &fips-common + build: + context: ../docker + image: fips-test:latest + entrypoint: ["/usr/local/bin/entrypoint.sh"] + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + sysctls: + - net.ipv6.conf.all.disable_ipv6=0 + restart: "no" + environment: + - FIPS_TEST_MODE=default + - RUST_LOG=info,fips::node=debug + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + +services: + service-a: + <<: *fips-common + container_name: fips-acl-container-a + hostname: host-a + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-a/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-a/peers.allow:/etc/fips/peers.allow:ro + - ./generated-configs/node-a/peers.deny:/etc/fips/peers.deny:ro + - ./generated-configs/node-a/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-a/fips.key:/etc/fips/fips.key:ro + networks: + acl-net: + ipv4_address: 172.31.0.10 + + service-b: + <<: *fips-common + container_name: fips-acl-container-b + hostname: host-b + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-b/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-b/peers.allow:/etc/fips/peers.allow:ro + - ./generated-configs/node-b/peers.deny:/etc/fips/peers.deny:ro + - ./generated-configs/node-b/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-b/fips.key:/etc/fips/fips.key:ro + networks: + acl-net: + ipv4_address: 172.31.0.11 + + service-c: + <<: *fips-common + container_name: fips-acl-container-c + hostname: host-c + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-c/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-c/peers.allow:/etc/fips/peers.allow:ro + - ./generated-configs/node-c/peers.deny:/etc/fips/peers.deny:ro + - ./generated-configs/node-c/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-c/fips.key:/etc/fips/fips.key:ro + networks: + acl-net: + ipv4_address: 172.31.0.12 + + service-d: + <<: *fips-common + container_name: fips-acl-container-d + hostname: host-d + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-d/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-d/peers.allow:/etc/fips/peers.allow:ro + - ./generated-configs/node-d/peers.deny:/etc/fips/peers.deny:ro + - ./generated-configs/node-d/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-d/fips.key:/etc/fips/fips.key:ro + networks: + acl-net: + ipv4_address: 172.31.0.13 + + service-e: + <<: *fips-common + container_name: fips-acl-container-e + hostname: host-e + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-e/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-e/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-e/fips.key:/etc/fips/fips.key:ro + networks: + acl-net: + ipv4_address: 172.31.0.14 + + service-f: + <<: *fips-common + container_name: fips-acl-container-f + hostname: host-f + volumes: + - ../docker/resolv.conf:/etc/resolv.conf:ro + - ./generated-configs/node-f/hosts:/etc/fips/hosts:ro + - ./generated-configs/node-f/fips.yaml:/etc/fips/fips.yaml:ro + - ./generated-configs/node-f/fips.key:/etc/fips/fips.key:ro + networks: + acl-net: + ipv4_address: 172.31.0.15 diff --git a/testing/acl-allowlist/generate-configs.sh b/testing/acl-allowlist/generate-configs.sh new file mode 100755 index 0000000..3168b29 --- /dev/null +++ b/testing/acl-allowlist/generate-configs.sh @@ -0,0 +1,306 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +GENERATED_DIR="$SCRIPT_DIR/generated-configs" + +write_file() { + local path="$1" + mkdir -p "$(dirname "$path")" + cat > "$path" +} + +write_hosts_file() { + local node="$1" + write_file "$GENERATED_DIR/$node/hosts" <<'EOF' +node-a npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m +node-b npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le +node-c npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6 +node-d npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl +node-e npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 +node-f npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c +EOF +} + +echo "Generating ACL allowlist fixtures..." +rm -rf "$GENERATED_DIR" + +write_file "$GENERATED_DIR/node-a/fips.yaml" <<'EOF' +node: + identity: + persistent: true + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + udp: + bind_addr: "0.0.0.0:2121" + +peers: + - npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le" + alias: "node-b" + addresses: + - transport: udp + addr: "172.31.0.11:2121" + connect_policy: auto_connect + - npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6" + alias: "node-c" + addresses: + - transport: udp + addr: "172.31.0.12:2121" + connect_policy: auto_connect + - npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl" + alias: "node-d" + addresses: + - transport: udp + addr: "172.31.0.13:2121" + connect_policy: auto_connect + - npub: "npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6" + alias: "node-e" + addresses: + - transport: udp + addr: "172.31.0.14:2121" + connect_policy: auto_connect + - npub: "npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c" + alias: "node-f" + addresses: + - transport: udp + addr: "172.31.0.15:2121" + connect_policy: auto_connect +EOF + +write_file "$GENERATED_DIR/node-a/fips.key" <<'EOF' +0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20 +EOF + +write_file "$GENERATED_DIR/node-a/peers.allow" <<'EOF' +node-a +node-b +node-e +node-f +EOF + +write_file "$GENERATED_DIR/node-a/peers.deny" <<'EOF' +ALL +EOF + +write_file "$GENERATED_DIR/node-b/fips.yaml" <<'EOF' +node: + identity: + persistent: true + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + udp: + bind_addr: "0.0.0.0:2121" + +peers: + - npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" + alias: "node-a" + addresses: + - transport: udp + addr: "172.31.0.10:2121" + connect_policy: auto_connect + - npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6" + alias: "node-c" + addresses: + - transport: udp + addr: "172.31.0.12:2121" + connect_policy: auto_connect + - npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl" + alias: "node-d" + addresses: + - transport: udp + addr: "172.31.0.13:2121" + connect_policy: auto_connect +EOF + +write_file "$GENERATED_DIR/node-b/fips.key" <<'EOF' +b102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fb0 +EOF + +write_file "$GENERATED_DIR/node-b/peers.allow" <<'EOF' +node-a +node-b +node-e +node-f +EOF + +write_file "$GENERATED_DIR/node-b/peers.deny" <<'EOF' +ALL +EOF + +write_file "$GENERATED_DIR/node-c/fips.yaml" <<'EOF' +node: + identity: + persistent: true + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + udp: + bind_addr: "0.0.0.0:2121" + +peers: + - npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" + alias: "node-a" + addresses: + - transport: udp + addr: "172.31.0.10:2121" + connect_policy: auto_connect +EOF + +write_file "$GENERATED_DIR/node-c/fips.key" <<'EOF' +c102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fc0 +EOF + +write_file "$GENERATED_DIR/node-c/peers.allow" <<'EOF' +node-a +node-b +node-c +node-d +node-e +node-f +EOF + +write_file "$GENERATED_DIR/node-c/peers.deny" <<'EOF' +# Intentionally empty. +EOF + +write_file "$GENERATED_DIR/node-d/fips.yaml" <<'EOF' +node: + identity: + persistent: true + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + udp: + bind_addr: "0.0.0.0:2121" + +peers: + - npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" + alias: "node-a" + addresses: + - transport: udp + addr: "172.31.0.10:2121" + connect_policy: auto_connect +EOF + +write_file "$GENERATED_DIR/node-d/fips.key" <<'EOF' +d102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1fd0 +EOF + +write_file "$GENERATED_DIR/node-d/peers.allow" <<'EOF' +node-a +node-b +node-c +node-d +node-e +node-f +EOF + +write_file "$GENERATED_DIR/node-d/peers.deny" <<'EOF' +# Intentionally empty. +EOF + +write_file "$GENERATED_DIR/node-e/fips.yaml" <<'EOF' +node: + identity: + persistent: true + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + udp: + bind_addr: "0.0.0.0:2121" + +peers: + - npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" + alias: "node-a" + addresses: + - transport: udp + addr: "172.31.0.10:2121" + connect_policy: auto_connect +EOF + +write_file "$GENERATED_DIR/node-e/fips.key" <<'EOF' +nsec1egyrmekfw3u4l88v8zhrak9uht503s2kvn9v49tqgp6c5l2yuxgsv386l0 +EOF + +write_file "$GENERATED_DIR/node-f/fips.yaml" <<'EOF' +node: + identity: + persistent: true + +tun: + enabled: true + name: fips0 + mtu: 1280 + +dns: + enabled: true + bind_addr: "127.0.0.1" + +transports: + udp: + bind_addr: "0.0.0.0:2121" + +peers: + - npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" + alias: "node-a" + addresses: + - transport: udp + addr: "172.31.0.10:2121" + connect_policy: auto_connect +EOF + +write_file "$GENERATED_DIR/node-f/fips.key" <<'EOF' +nsec1afh3nysthqh47awpdewcw59wvvp499f8dvlyclmnv4gvpxdk56dsa6eqsn +EOF + +write_hosts_file node-a +write_hosts_file node-b +write_hosts_file node-c +write_hosts_file node-d +write_hosts_file node-e +write_hosts_file node-f + +echo "ACL allowlist fixtures written to $GENERATED_DIR" diff --git a/testing/acl-allowlist/test.sh b/testing/acl-allowlist/test.sh new file mode 100755 index 0000000..2bf8565 --- /dev/null +++ b/testing/acl-allowlist/test.sh @@ -0,0 +1,150 @@ +#!/bin/bash +# Integration test for the ACL allowlist harness. +# +# Usage: ./test.sh [--skip-build] [--keep-up] + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TESTING_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml" +GENERATE_CONFIGS="$SCRIPT_DIR/generate-configs.sh" + +SKIP_BUILD=false +KEEP_UP=false + +while [ $# -gt 0 ]; do + case "$1" in + --skip-build) SKIP_BUILD=true; shift ;; + --keep-up) KEEP_UP=true; shift ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +cleanup() { + if [ "$KEEP_UP" = false ]; then + docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true + fi +} + +trap cleanup EXIT + +log() { + echo "=== $*" +} + +peer_npubs() { + local container="$1" + docker exec "$container" fipsctl show peers \ + | python3 -c 'import json,sys; data=json.load(sys.stdin); print(" ".join(sorted(p["npub"] for p in data.get("peers", []) if p.get("connectivity") == "connected")))' +} + +acl_field() { + local container="$1" + local field="$2" + docker exec "$container" fipsctl acl show \ + | python3 -c 'import json,sys; data=json.load(sys.stdin); field=sys.argv[1]; value=data.get(field); print(" ".join(sorted(value)) if isinstance(value, list) else ("" if value is None else value))' "$field" +} + +assert_peer_set() { + local container="$1" + local expected="$2" + local actual + actual="$(peer_npubs "$container")" + if [ "$actual" != "$expected" ]; then + echo "FAIL: $container peers mismatch" >&2 + echo " expected: $expected" >&2 + echo " actual: $actual" >&2 + exit 1 + fi + echo "PASS: $container peers match expected set" +} + +assert_acl_field() { + local container="$1" + local field="$2" + local expected="$3" + local actual + actual="$(acl_field "$container" "$field")" + if [ "$actual" != "$expected" ]; then + echo "FAIL: $container ACL field $field mismatch" >&2 + echo " expected: $expected" >&2 + echo " actual: $actual" >&2 + exit 1 + fi + echo "PASS: $container ACL field $field matches expected value" +} + +wait_for_peers_exact() { + local container="$1" + local expected_count="$2" + local timeout="${3:-30}" + + for _ in $(seq 1 "$timeout"); do + local count + count=$(docker exec "$container" fipsctl show peers 2>/dev/null \ + | python3 -c 'import json,sys; data=json.load(sys.stdin); print(sum(1 for p in data.get("peers", []) if p.get("connectivity") == "connected"))' 2>/dev/null || echo 0) + if [ "$count" -eq "$expected_count" ]; then + return 0 + fi + sleep 1 + done + + echo "FAIL: $container did not reach $expected_count connected peers in ${timeout}s" >&2 + docker exec "$container" fipsctl show peers >&2 || true + exit 1 +} + +assert_log_contains() { + local container="$1" + local pattern="$2" + local logs + logs="$(docker logs "$container" 2>&1 | python3 -c 'import re,sys; print(re.sub(r"\x1b\[[0-9;]*m", "", sys.stdin.read()), end="")' || true)" + if ! printf '%s' "$logs" | grep -F "$pattern" >/dev/null; then + echo "FAIL: missing log pattern in $container: $pattern" >&2 + exit 1 + fi + echo "PASS: $container logs contain expected ACL rejection" +} + +if [ "$SKIP_BUILD" = false ]; then + log "Building Linux test binaries" + "$TESTING_DIR/scripts/build.sh" --no-docker +fi + +log "Generating ACL allowlist fixtures" +"$GENERATE_CONFIGS" + +log "Starting ACL allowlist harness" +docker compose -f "$COMPOSE_FILE" down >/dev/null 2>&1 || true +docker compose -f "$COMPOSE_FILE" up -d --build + +log "Waiting for expected peer convergence" +wait_for_peers_exact fips-acl-container-a 3 40 +wait_for_peers_exact fips-acl-container-b 1 40 +wait_for_peers_exact fips-acl-container-c 0 5 +wait_for_peers_exact fips-acl-container-d 0 5 +wait_for_peers_exact fips-acl-container-e 1 40 +wait_for_peers_exact fips-acl-container-f 1 40 + +log "Verifying peer sets" +assert_peer_set fips-acl-container-a "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c" +assert_peer_set fips-acl-container-b "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" +assert_peer_set fips-acl-container-c "" +assert_peer_set fips-acl-container-d "" +assert_peer_set fips-acl-container-e "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" +assert_peer_set fips-acl-container-f "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" + +log "Checking alias-based ACL resolution" +assert_acl_field fips-acl-container-a allow_file_entries "node-a node-b node-e node-f" +assert_acl_field fips-acl-container-a allow_entries "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c" +assert_acl_field fips-acl-container-c allow_file_entries "node-a node-b node-c node-d node-e node-f" +assert_acl_field fips-acl-container-c allow_entries "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6 npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le npub1x5z9rwzzm26q9verutx4aajhf2zw2pyp34c6whhde2zduxqav40qgq36l6 npub1ytrut7gjncn2zfnhn56c0zgftf0w6p99gf6fu8j73hzw5603zglqc9av6c" + +log "Checking ACL rejection logs" +assert_log_contains fips-acl-container-a "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6" +assert_log_contains fips-acl-container-a "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl" +assert_log_contains fips-acl-container-a "context=inbound_handshake" +assert_log_contains fips-acl-container-a "decision=denylist match" + +log "ACL allowlist integration test passed"