mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-22 07:48:26 +00:00
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:
committed by
Johnathan Corgan
parent
5cdcff7386
commit
745b523ac6
17
.github/workflows/ci.yml
vendored
17
.github/workflows/ci.yml
vendored
@@ -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'
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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)),
|
||||
|
||||
954
src/node/acl.rs
Normal file
954
src/node/acl.rs
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,9 +622,9 @@ impl Node {
|
||||
return;
|
||||
}
|
||||
|
||||
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!(
|
||||
@@ -622,11 +636,9 @@ impl Node {
|
||||
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 => {
|
||||
@@ -635,6 +647,34 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
(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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<NodeAddr, String>,
|
||||
|
||||
/// Reloadable peer ACL state from standard allow/deny files.
|
||||
peer_acl: acl::PeerAclReloader,
|
||||
|
||||
// === Host Map ===
|
||||
/// Static hostname → npub mapping for DNS resolution.
|
||||
/// Built at construction from peer aliases and /etc/fips/hosts.
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
152
src/node/tests/acl.rs
Normal file
152
src/node/tests/acl.rs
Normal 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(_))));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,13 @@ use super::*;
|
||||
use crate::protocol::TreeAnnounce;
|
||||
use crate::tree::{CoordEntry, ParentDeclaration, TreeCoordinate};
|
||||
|
||||
static LARGE_NETWORK_TEST_LOCK: std::sync::LazyLock<tokio::sync::Mutex<()>> =
|
||||
std::sync::LazyLock::new(|| tokio::sync::Mutex::new(()));
|
||||
|
||||
pub(super) async fn lock_large_network_test() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
LARGE_NETWORK_TEST_LOCK.lock().await
|
||||
}
|
||||
|
||||
/// A test node bundling a Node with its transport and packet channel.
|
||||
pub(super) struct TestNode {
|
||||
pub(super) node: Node,
|
||||
@@ -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;
|
||||
|
||||
1
testing/acl-allowlist/.gitignore
vendored
Normal file
1
testing/acl-allowlist/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
generated-configs
|
||||
164
testing/acl-allowlist/README.md
Normal file
164
testing/acl-allowlist/README.md
Normal 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
|
||||
```
|
||||
111
testing/acl-allowlist/docker-compose.yml
Normal file
111
testing/acl-allowlist/docker-compose.yml
Normal 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
|
||||
306
testing/acl-allowlist/generate-configs.sh
Executable file
306
testing/acl-allowlist/generate-configs.sh
Executable 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
150
testing/acl-allowlist/test.sh
Executable 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"
|
||||
Reference in New Issue
Block a user