diff --git a/CHANGELOG.md b/CHANGELOG.md index 7fafb67..bd514eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,107 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(brahman-handshake+ente-zero): denylist + hot reload de la política de peers +Consolida `PeerAllowlist` + nueva `PeerDenylist` en un único +`PeerPolicy` con allow + deny + hot reload vía `notify`. Cubre los +dos pendientes documentados en el commit anterior y simplifica la +API hacia un sólo punto de entrada. + +API consolidada en `brahman_handshake::peer_policy`: +- `PeerPolicy::open()` — todo permitido (default). +- `PeerPolicy::from_sets(allow: Option>, deny: BTreeSet)` + — política inline para tests. +- `PeerPolicy::from_files(allow_path?, deny_path?)` — carga ambos + archivos opcionales. +- `PeerPolicy::evaluate(peer) -> Decision` — `Admit | + DeniedByDenylist | NotInAllowlist`. Decision lleva su `reason()` + para logging consistente. +- `PeerPolicy::reload()` — recarga atómica desde los paths + asociados. **Si un archivo falla, conserva la versión anterior** + (un typo no debe tirar al Init en modo inseguro). +- `PeerPolicy::spawn_watcher() -> JoinHandle` — vigila los + archivos vía `notify`, debounce 250ms (coalesce de los varios + eventos típicos de un save), recarga atómica al detectar cambio. + +Orden de evaluación (deny-first): +1. Si `peer ∈ denylist` → `DeniedByDenylist`. +2. Si hay allowlist y `peer ∉ allowlist` → `NotInAllowlist`. +3. Resto → `Admit`. + +Esto significa que **deny gana sobre allow**: un peer en ambas listas +es rechazado. Diseño explícito para que la denylist sea la primitiva +de "kill switch" — agregar un peer al deny lo banea inmediatamente +sin importar dónde más esté listado. + +Watcher: vigila el **directorio padre** del archivo, no el archivo +mismo. Razón: editores típicos hacen rename-and-replace (escriben +a tmp y rename al destino), lo que rompe el watch del archivo pero +no el del dir. Filtra eventos por path al procesar. + +Wire en server: +- `ServerConfig.allowlist` → `ServerConfig.policy: Option` + (breaking rename, scope local al monorepo). Gate en `do_handshake` + llama `policy.evaluate(&peer)` y usa `decision.reason()` para el + mensaje de error tipado. + +Wire en Arje (`ente-zero`): +- Nueva env `BRAHMAN_PEER_DENYLIST` complementa + `BRAHMAN_PEER_ALLOWLIST`. Cualquiera (o ambas) activa la política. +- `setup_brahman_policy()` carga + arranca watcher. Devuelve + `(policy, JoinHandle)`; el handle se guarda en main para que el + thread no se aborte. +- Failure modes degradan a "modo abierto" (sin política) con log, + preservando la doctrina PID 1. + +Activación end-to-end con todas las capas activas: +```sh +BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 \ +BRAHMAN_PEER_ALLOWLIST=/etc/brahman/allow.txt \ +BRAHMAN_PEER_DENYLIST=/etc/brahman/deny.txt \ +ente-zero +# El operador puede editar deny.txt en caliente y la nueva regla +# entra en efecto en ~250ms sin restart del Init. +``` + +Tests: 10 unit en `peer_policy::tests`: +- `open_admits_anyone`, `allow_only_admits_listed`, + `deny_overrides_open`, `deny_overrides_allow` (deny-first + semantics). +- `from_files_with_both_lists`, `from_files_only_deny`, + `invalid_file_rejected_at_load`. +- `reload_picks_up_changes` — manualmente recarga y verifica. +- `reload_failure_preserves_previous_state` — invariante de + seguridad: archivo roto NO baja la política activa. +- `watcher_reloads_on_file_change` — E2E del watcher con notify + real: muta archivo, espera < debounce + margen, verifica que + la política refleja el cambio sin haber llamado reload manualmente. + +Plus 1 E2E nuevo en `network_libp2p.rs`: +`libp2p_handshake_denylist_blocks_listed_peer` — A configura +`policy = PeerPolicy::from_sets(None, [banned_peer])`. Cliente +con keypair baneada es rechazado; cliente con keypair distinta +pasa el handshake. + +30 tests verdes en brahman-handshake (16 unit + 7 handshake + 3 +discovery + 4 libp2p incluyendo allowlist + denylist E2E). Sin +regresión en ente-zero. + +Lo que cierra esta entrega: +- Política completa de admisión: open / allow-only / deny-only / + allow+deny. +- Hot reload sin restart del Init — el operador puede banear/admitir + peers en caliente editando archivos. +- Atomicidad: la recarga es del paquete `(allow, deny)` completo, no + de cada lista por separado. No hay momento donde una lista esté + vieja y la otra nueva. +- Resiliencia: errores de parseo NO bajan la política activa. + +Pendientes futuros del changelog: +- Aplicar la política a nivel de swarm vía `libp2p_allow_block_list:: + Behaviour` (rechazar ANTES del Noise handshake, ahorrar el + round-trip TCP+Noise por intento denegado). +- Rotación de keypair sin perder peer_id (multi-key identity). + ### feat(brahman-handshake+ente-zero): allowlist explícita de peers libp2p Capa de política sobre el trust criptográfico de Fase 3. Hasta ahora cualquier peer con keypair Ed25519 válida pasaba el handshake remoto; diff --git a/Cargo.lock b/Cargo.lock index 60e809c..f4574f0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1204,6 +1204,7 @@ dependencies = [ "brahman-card", "brahman-net", "futures", + "notify", "postcard", "serde", "tempfile", diff --git a/crates/core/brahman-handshake/Cargo.toml b/crates/core/brahman-handshake/Cargo.toml index 057a003..a0507c0 100644 --- a/crates/core/brahman-handshake/Cargo.toml +++ b/crates/core/brahman-handshake/Cargo.toml @@ -14,6 +14,7 @@ brahman-broker = { path = "../brahman-broker" } brahman-net = { path = "../../shared/brahman-net" } blake3 = { workspace = true } futures = { workspace = true } +notify = { workspace = true } serde = { workspace = true } postcard = { workspace = true } tokio = { workspace = true } diff --git a/crates/core/brahman-handshake/src/lib.rs b/crates/core/brahman-handshake/src/lib.rs index c8ea5da..198babe 100644 --- a/crates/core/brahman-handshake/src/lib.rs +++ b/crates/core/brahman-handshake/src/lib.rs @@ -22,7 +22,7 @@ pub mod messages; pub mod server; pub mod client; pub mod network; -pub mod peer_allowlist; +pub mod peer_policy; pub mod signature; pub mod transport; diff --git a/crates/core/brahman-handshake/src/peer_allowlist.rs b/crates/core/brahman-handshake/src/peer_allowlist.rs deleted file mode 100644 index a7683b8..0000000 --- a/crates/core/brahman-handshake/src/peer_allowlist.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Allowlist explícita de `PeerId`s para el trust gate remoto. -//! -//! Capa de política sobre el trust criptográfico de Fase 3 -//! (firma Ed25519 anclada al peer libp2p). Hoy cualquier peer con -//! keypair Ed25519 válida pasa el handshake; con allowlist activa, -//! sólo los peers explícitamente listados. -//! -//! Aplica únicamente al path libp2p — el path Unix sigue usando -//! `SO_PEERCRED` del kernel para autenticación local. Sin allowlist -//! configurada, comportamiento abierto (compatible con todo lo -//! anterior). -//! -//! ## Formato del archivo -//! -//! Texto plano, una entrada por línea: -//! - `PeerId` en formato base58 (canónico de libp2p, lo que muestra -//! `peer_id.to_string()` y lo que aparece en multiaddrs `/p2p/...`). -//! - Líneas vacías y líneas que empiezan con `#` se ignoran. -//! -//! Ejemplo: -//! -//! ```text -//! # allowlist brahman para máquina prod-eu-1 -//! 12D3KooWFooBarBazFooBarBazFooBarBazFooBarBazFooBarBaz -//! # operador secundario -//! 12D3KooWQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQuxQux -//! ``` - -use std::collections::BTreeSet; -use std::path::Path; - -use brahman_net::PeerId; - -/// Política de admisión por `PeerId` para conexiones libp2p. -/// -/// **Nota**: la allowlist sólo se evalúa cuando el server tiene -/// `expected_peer = Some(...)` (path libp2p). El path Unix la -/// ignora — autenticación local va por SO_PEERCRED. -#[derive(Debug, Clone)] -pub struct PeerAllowlist { - allowed: BTreeSet, -} - -#[derive(Debug, thiserror::Error)] -pub enum AllowlistError { - #[error("leer allowlist en {path}: {source}")] - Io { - path: std::path::PathBuf, - #[source] - source: std::io::Error, - }, - #[error("línea {line_no} de {path}: PeerId inválido '{value}'")] - InvalidPeerId { - path: std::path::PathBuf, - line_no: usize, - value: String, - }, -} - -impl PeerAllowlist { - /// Construye una allowlist a partir de un iterable de `PeerId`s. - /// Útil para tests o para listas hardcodeadas. - pub fn from_iter(peers: I) -> Self - where - I: IntoIterator, - { - Self { - allowed: peers.into_iter().collect(), - } - } - - /// Carga la allowlist desde un archivo. Cada línea no-vacía y no - /// comentario debe ser un `PeerId` parseable. Errores incluyen - /// el número de línea para facilitar el debug. - pub fn from_file(path: impl AsRef) -> Result { - let path = path.as_ref(); - let contents = std::fs::read_to_string(path).map_err(|e| AllowlistError::Io { - path: path.to_path_buf(), - source: e, - })?; - let mut allowed = BTreeSet::new(); - for (idx, raw) in contents.lines().enumerate() { - let line_no = idx + 1; - let trimmed = raw.split('#').next().unwrap_or("").trim(); - if trimmed.is_empty() { - continue; - } - let peer = trimmed - .parse::() - .map_err(|_| AllowlistError::InvalidPeerId { - path: path.to_path_buf(), - line_no, - value: trimmed.to_string(), - })?; - allowed.insert(peer); - } - Ok(Self { allowed }) - } - - /// Indica si `peer` está en la lista. Si la lista está vacía, - /// devuelve `false` (la "lista vacía" no es lo mismo que "sin - /// política" — para sin política, no construyas un `PeerAllowlist` - /// y dejá `ServerConfig.allowlist = None`). - pub fn is_allowed(&self, peer: &PeerId) -> bool { - self.allowed.contains(peer) - } - - /// Cantidad de peers en la lista. Útil para logs. - pub fn len(&self) -> usize { - self.allowed.len() - } - - /// `true` si no hay ningún peer en la lista. - pub fn is_empty(&self) -> bool { - self.allowed.is_empty() - } - - /// Itera los `PeerId`s permitidos en orden determinístico. - pub fn iter(&self) -> impl Iterator { - self.allowed.iter() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use brahman_net::Keypair; - use tempfile::TempDir; - - fn fresh_peer() -> PeerId { - Keypair::generate_ed25519().public().to_peer_id() - } - - #[test] - fn from_iter_and_is_allowed() { - let p1 = fresh_peer(); - let p2 = fresh_peer(); - let p3 = fresh_peer(); - let list = PeerAllowlist::from_iter([p1, p2]); - assert!(list.is_allowed(&p1)); - assert!(list.is_allowed(&p2)); - assert!(!list.is_allowed(&p3)); - assert_eq!(list.len(), 2); - } - - #[test] - fn from_file_parses_clean() { - let p1 = fresh_peer(); - let p2 = fresh_peer(); - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("allow.txt"); - let content = format!( - "# header comment\n\n{}\n # indented comment ignored\n{}\n\n", - p1, p2 - ); - std::fs::write(&path, content).unwrap(); - let list = PeerAllowlist::from_file(&path).unwrap(); - assert_eq!(list.len(), 2); - assert!(list.is_allowed(&p1)); - assert!(list.is_allowed(&p2)); - } - - #[test] - fn from_file_supports_inline_comment() { - let p1 = fresh_peer(); - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("allow.txt"); - let content = format!("{} # operador foo\n", p1); - std::fs::write(&path, content).unwrap(); - let list = PeerAllowlist::from_file(&path).unwrap(); - assert!(list.is_allowed(&p1)); - } - - #[test] - fn from_file_rejects_invalid_peer_id() { - let tmp = TempDir::new().unwrap(); - let path = tmp.path().join("bad.txt"); - std::fs::write(&path, "not-a-real-peer-id\n").unwrap(); - let err = PeerAllowlist::from_file(&path).unwrap_err(); - match err { - AllowlistError::InvalidPeerId { line_no, .. } => assert_eq!(line_no, 1), - other => panic!("wrong error: {other:?}"), - } - } - - #[test] - fn from_file_missing_returns_io_error() { - let err = PeerAllowlist::from_file("/no/such/path/allow.txt").unwrap_err(); - assert!(matches!(err, AllowlistError::Io { .. })); - } - - #[test] - fn empty_list_rejects_everything() { - let list = PeerAllowlist::from_iter(std::iter::empty()); - assert!(list.is_empty()); - assert!(!list.is_allowed(&fresh_peer())); - } -} diff --git a/crates/core/brahman-handshake/src/peer_policy.rs b/crates/core/brahman-handshake/src/peer_policy.rs new file mode 100644 index 0000000..9f07522 --- /dev/null +++ b/crates/core/brahman-handshake/src/peer_policy.rs @@ -0,0 +1,521 @@ +//! Política de admisión de peers libp2p: allowlist + denylist con hot +//! reload opcional. +//! +//! Capa de política sobre el trust criptográfico de Fase 3. Combina: +//! +//! - **Denylist**: peers explícitamente baneados. Si está, deny gana. +//! - **Allowlist**: si está set, sólo los peers listados pasan. +//! Si no está set, modo abierto (todo peer Ed25519-válido pasa, +//! sujeto sólo a denylist). +//! +//! Sin denylist y sin allowlist → modo totalmente abierto (compat +//! con todo lo anterior). Con allowlist y denylist a la vez, el +//! orden de evaluación es: deny first → allow check → admit. +//! +//! Aplica únicamente al path libp2p — el path Unix usa SO_PEERCRED +//! del kernel para autenticación local, no PeerId. +//! +//! ## Hot reload +//! +//! Si la política se construyó con [`PeerPolicy::watch_files`], un +//! thread dedicado vigila los archivos de allow/deny vía `notify`. +//! Cualquier cambio (write, create, modify, remove) dispara una +//! recarga atómica con debounce de 250ms (los editores típicos +//! producen varios eventos por save). +//! +//! Errores de reload (parse fallido, archivo eliminado) se loggean +//! pero NO bajan la política existente — aceptamos la versión +//! anterior hasta que el archivo vuelva a parsearse limpio. Esto +//! evita que un error de tipeo deje al Init en modo inseguro. +//! +//! ## Formato del archivo +//! +//! Idéntico para allow y deny: PeerId base58 por línea, `#` para +//! comentarios (línea entera o inline), líneas vacías ignoradas. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant}; + +use brahman_net::PeerId; +use tracing::{debug, info, warn}; + +/// Política de admisión combinada (allow + deny). Clone barato (todos +/// los campos son Arc o referencias inmutables). +#[derive(Clone)] +pub struct PeerPolicy { + inner: Arc>, + paths: Arc, +} + +#[derive(Default)] +struct PolicyInner { + /// `Some(set)`: sólo peers en el set pasan. `None`: modo abierto. + allow: Option>, + /// Peers baneados. Vacío = sin denylist. + deny: BTreeSet, +} + +#[derive(Default)] +struct PolicyPaths { + allow_path: Option, + deny_path: Option, +} + +/// Decisión del gate de política para un peer dado. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + /// El peer es admitido (no está en deny y, si hay allow, está en allow). + Admit, + /// El peer está explícitamente en la denylist. + DeniedByDenylist, + /// Hay allowlist configurada y el peer no está en ella. + NotInAllowlist, +} + +impl Decision { + pub fn is_admitted(self) -> bool { + matches!(self, Decision::Admit) + } + + pub fn reason(self) -> &'static str { + match self { + Decision::Admit => "admit", + Decision::DeniedByDenylist => "explicitly denied", + Decision::NotInAllowlist => "not in allowlist", + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum PolicyError { + #[error("leer política en {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("línea {line_no} de {path}: PeerId inválido '{value}'")] + InvalidPeerId { + path: PathBuf, + line_no: usize, + value: String, + }, +} + +impl PeerPolicy { + /// Política totalmente abierta: todo peer pasa. Útil como default + /// cuando no hay archivos configurados. + pub fn open() -> Self { + Self { + inner: Arc::new(RwLock::new(PolicyInner::default())), + paths: Arc::new(PolicyPaths::default()), + } + } + + /// Construye una política inline con sets explícitos. Sin paths + /// asociados, así que `reload` y `watch_files` son no-ops. + pub fn from_sets(allow: Option>, deny: BTreeSet) -> Self { + Self { + inner: Arc::new(RwLock::new(PolicyInner { allow, deny })), + paths: Arc::new(PolicyPaths::default()), + } + } + + /// Carga política desde archivos. Cada path es opcional: `None` + /// significa "esa lista no aplica" (allow=None ⇒ modo abierto; + /// deny=None ⇒ sin baneados). Asocia los paths internamente para + /// que `reload` y `watch_files` los re-lean. + pub fn from_files( + allow_path: Option<&Path>, + deny_path: Option<&Path>, + ) -> Result { + let allow = match allow_path { + Some(p) => Some(parse_peer_set(p)?), + None => None, + }; + let deny = match deny_path { + Some(p) => parse_peer_set(p)?, + None => BTreeSet::new(), + }; + Ok(Self { + inner: Arc::new(RwLock::new(PolicyInner { allow, deny })), + paths: Arc::new(PolicyPaths { + allow_path: allow_path.map(Path::to_path_buf), + deny_path: deny_path.map(Path::to_path_buf), + }), + }) + } + + /// Evalúa si `peer` puede registrarse. Toma read lock — barato, + /// concurrente, sin awaits. + pub fn evaluate(&self, peer: &PeerId) -> Decision { + let inner = match self.inner.read() { + Ok(g) => g, + Err(_) => { + // Lock envenenado: degrada a "deny por seguridad". + warn!("policy lock envenenado — deny por defecto"); + return Decision::DeniedByDenylist; + } + }; + if inner.deny.contains(peer) { + return Decision::DeniedByDenylist; + } + if let Some(allow) = &inner.allow { + if !allow.contains(peer) { + return Decision::NotInAllowlist; + } + } + Decision::Admit + } + + /// Tamaño actual de cada lista, para logging. Tupla `(allow_count, + /// deny_count)`. `allow_count = None` significa "modo abierto" + /// (sin allowlist). + pub fn sizes(&self) -> (Option, usize) { + match self.inner.read() { + Ok(g) => (g.allow.as_ref().map(|s| s.len()), g.deny.len()), + Err(_) => (Some(0), 0), + } + } + + /// Recarga atómica desde los paths asociados. Si un archivo + /// falla, la versión anterior persiste y el error se devuelve. + /// Esto evita que un typo en el archivo deje al Init en modo + /// inseguro. + pub fn reload(&self) -> Result<(), PolicyError> { + let new_allow = match &self.paths.allow_path { + Some(p) => Some(parse_peer_set(p)?), + None => None, + }; + let new_deny = match &self.paths.deny_path { + Some(p) => parse_peer_set(p)?, + None => BTreeSet::new(), + }; + if let Ok(mut inner) = self.inner.write() { + inner.allow = new_allow; + inner.deny = new_deny; + } + Ok(()) + } + + /// Arranca un thread que vigila los archivos asociados con + /// `notify` y llama [`Self::reload`] cuando cambian. Debounce + /// 250ms para coalescer múltiples eventos por save (los editores + /// hacen Create+Modify+más). + /// + /// Devuelve un `JoinHandle` que el caller debe mantener vivo. + /// Drop del handle no detiene el thread (notify watcher es + /// sticky); para detener, terminar el proceso. + /// + /// No-op si no hay paths asociados (devuelve un handle dummy + /// que termina inmediatamente). + pub fn spawn_watcher(&self) -> std::io::Result> { + let allow_path = self.paths.allow_path.clone(); + let deny_path = self.paths.deny_path.clone(); + let policy = self.clone(); + + if allow_path.is_none() && deny_path.is_none() { + // Sin archivos a vigilar: spawn un thread que termina ya. + return std::thread::Builder::new() + .name("brahman-policy-watcher-noop".into()) + .spawn(|| {}); + } + + std::thread::Builder::new() + .name("brahman-policy-watcher".into()) + .spawn(move || { + run_watcher(policy, allow_path, deny_path); + }) + } +} + +impl std::fmt::Debug for PeerPolicy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let (allow, deny) = self.sizes(); + f.debug_struct("PeerPolicy") + .field("allow", &allow) + .field("deny", &deny) + .field("allow_path", &self.paths.allow_path) + .field("deny_path", &self.paths.deny_path) + .finish() + } +} + +fn parse_peer_set(path: &Path) -> Result, PolicyError> { + let contents = std::fs::read_to_string(path).map_err(|e| PolicyError::Io { + path: path.to_path_buf(), + source: e, + })?; + let mut out = BTreeSet::new(); + for (idx, raw) in contents.lines().enumerate() { + let line_no = idx + 1; + let trimmed = raw.split('#').next().unwrap_or("").trim(); + if trimmed.is_empty() { + continue; + } + let peer = trimmed + .parse::() + .map_err(|_| PolicyError::InvalidPeerId { + path: path.to_path_buf(), + line_no, + value: trimmed.to_string(), + })?; + out.insert(peer); + } + Ok(out) +} + +const DEBOUNCE_MS: u64 = 250; + +fn run_watcher( + policy: PeerPolicy, + allow_path: Option, + deny_path: Option, +) { + use notify::{RecursiveMode, Watcher}; + + let (tx, rx) = std::sync::mpsc::channel::>(); + let mut watcher = match notify::recommended_watcher(move |res| { + let _ = tx.send(res); + }) { + Ok(w) => w, + Err(e) => { + warn!(?e, "notify watcher para policy no se pudo crear — hot reload deshabilitado"); + return; + } + }; + + // Vigilamos los DIRECTORIOS de los archivos, no los archivos + // directos. Los editores típicos hacen rename-and-replace (escriben + // a tmp, rename al destino), lo que rompe el watch del archivo + // pero NO el del directorio. Trade-off: recibimos más eventos + // (cualquier archivo del dir), filtramos por path al procesar. + for p in [&allow_path, &deny_path].iter().filter_map(|x| x.as_ref()) { + if let Some(parent) = p.parent() { + if let Err(e) = watcher.watch(parent, RecursiveMode::NonRecursive) { + warn!(path = %parent.display(), ?e, "watch failed"); + } + } + } + + let debounce = Duration::from_millis(DEBOUNCE_MS); + let mut pending_at: Option = None; + + loop { + let timeout = match pending_at { + Some(at) => debounce.saturating_sub(at.elapsed()).max(Duration::from_millis(10)), + None => Duration::from_secs(60), // wakeup periódico, no esencial + }; + + match rx.recv_timeout(timeout) { + Ok(Ok(event)) => { + // Sólo nos interesan eventos sobre los paths exactos. + let touches_us = event.paths.iter().any(|p| { + Some(p) == allow_path.as_ref() || Some(p) == deny_path.as_ref() + }); + if !touches_us { + continue; + } + debug!(?event.kind, "policy file event recibido — debounce"); + pending_at = Some(Instant::now()); + } + Ok(Err(e)) => { + warn!(?e, "notify error en policy watcher"); + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if let Some(at) = pending_at { + if at.elapsed() >= debounce { + match policy.reload() { + Ok(()) => { + let (a, d) = policy.sizes(); + info!( + allow = ?a, + deny = d, + "policy hot-reload completo" + ); + } + Err(e) => { + warn!(?e, "policy hot-reload falló — manteniendo versión anterior"); + } + } + pending_at = None; + } + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + warn!("policy watcher channel cerrado — terminando thread"); + return; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use brahman_net::Keypair; + use tempfile::TempDir; + + fn fresh_peer() -> PeerId { + Keypair::generate_ed25519().public().to_peer_id() + } + + #[test] + fn open_admits_anyone() { + let p = PeerPolicy::open(); + assert_eq!(p.evaluate(&fresh_peer()), Decision::Admit); + } + + #[test] + fn allow_only_admits_listed() { + let p1 = fresh_peer(); + let p2 = fresh_peer(); + let policy = PeerPolicy::from_sets( + Some([p1].into_iter().collect()), + BTreeSet::new(), + ); + assert_eq!(policy.evaluate(&p1), Decision::Admit); + assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist); + } + + #[test] + fn deny_overrides_open() { + let p1 = fresh_peer(); + let p2 = fresh_peer(); + let policy = PeerPolicy::from_sets(None, [p1].into_iter().collect()); + assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist); + assert_eq!(policy.evaluate(&p2), Decision::Admit); + } + + #[test] + fn deny_overrides_allow() { + // Conflicto explícito: p1 está en ambas. Deny gana. + let p1 = fresh_peer(); + let policy = PeerPolicy::from_sets( + Some([p1].into_iter().collect()), + [p1].into_iter().collect(), + ); + assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist); + } + + #[test] + fn from_files_with_both_lists() { + let p1 = fresh_peer(); + let p2 = fresh_peer(); + let p3 = fresh_peer(); + let tmp = TempDir::new().unwrap(); + let allow = tmp.path().join("allow.txt"); + let deny = tmp.path().join("deny.txt"); + std::fs::write(&allow, format!("{}\n{}\n", p1, p2)).unwrap(); + std::fs::write(&deny, format!("# baneado\n{}\n", p2)).unwrap(); + let policy = PeerPolicy::from_files(Some(&allow), Some(&deny)).unwrap(); + assert_eq!(policy.evaluate(&p1), Decision::Admit); + assert_eq!(policy.evaluate(&p2), Decision::DeniedByDenylist); // deny gana + assert_eq!(policy.evaluate(&p3), Decision::NotInAllowlist); + } + + #[test] + fn from_files_only_deny() { + let p1 = fresh_peer(); + let p2 = fresh_peer(); + let tmp = TempDir::new().unwrap(); + let deny = tmp.path().join("deny.txt"); + std::fs::write(&deny, format!("{}\n", p1)).unwrap(); + let policy = PeerPolicy::from_files(None, Some(&deny)).unwrap(); + assert_eq!(policy.evaluate(&p1), Decision::DeniedByDenylist); + assert_eq!(policy.evaluate(&p2), Decision::Admit); + } + + #[test] + fn reload_picks_up_changes() { + let p1 = fresh_peer(); + let p2 = fresh_peer(); + let tmp = TempDir::new().unwrap(); + let allow = tmp.path().join("allow.txt"); + std::fs::write(&allow, format!("{}\n", p1)).unwrap(); + + let policy = PeerPolicy::from_files(Some(&allow), None).unwrap(); + assert_eq!(policy.evaluate(&p1), Decision::Admit); + assert_eq!(policy.evaluate(&p2), Decision::NotInAllowlist); + + // Mutar el archivo: ahora p2 está, p1 no. + std::fs::write(&allow, format!("{}\n", p2)).unwrap(); + policy.reload().unwrap(); + assert_eq!(policy.evaluate(&p1), Decision::NotInAllowlist); + assert_eq!(policy.evaluate(&p2), Decision::Admit); + } + + #[test] + fn reload_failure_preserves_previous_state() { + let p1 = fresh_peer(); + let tmp = TempDir::new().unwrap(); + let allow = tmp.path().join("allow.txt"); + std::fs::write(&allow, format!("{}\n", p1)).unwrap(); + let policy = PeerPolicy::from_files(Some(&allow), None).unwrap(); + assert_eq!(policy.evaluate(&p1), Decision::Admit); + + // Romper el archivo con basura. + std::fs::write(&allow, "this-is-not-a-peer-id\n").unwrap(); + let err = policy.reload(); + assert!(err.is_err(), "reload con typo debe fallar"); + + // Estado anterior se mantiene. + assert_eq!( + policy.evaluate(&p1), + Decision::Admit, + "policy debe conservar la versión anterior tras fallo de reload" + ); + } + + #[test] + fn invalid_file_rejected_at_load() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("bad.txt"); + std::fs::write(&path, "not-a-peer-id\n").unwrap(); + let err = PeerPolicy::from_files(Some(&path), None).unwrap_err(); + assert!(matches!(err, PolicyError::InvalidPeerId { .. })); + } + + #[test] + fn watcher_reloads_on_file_change() { + // Test integración del watcher: arma policy con file, spawn + // watcher, modifica el archivo, espera el debounce, verifica + // que la policy refleja el cambio. + let p1 = fresh_peer(); + let p2 = fresh_peer(); + let tmp = TempDir::new().unwrap(); + let allow = tmp.path().join("allow.txt"); + std::fs::write(&allow, format!("{}\n", p1)).unwrap(); + + let policy = PeerPolicy::from_files(Some(&allow), None).unwrap(); + let _watcher = policy.spawn_watcher().unwrap(); + + // Le damos un instante al watcher para subscribirse al dir. + std::thread::sleep(Duration::from_millis(100)); + + // Mutamos el archivo: p2 reemplaza a p1. + std::fs::write(&allow, format!("{}\n", p2)).unwrap(); + + // Esperamos > debounce + margen. + let deadline = Instant::now() + Duration::from_secs(3); + while Instant::now() < deadline { + if policy.evaluate(&p2) == Decision::Admit { + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + + assert_eq!( + policy.evaluate(&p2), + Decision::Admit, + "watcher debería haber recargado la policy" + ); + assert_eq!( + policy.evaluate(&p1), + Decision::NotInAllowlist, + "p1 debería haber salido tras el reload" + ); + } +} diff --git a/crates/core/brahman-handshake/src/server.rs b/crates/core/brahman-handshake/src/server.rs index 4140fc0..4e74783 100644 --- a/crates/core/brahman-handshake/src/server.rs +++ b/crates/core/brahman-handshake/src/server.rs @@ -57,13 +57,12 @@ pub struct ServerConfig { /// locales (lo cual es correcto cuando no hay conectividad o no /// se desea exponer al exterior). pub net: Option>, - /// Política de admisión de peers libp2p. Si está presente, el - /// trust gate del path libp2p exige además que el `peer_id` - /// autenticado por Noise esté en la lista. `None` → modo abierto - /// (cualquier peer Ed25519-válido pasa, comportamiento de Fase 3 - /// sin restricción adicional). El path Unix la ignora — la - /// allowlist es a nivel libp2p, no de filesystem. - pub allowlist: Option, + /// Política de admisión de peers libp2p (allow + deny + hot + /// reload opcional). Si está presente, el trust gate del path + /// libp2p evalúa cada `peer_id` (ya autenticado por Noise) + /// contra esta política. `None` → modo totalmente abierto + /// (cualquier peer Ed25519-válido pasa). El path Unix la ignora. + pub policy: Option, } // Manual Debug porque BrahmanNet no implementa Debug (libp2p Swarm @@ -74,7 +73,7 @@ impl std::fmt::Debug for ServerConfig { .field("init_attached", &self.init_attached) .field("broker", &self.broker.as_ref().map(|_| "")) .field("net", &self.net.as_ref().map(|_| "")) - .field("allowlist", &self.allowlist.as_ref().map(|a| a.len())) + .field("policy", &self.policy.as_ref().map(|p| p.sizes())) .finish() } } @@ -548,22 +547,24 @@ where return Ok(None); } - // Allowlist gate (path libp2p): si está configurada, el peer - // autenticado por Noise debe estar en la lista. Se chequea - // ANTES de la firma porque es comparación O(log n) sin crypto - // — ahorra ciclos contra peers no permitidos. La allowlist no - // se aplica al path Unix (autenticación por SO_PEERCRED, no - // por libp2p PeerId). - if let (Some(peer), Some(allowlist)) = (expected_peer, &config.allowlist) { - if !allowlist.is_allowed(&peer) { + // Policy gate (path libp2p): si está configurada, el peer + // autenticado por Noise debe pasar la política (deny first, + // luego allow). Se chequea ANTES de la firma porque es + // comparación O(log n) sin crypto — ahorra ciclos contra peers + // no permitidos. La política no se aplica al path Unix + // (autenticación por SO_PEERCRED, no por libp2p PeerId). + if let (Some(peer), Some(policy)) = (expected_peer, &config.policy) { + let decision = policy.evaluate(&peer); + if !decision.is_admitted() { write_frame( stream, &Frame::Error(HandshakeError::Unauthorized(format!( - "peer {peer} no está en la allowlist" + "peer {peer}: {}", + decision.reason() ))), ) .await?; - debug!(peer = %peer, "rechazado por allowlist"); + debug!(peer = %peer, reason = decision.reason(), "rechazado por policy"); return Ok(None); } } diff --git a/crates/core/brahman-handshake/tests/handshake.rs b/crates/core/brahman-handshake/tests/handshake.rs index eee1bfc..5e5b417 100644 --- a/crates/core/brahman-handshake/tests/handshake.rs +++ b/crates/core/brahman-handshake/tests/handshake.rs @@ -58,7 +58,7 @@ fn sock_path(name: &str) -> std::path::PathBuf { #[tokio::test] async fn full_handshake_roundtrip() { let path = sock_path("happy"); - let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None, net: None, allowlist: None }).unwrap(); + let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None, net: None, policy: None }).unwrap(); let session_handle = tokio::spawn({ async move { @@ -195,7 +195,7 @@ async fn broker_registers_and_unregisters_with_session() { init_attached: false, broker: Some(broker.clone()), net: None, - allowlist: None, + policy: None, }, ) .unwrap(); @@ -239,7 +239,7 @@ async fn broker_matches_two_live_modules() { init_attached: false, broker: Some(broker.clone()), net: None, - allowlist: None, + policy: None, }, ) .unwrap(); @@ -316,7 +316,7 @@ async fn match_event_pushed_on_producer_arrival() { init_attached: false, broker: Some(broker.clone()), net: None, - allowlist: None, + policy: None, }, ) .unwrap(); diff --git a/crates/core/brahman-handshake/tests/network_discovery.rs b/crates/core/brahman-handshake/tests/network_discovery.rs index 430c55d..d10d8bd 100644 --- a/crates/core/brahman-handshake/tests/network_discovery.rs +++ b/crates/core/brahman-handshake/tests/network_discovery.rs @@ -85,7 +85,7 @@ async fn dht_discovery_finds_remote_provider() { init_attached: true, broker: Some(a_broker.clone()), net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT - allowlist: None, + policy: None, }, ) .unwrap(), @@ -172,7 +172,7 @@ async fn dht_discovery_negative_unknown_flow() { init_attached: true, broker: Some(a_broker), net: Some(a_net.clone()), - allowlist: None, + policy: None, }, ) .unwrap(), @@ -251,7 +251,7 @@ async fn dht_discovery_withdraws_on_session_cleanup() { init_attached: true, broker: Some(a_broker), net: Some(a_net.clone()), - allowlist: None, + policy: None, }, ) .unwrap(), diff --git a/crates/core/brahman-handshake/tests/network_libp2p.rs b/crates/core/brahman-handshake/tests/network_libp2p.rs index a829e40..96c7b55 100644 --- a/crates/core/brahman-handshake/tests/network_libp2p.rs +++ b/crates/core/brahman-handshake/tests/network_libp2p.rs @@ -19,7 +19,7 @@ use brahman_card::{ CARD_SCHEMA_VERSION, }; use brahman_handshake::network::{connect_libp2p, run_libp2p_accept_loop}; -use brahman_handshake::peer_allowlist::PeerAllowlist; +use brahman_handshake::peer_policy::PeerPolicy; use brahman_handshake::server::{Server, ServerConfig}; use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol}; use tempfile::TempDir; @@ -57,7 +57,7 @@ async fn libp2p_handshake_roundtrip() { init_attached: true, broker: Some(broker.clone()), net: None, - allowlist: None, + policy: None, }, ) .unwrap(), @@ -136,7 +136,7 @@ async fn libp2p_handshake_rejects_mismatched_signing_key() { init_attached: true, broker: None, net: None, - allowlist: None, + policy: None, }, ) .unwrap(), @@ -197,7 +197,10 @@ async fn libp2p_handshake_allowlist_admits_listed_rejects_others() { init_attached: true, broker: None, net: None, - allowlist: Some(PeerAllowlist::from_iter([allowed_peer])), + policy: Some(PeerPolicy::from_sets( + Some([allowed_peer].into_iter().collect()), + std::collections::BTreeSet::new(), + )), }, ) .unwrap(), @@ -247,3 +250,74 @@ async fn libp2p_handshake_allowlist_admits_listed_rejects_others() { assert_eq!(s.len(), 0, "ninguna sesión adicional registrada tras intento denegado"); } } + +/// Denylist gate: A configura `policy` con un peer en la denylist. +/// Modo abierto para todo lo demás (sin allowlist), pero el peer +/// baneado es rechazado aún teniendo Ed25519 válida y peer_id que +/// derivaría limpio del Noise handshake. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn libp2p_handshake_denylist_blocks_listed_peer() { + let banned_kp = Keypair::generate_ed25519(); + let banned_peer = banned_kp.public().to_peer_id(); + let other_kp = Keypair::generate_ed25519(); + + let tmp = TempDir::new().unwrap(); + let unix_socket = tmp.path().join("brahman-init.sock"); + let server = Arc::new( + Server::bind( + &unix_socket, + ServerConfig { + init_attached: true, + broker: None, + net: None, + policy: Some(PeerPolicy::from_sets( + None, // sin allowlist (abierto) + [banned_peer].into_iter().collect(), + )), + }, + ) + .unwrap(), + ); + let sessions = server.sessions(); + + let server_net = Arc::new(BrahmanNet::new().unwrap()); + let server_peer = server_net.peer_id; + let actual = server_net + .listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()) + .await; + let mut full = actual.clone(); + full.push(Protocol::P2p(server_peer)); + + tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone())); + + // Cliente baneado: connect debe fallar. + let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap(); + banned_net.dial(full.clone()); + tokio::time::sleep(Duration::from_millis(200)).await; + + let card_x = sample_card("test.banned"); + let result = connect_libp2p(&banned_net, server_peer, card_x, None, &banned_kp).await; + assert!( + result.is_err(), + "peer en denylist debe ser rechazado, got Ok" + ); + { + let s = sessions.lock().await; + assert_eq!(s.len(), 0, "el peer baneado no debería tener sesión"); + } + + // Cliente no-baneado pasa. + let other_net = BrahmanNet::with_keypair(other_kp.clone()).unwrap(); + other_net.dial(full.clone()); + tokio::time::sleep(Duration::from_millis(200)).await; + + let card_ok = sample_card("test.other"); + let mut other_client = connect_libp2p(&other_net, server_peer, card_ok, None, &other_kp) + .await + .expect("peer fuera de denylist debe pasar"); + { + let s = sessions.lock().await; + assert_eq!(s.len(), 1, "sesión del peer no-baneado registrada"); + } + other_client.farewell().await.ok(); +} diff --git a/crates/core/ente-zero/src/main.rs b/crates/core/ente-zero/src/main.rs index 78b07b6..308df28 100644 --- a/crates/core/ente-zero/src/main.rs +++ b/crates/core/ente-zero/src/main.rs @@ -168,11 +168,12 @@ async fn primordial_loop( // reboots). let brahman_net = setup_brahman_net(dev_mode).await; - // Allowlist opcional de peers libp2p: si BRAHMAN_PEER_ALLOWLIST - // apunta a un archivo, cualquier handshake remoto requiere que - // su peer_id esté en la lista. Sin la env, modo abierto (todo - // peer Ed25519-válido pasa el trust gate de Fase 3). - let brahman_allowlist = setup_brahman_allowlist(); + // Política opcional de peers libp2p: allowlist + denylist + hot + // reload. Activada si BRAHMAN_PEER_ALLOWLIST o BRAHMAN_PEER_DENYLIST + // están set. Sin ninguna, modo totalmente abierto (Fase 3 sin + // restricción adicional). El watcher se queda vivo en background + // observando los archivos para hot reload. + let (brahman_policy, _policy_watcher) = setup_brahman_policy(); let brahman_sock = brahman_handshake::transport::default_socket_path(); match brahman_handshake::server::Server::bind( @@ -181,7 +182,7 @@ async fn primordial_loop( init_attached: true, broker: Some(brahman_broker.clone()), net: brahman_net.clone(), - allowlist: brahman_allowlist.clone(), + policy: brahman_policy.clone(), }, ) { Ok(server) => { @@ -706,35 +707,70 @@ async fn setup_brahman_net( Some(net) } -/// Carga la allowlist de peers libp2p desde el archivo apuntado por -/// `BRAHMAN_PEER_ALLOWLIST`. Sin la env, devuelve `None` (modo abierto: -/// cualquier peer Ed25519-válido pasa el trust gate). Si la env está -/// pero el archivo falla, loggea y degrada a None — la doctrina PID 1 -/// de no romper por subsistemas opcionales se mantiene. -fn setup_brahman_allowlist() -> Option { - let path = match std::env::var("BRAHMAN_PEER_ALLOWLIST") { - Ok(s) if !s.is_empty() => s, - _ => { - tracing::debug!("BRAHMAN_PEER_ALLOWLIST no set — modo abierto (todo peer pasa)"); - return None; - } - }; - match brahman_handshake::peer_allowlist::PeerAllowlist::from_file(&path) { - Ok(list) => { - info!( - path = %path, - peers = list.len(), - "allowlist de peers libp2p cargada" - ); - Some(list) - } +/// Carga la política de peers libp2p desde los archivos apuntados por +/// `BRAHMAN_PEER_ALLOWLIST` y/o `BRAHMAN_PEER_DENYLIST`, y arranca un +/// watcher para hot reload sobre cualquier cambio. +/// +/// - Sin ninguna env: `(None, None)` → modo totalmente abierto. +/// - Con cualquiera (o ambas) set: política activa + watcher vivo. +/// - Si los archivos fallan al cargar: degrada a `(None, None)`, +/// loggea, NO rompe el bucle primordial (doctrina PID 1). +/// +/// Devuelve la política y el `JoinHandle` del watcher (que el caller +/// debe mantener para que el thread no se aborte). Si no hay paths, +/// el watcher es un no-op que termina inmediato. +fn setup_brahman_policy() -> ( + Option, + Option>, +) { + let allow_path = std::env::var("BRAHMAN_PEER_ALLOWLIST") + .ok() + .filter(|s| !s.is_empty()); + let deny_path = std::env::var("BRAHMAN_PEER_DENYLIST") + .ok() + .filter(|s| !s.is_empty()); + + if allow_path.is_none() && deny_path.is_none() { + tracing::debug!( + "BRAHMAN_PEER_ALLOWLIST y BRAHMAN_PEER_DENYLIST no set — modo abierto (todo peer pasa)" + ); + return (None, None); + } + + let allow_pb = allow_path.as_deref().map(std::path::Path::new); + let deny_pb = deny_path.as_deref().map(std::path::Path::new); + + let policy = match brahman_handshake::peer_policy::PeerPolicy::from_files(allow_pb, deny_pb) { + Ok(p) => p, Err(e) => { warn!( - path = %path, ?e, - "BRAHMAN_PEER_ALLOWLIST inválido — degradando a modo abierto (sin restricción)" + allow = ?allow_path, + deny = ?deny_path, + "policy de peers inválida — degradando a modo abierto (sin restricción)" ); + return (None, None); + } + }; + + let (allow_count, deny_count) = policy.sizes(); + info!( + allow = ?allow_count, + deny = deny_count, + allow_path = ?allow_path, + deny_path = ?deny_path, + "policy de peers libp2p cargada" + ); + + // Spawn watcher para hot reload. Errores aquí no son fatales — + // tendrías política sin reload, que es razonable. + let watcher = match policy.spawn_watcher() { + Ok(h) => Some(h), + Err(e) => { + warn!(?e, "policy watcher no se pudo crear — hot reload deshabilitado"); None } - } + }; + + (Some(policy), watcher) }