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 <johnathan@corganlabs.com>
This commit is contained in:
Alex Xie
2026-04-16 05:13:07 +00:00
committed by Johnathan Corgan
parent 5cdcff7386
commit 745b523ac6
19 changed files with 2038 additions and 29 deletions

View File

@@ -258,6 +258,8 @@ jobs:
- suite: rekey - suite: rekey
type: rekey type: rekey
topology: rekey topology: rekey
- suite: acl-allowlist
type: acl-allowlist
# ── Chaos / stochastic scenarios ─────────────────────────────────── # ── Chaos / stochastic scenarios ───────────────────────────────────
- suite: chaos-smoke-10 - suite: chaos-smoke-10
type: chaos type: chaos
@@ -379,6 +381,21 @@ jobs:
docker compose -f testing/static/docker-compose.yml \ docker compose -f testing/static/docker-compose.yml \
--profile rekey down --volumes --remove-orphans --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 ─────────────────────────────────────────────────── # ── Chaos simulation ───────────────────────────────────────────────────
- name: Install Python deps (chaos) - name: Install Python deps (chaos)
if: matrix.type == 'chaos' if: matrix.type == 'chaos'

View File

@@ -40,6 +40,11 @@ enum Commands {
#[command(subcommand)] #[command(subcommand)]
what: ShowCommands, what: ShowCommands,
}, },
/// Show peer ACL information
Acl {
#[command(subcommand)]
what: AclCommands,
},
/// Generate a new FIPS identity keypair /// Generate a new FIPS identity keypair
Keygen { Keygen {
/// Output directory for fips.key and fips.pub /// Output directory for fips.key and fips.pub
@@ -128,6 +133,12 @@ enum ShowCommands {
IdentityCache, IdentityCache,
} }
#[derive(Subcommand, Debug)]
enum AclCommands {
/// Loaded peer ACL state
Show,
}
impl ShowCommands { impl ShowCommands {
fn command_name(&self) -> &'static str { fn command_name(&self) -> &'static str {
match self { 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 { fn default_socket_path() -> PathBuf {
fips::config::default_control_path() fips::config::default_control_path()
} }
@@ -403,6 +422,7 @@ fn main() {
let request = match &cli.command { let request = match &cli.command {
Commands::Show { what } => build_query(what.command_name()), Commands::Show { what } => build_query(what.command_name()),
Commands::Acl { what } => build_query(what.command_name()),
Commands::Connect { Commands::Connect {
peer, peer,
address, address,
@@ -584,6 +604,23 @@ fn sparkline(values: &[f64], min: f64, max: f64) -> String {
mod tests { mod tests {
use super::*; 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] #[test]
fn detects_bare_ula_literal() { fn detects_bare_ula_literal() {
assert!(is_fips_mesh_address("fd9d:abcd::1")); assert!(is_fips_mesh_address("fd9d:abcd::1"));

View File

@@ -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. /// `show_peers` — Authenticated peers.
pub fn show_peers(node: &Node) -> Value { pub fn show_peers(node: &Node) -> Value {
let tree = node.tree_state(); 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. /// Dispatch a command string to the appropriate query function.
pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response { pub fn dispatch(node: &Node, command: &str, params: Option<&Value>) -> super::protocol::Response {
match command { match command {
"show_acl" => super::protocol::Response::ok(show_acl(node)),
"show_status" => super::protocol::Response::ok(show_status(node)), "show_status" => super::protocol::Response::ok(show_status(node)),
"show_peers" => super::protocol::Response::ok(show_peers(node)), "show_peers" => super::protocol::Response::ok(show_peers(node)),
"show_links" => super::protocol::Response::ok(show_links(node)), "show_links" => super::protocol::Response::ok(show_links(node)),

954
src/node/acl.rs Normal file
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
);
}
}

View File

@@ -1,6 +1,7 @@
//! Handshake handlers and connection promotion. //! Handshake handlers and connection promotion.
use crate::PeerIdentity; use crate::PeerIdentity;
use crate::node::acl::PeerAclContext;
use crate::node::wire::{Msg1Header, Msg2Header, build_msg2}; use crate::node::wire::{Msg1Header, Msg2Header, build_msg2};
use crate::node::{Node, NodeError}; use crate::node::{Node, NodeError};
use crate::peer::{ActivePeer, PeerConnection, PromotionResult, cross_connection_winner}; 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 // If possible_restart was true but peer is no longer in self.peers
// (removed by another path), fall through to process as new connection. // (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. // Note: we don't early-return if peer is already in self.peers here.
// promote_connection handles cross-connection resolution via tie-breaker. // promote_connection handles cross-connection resolution via tie-breaker.
@@ -608,33 +622,59 @@ impl Node {
return; 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..];
let noise_msg2 = &packet.data[header.noise_msg2_offset..]; if let Err(e) = conn.complete_handshake(noise_msg2, packet.timestamp_ms) {
if let Err(e) = conn.complete_handshake(noise_msg2, packet.timestamp_ms) { warn!(
warn!( link_id = %link_id,
link_id = %link_id, error = %e,
error = %e, "Handshake completion failed"
"Handshake completion failed" );
); conn.mark_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");
return; 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(); let peer_node_addr = *peer_identity.node_addr();
debug!( debug!(

View File

@@ -116,6 +116,7 @@ impl Node {
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64) .map(|d| d.as_millis() as u64)
.unwrap_or(0); .unwrap_or(0);
self.reload_peer_acl();
self.poll_pending_connects().await; self.poll_pending_connects().await;
self.resend_pending_handshakes(now_ms).await; self.resend_pending_handshakes(now_ms).await;
self.resend_pending_rekeys(now_ms).await; self.resend_pending_rekeys(now_ms).await;

View File

@@ -1,6 +1,7 @@
//! Node lifecycle management: start, stop, and peer connection initiation. //! Node lifecycle management: start, stop, and peer connection initiation.
use super::{Node, NodeError, NodeState}; use super::{Node, NodeError, NodeState};
use crate::node::acl::PeerAclContext;
use crate::node::wire::build_msg1; use crate::node::wire::build_msg1;
use crate::peer::PeerConnection; use crate::peer::PeerConnection;
use crate::protocol::{Disconnect, DisconnectReason}; use crate::protocol::{Disconnect, DisconnectReason};
@@ -168,6 +169,7 @@ impl Node {
.await .await
{ {
Ok(()) => return Ok(()), Ok(()) => return Ok(()),
Err(e @ NodeError::AccessDenied(_)) => return Err(e),
Err(e) => { Err(e) => {
debug!( debug!(
npub = %peer_config.npub, npub = %peer_config.npub,
@@ -205,6 +207,13 @@ impl Node {
) -> Result<(), NodeError> { ) -> Result<(), NodeError> {
let peer_node_addr = *peer_identity.node_addr(); let peer_node_addr = *peer_identity.node_addr();
self.authorize_peer(
&peer_identity,
PeerAclContext::OutboundConnect,
transport_id,
&remote_addr,
)?;
let is_connection_oriented = self let is_connection_oriented = self
.transports .transports
.get(&transport_id) .get(&transport_id)

View File

@@ -4,6 +4,7 @@
//! holds all state required for mesh routing: identity, tree state, //! holds all state required for mesh routing: identity, tree state,
//! Bloom filters, coordinate caches, transports, links, and peers. //! Bloom filters, coordinate caches, transports, links, and peers.
mod acl;
mod bloom; mod bloom;
mod discovery_rate_limit; mod discovery_rate_limit;
mod handlers; mod handlers;
@@ -88,6 +89,9 @@ pub enum NodeError {
#[error("invalid peer npub '{npub}': {reason}")] #[error("invalid peer npub '{npub}': {reason}")]
InvalidPeerNpub { npub: String, reason: String }, InvalidPeerNpub { npub: String, reason: String },
#[error("access denied: {0}")]
AccessDenied(String),
#[error("max connections exceeded: {max}")] #[error("max connections exceeded: {max}")]
MaxConnectionsExceeded { max: usize }, MaxConnectionsExceeded { max: usize },
@@ -443,6 +447,9 @@ pub struct Node {
/// Populated at startup from peer config. /// Populated at startup from peer config.
peer_aliases: HashMap<NodeAddr, String>, peer_aliases: HashMap<NodeAddr, String>,
/// Reloadable peer ACL state from standard allow/deny files.
peer_acl: acl::PeerAclReloader,
// === Host Map === // === Host Map ===
/// Static hostname → npub mapping for DNS resolution. /// Static hostname → npub mapping for DNS resolution.
/// Built at construction from peer aliases and /etc/fips/hosts. /// 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 backoff_max_secs = config.node.discovery.backoff_max_secs;
let forward_min_interval_secs = config.node.discovery.forward_min_interval_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( let hosts_file = HostMap::load_hosts_file(std::path::Path::new(
crate::upper::hosts::DEFAULT_HOSTS_PATH, crate::upper::hosts::DEFAULT_HOSTS_PATH,
)); ));
host_map.merge(hosts_file); host_map.merge(hosts_file);
let host_map = Arc::new(host_map); 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 { Ok(Self {
identity, identity,
@@ -570,6 +585,7 @@ impl Node {
estimated_mesh_size: None, estimated_mesh_size: None,
last_mesh_size_log: None, last_mesh_size_log: None,
peer_aliases: HashMap::new(), peer_aliases: HashMap::new(),
peer_acl,
host_map, host_map,
}) })
} }
@@ -618,7 +634,18 @@ impl Node {
let max_links = config.node.limits.max_links; let max_links = config.node.limits.max_links;
let coords_response_interval_ms = config.node.session.coords_response_interval_ms; 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 { Self {
identity, identity,
@@ -678,6 +705,7 @@ impl Node {
estimated_mesh_size: None, estimated_mesh_size: None,
last_mesh_size_log: None, last_mesh_size_log: None,
peer_aliases: HashMap::new(), peer_aliases: HashMap::new(),
peer_acl,
host_map, host_map,
} }
} }

152
src/node/tests/acl.rs Normal file
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(_))));
}

View File

@@ -439,6 +439,8 @@ async fn test_bloom_filter_split_horizon() {
/// 100-node random graph: bloom filter exchange at scale. /// 100-node random graph: bloom filter exchange at scale.
#[tokio::test] #[tokio::test]
async fn test_bloom_filter_convergence_100_nodes() { async fn test_bloom_filter_convergence_100_nodes() {
let _guard = lock_large_network_test().await;
const NUM_NODES: usize = 100; const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250; const TARGET_EDGES: usize = 250;
const SEED: u64 = 42; const SEED: u64 = 42;

View File

@@ -9,8 +9,8 @@ use crate::node::RecentRequest;
use crate::protocol::{LookupRequest, LookupResponse}; use crate::protocol::{LookupRequest, LookupResponse};
use crate::tree::TreeCoordinate; use crate::tree::TreeCoordinate;
use spanning_tree::{ use spanning_tree::{
cleanup_nodes, generate_random_edges, process_available_packets, run_tree_test, cleanup_nodes, generate_random_edges, lock_large_network_test, process_available_packets,
verify_tree_convergence, run_tree_test, verify_tree_convergence,
}; };
// ============================================================================ // ============================================================================
@@ -521,6 +521,8 @@ async fn test_request_dedup_convergent_paths() {
#[tokio::test] #[tokio::test]
#[ignore] // Long-running (~2 min): run explicitly with --ignored #[ignore] // Long-running (~2 min): run explicitly with --ignored
async fn test_discovery_100_nodes() { 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). // 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, // Each node initiates lookups to a sample of other nodes in batches,
// processing packets between batches to avoid flooding the network. // processing packets between batches to avoid flooding the network.

View File

@@ -8,7 +8,7 @@ use crate::bloom::BloomFilter;
use crate::tree::{ParentDeclaration, TreeCoordinate}; use crate::tree::{ParentDeclaration, TreeCoordinate};
use spanning_tree::{ use spanning_tree::{
TestNode, cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake, 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; use std::collections::HashSet;
@@ -579,6 +579,8 @@ fn simulate_forwarding(
/// without loops. /// without loops.
#[tokio::test] #[tokio::test]
async fn test_routing_reachability_100_nodes() { async fn test_routing_reachability_100_nodes() {
let _guard = lock_large_network_test().await;
const NUM_NODES: usize = 100; const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250; const TARGET_EDGES: usize = 250;
const SEED: u64 = 42; 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. /// non-adjacent nodes. Direct peer adjacency handles the last hop.
#[tokio::test] #[tokio::test]
async fn test_routing_source_only_coords_100_nodes() { async fn test_routing_source_only_coords_100_nodes() {
let _guard = lock_large_network_test().await;
const NUM_NODES: usize = 100; const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250; const TARGET_EDGES: usize = 250;
const SEED: u64 = 42; const SEED: u64 = 42;

View File

@@ -3,8 +3,8 @@
use super::*; use super::*;
use crate::node::session::EndToEndState; use crate::node::session::EndToEndState;
use crate::node::tests::spanning_tree::{ use crate::node::tests::spanning_tree::{
TestNode, cleanup_nodes, generate_random_edges, process_available_packets, run_tree_test, TestNode, cleanup_nodes, generate_random_edges, lock_large_network_test,
run_tree_test_with_mtus, verify_tree_convergence, process_available_packets, run_tree_test, run_tree_test_with_mtus, verify_tree_convergence,
}; };
use crate::protocol::{SessionAck, SessionDatagram}; use crate::protocol::{SessionAck, SessionDatagram};
@@ -510,6 +510,8 @@ async fn drain_to_quiescence(nodes: &mut [TestNode]) {
#[tokio::test] #[tokio::test]
async fn test_session_100_nodes() { async fn test_session_100_nodes() {
let _guard = lock_large_network_test().await;
use rand::rngs::StdRng; use rand::rngs::StdRng;
use rand::{RngExt, SeedableRng}; use rand::{RngExt, SeedableRng};
use std::sync::mpsc; use std::sync::mpsc;

View File

@@ -8,6 +8,13 @@ use super::*;
use crate::protocol::TreeAnnounce; use crate::protocol::TreeAnnounce;
use crate::tree::{CoordEntry, ParentDeclaration, TreeCoordinate}; 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. /// A test node bundling a Node with its transport and packet channel.
pub(super) struct TestNode { pub(super) struct TestNode {
pub(super) node: Node, pub(super) node: Node,
@@ -667,6 +674,8 @@ pub(super) async fn cleanup_nodes(nodes: &mut [TestNode]) {
/// consistent spanning tree with the correct root. /// consistent spanning tree with the correct root.
#[tokio::test] #[tokio::test]
async fn test_spanning_tree_convergence_100_nodes() { async fn test_spanning_tree_convergence_100_nodes() {
let _guard = lock_large_network_test().await;
const NUM_NODES: usize = 100; const NUM_NODES: usize = 100;
const TARGET_EDGES: usize = 250; const TARGET_EDGES: usize = 250;
const SEED: u64 = 42; const SEED: u64 = 42;

1
testing/acl-allowlist/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
generated-configs

View File

@@ -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
```

View File

@@ -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

View File

@@ -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"

150
testing/acl-allowlist/test.sh Executable file
View File

@@ -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"