mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 00:26:59 +00:00
The macOS packaging installs config under /usr/local/etc/fips, wired through the launchd plist and build-pkg.sh, but the default-path constants and the config search path were hardcoded to /etc/fips for all Unix. On macOS the daemon and fipsctl therefore looked in a directory that does not exist: the ACL and host-map loaders hit their NotFound no-op arm and returned empty state, so a populated peers.deny reported effective_mode "default_open" with enforcement inactive, and host-file aliases went unloaded, with no error. The peers.allow, peers.deny and hosts defaults now follow the platform's packaging, and fipsctl keygen writes its identity there too. The config search path keeps probing /etc/fips first and adds /usr/local/etc/fips after it, so an existing install keeps working across the upgrade and the packaged file still wins over a stale leftover. Both the macOS search-path entry and the keygen output directory read one SYSTEM_CONFIG_DIR constant, so they cannot drift apart. At startup the daemon warns once about hosts, peers.allow or peers.deny stranded at the old location; the config file is deliberately excluded, since both directories stay on the search path and a config left behind is still read. The control-socket snapshot tests repoint the ACL reloader at non-existent paths under the temp dir, so the snapshot no longer reflects whatever ACL files happen to exist on the machine running the tests. Platform-gated unit tests pin both layouts, so a future refactor cannot silently drift either one. Linux and Windows behavior is unchanged. Adding a second system config directory moves the directory the daemon derives the identity key path from, since that comes from whichever config file loaded last. A host carrying fips.yaml at both locations would have resolved fips.key to the new directory, found none, and under persistent generated a fresh identity, silently changing its npub, routing address and mesh IPv6 with no migration path. The daemon now adopts a key stranded at the legacy path and warns to move it rather than generating one. The fallback is confined to keys resolved from the system config directory, so a run using ./fips.yaml or a user config is never redirected to a system key. Co-authored-by: Johnathan Corgan <johnathan@corganlabs.com>
1046 lines
35 KiB
Rust
1046 lines
35 KiB
Rust
//! 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::reloadable::Reloadable;
|
|
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::sync::Arc;
|
|
use std::time::SystemTime;
|
|
use tracing::{debug, info, warn};
|
|
|
|
/// Default path for the peer allow list.
|
|
///
|
|
/// On macOS the install layout (see `packaging/macos/`) ships config under
|
|
/// `/usr/local/etc/fips/` rather than `/etc/fips/`; the default follows the
|
|
/// platform's packaging so the daemon reads the file the operator was told
|
|
/// to edit. Linux and other Unix keep the historic `/etc/fips/` location.
|
|
#[cfg(target_os = "macos")]
|
|
pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/usr/local/etc/fips/peers.allow";
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub const DEFAULT_PEERS_ALLOW_PATH: &str = "/etc/fips/peers.allow";
|
|
|
|
/// Default path for the peer deny list.
|
|
///
|
|
/// See [`DEFAULT_PEERS_ALLOW_PATH`] for the macOS `/usr/local/etc/fips/`
|
|
/// rationale.
|
|
#[cfg(target_os = "macos")]
|
|
pub const DEFAULT_PEERS_DENY_PATH: &str = "/usr/local/etc/fips/peers.deny";
|
|
#[cfg(not(target_os = "macos"))]
|
|
pub const DEFAULT_PEERS_DENY_PATH: &str = "/etc/fips/peers.deny";
|
|
|
|
/// Warn about config files stranded at the pre-move default location.
|
|
///
|
|
/// The macOS defaults for `hosts`, `peers.allow` and `peers.deny` moved
|
|
/// from `/etc/fips` to `/usr/local/etc/fips`, the directory the macOS
|
|
/// packaging actually populates. The old location is no longer read by
|
|
/// the default path constants, and a `peers.deny` silently left behind
|
|
/// there would fail open (a missing deny list is not an error), so surface
|
|
/// the situation loudly once at startup.
|
|
#[cfg(target_os = "macos")]
|
|
pub fn warn_on_legacy_config_paths() {
|
|
for (current, name) in [
|
|
(crate::upper::hosts::DEFAULT_HOSTS_PATH, "hosts"),
|
|
(DEFAULT_PEERS_ALLOW_PATH, "peers.allow"),
|
|
(DEFAULT_PEERS_DENY_PATH, "peers.deny"),
|
|
] {
|
|
let legacy = format!("/etc/fips/{name}");
|
|
if std::path::Path::new(&legacy).exists() && !std::path::Path::new(current).exists() {
|
|
warn!(
|
|
legacy = %legacy,
|
|
current = %current,
|
|
"Config file found at legacy path but not at the current default; \
|
|
it is no longer read — move it to the current path"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// Follows the canonical Arc-wrapper template from [`Reloadable`]: the
|
|
/// reader-facing [`PeerAcl`] snapshot is published through an
|
|
/// [`arc_swap::ArcSwap`] so the authorization hot path reads it without
|
|
/// locking, while the reloader's change-detection state (file mtimes, the
|
|
/// embedded hosts reloader) is touched only by [`Reloadable::reload`] on the
|
|
/// single node tick task.
|
|
pub struct PeerAclReloader {
|
|
/// Reader-facing effective ACL snapshot.
|
|
acl: arc_swap::ArcSwap<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: arc_swap::ArcSwap::from(Arc::new(acl)),
|
|
hosts,
|
|
allow_path,
|
|
deny_path,
|
|
last_allow_mtime,
|
|
last_deny_mtime,
|
|
}
|
|
}
|
|
|
|
/// Acquire a lock-free guard over the current ACL snapshot.
|
|
pub fn acl(&self) -> arc_swap::Guard<Arc<PeerAcl>> {
|
|
self.load()
|
|
}
|
|
|
|
/// Return a human-readable snapshot of the loaded ACL state.
|
|
pub fn status(&self) -> PeerAclStatus {
|
|
let acl = self.acl.load();
|
|
PeerAclStatus {
|
|
allow_file: self.allow_path.display().to_string(),
|
|
deny_file: self.deny_path.display().to_string(),
|
|
enforcement_active: !acl.is_empty(),
|
|
effective_mode: acl.effective_mode().to_string(),
|
|
default_decision: acl.default_decision().to_string(),
|
|
allow_all: acl.allow_all,
|
|
deny_all: acl.deny_all,
|
|
allow_file_entries: acl.allow_file_entries(),
|
|
deny_file_entries: acl.deny_file_entries(),
|
|
allow_entries: acl.allow_entries(),
|
|
deny_entries: acl.deny_entries(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Reloadable for PeerAclReloader {
|
|
type Snapshot = PeerAcl;
|
|
|
|
async fn 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;
|
|
let new_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 = new_acl.allow.len(),
|
|
deny_entries = new_acl.deny.len(),
|
|
alias_entries = self.hosts.hosts().len(),
|
|
allow_all = new_acl.allow_all,
|
|
deny_all = new_acl.deny_all,
|
|
"Reloaded peer ACL files"
|
|
);
|
|
self.acl.store(Arc::new(new_acl));
|
|
true
|
|
}
|
|
|
|
fn load(&self) -> arc_swap::Guard<Arc<PeerAcl>> {
|
|
self.acl.load()
|
|
}
|
|
}
|
|
|
|
impl Node {
|
|
/// Reload the peer ACL if the ACL or hosts files changed.
|
|
pub(crate) async fn reload_peer_acl(&mut self) -> bool {
|
|
self.peer_acl.reload().await
|
|
}
|
|
|
|
/// 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
|
|
)))
|
|
}
|
|
|
|
/// Test-only: replace the peer-ACL reloader with one that reads from
|
|
/// the given paths, isolating the node from the host's real
|
|
/// `peers.allow` / `peers.deny` files. Used by snapshot tests
|
|
/// that must be deterministic regardless of whether an operator has
|
|
/// edited the system ACL files on the dev/CI machine.
|
|
#[cfg(test)]
|
|
pub(crate) fn isolate_peer_acl_for_test(&mut self, allow: PathBuf, deny: PathBuf) {
|
|
self.peer_acl = PeerAclReloader::with_paths(allow, deny);
|
|
}
|
|
}
|
|
|
|
#[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
|
|
}
|
|
|
|
// Guard against the macOS path regression: the install layout
|
|
// (`packaging/macos/`) ships config under `/usr/local/etc/fips/`, so the
|
|
// default ACL paths must follow it, or `peers.allow`/`peers.deny` are
|
|
// silently unread on macOS (see the `NotFound` no-op in `load_file`).
|
|
#[cfg(target_os = "macos")]
|
|
#[test]
|
|
fn test_default_acl_paths_follow_macos_packaging_layout() {
|
|
assert_eq!(DEFAULT_PEERS_ALLOW_PATH, "/usr/local/etc/fips/peers.allow");
|
|
assert_eq!(DEFAULT_PEERS_DENY_PATH, "/usr/local/etc/fips/peers.deny");
|
|
}
|
|
|
|
// Non-macOS Unix/Linux keeps the historic `/etc/fips/` location; this
|
|
// runs on the Linux CI matrix and pins the value so a future refactor
|
|
// can't silently drift it.
|
|
#[cfg(all(unix, not(target_os = "macos")))]
|
|
#[test]
|
|
fn test_default_acl_paths_keep_etc_fips_layout() {
|
|
assert_eq!(DEFAULT_PEERS_ALLOW_PATH, "/etc/fips/peers.allow");
|
|
assert_eq!(DEFAULT_PEERS_DENY_PATH, "/etc/fips/peers.deny");
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async 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.reload().await);
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
std::fs::write(&deny, format!("{denied}\n")).unwrap();
|
|
|
|
assert!(reloader.reload().await);
|
|
assert_eq!(
|
|
reloader
|
|
.acl()
|
|
.check(&PeerIdentity::from_npub(&denied).unwrap()),
|
|
PeerAclDecision::DenyList
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async 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.reload().await);
|
|
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);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async 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.reload().await);
|
|
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
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async 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.reload().await);
|
|
assert!(reloader.acl().is_empty());
|
|
assert_eq!(
|
|
reloader.acl().check(&test_peer(&npub)),
|
|
PeerAclDecision::DefaultAllow
|
|
);
|
|
}
|
|
}
|