feat(brahman-handshake+ente-zero): denylist + hot reload de policy de peers

Consolida PeerAllowlist + nueva denylist en un unico PeerPolicy con
allow + deny + hot reload via notify. Cubre los dos pendientes
documentados en el commit anterior y simplifica la API hacia un solo
punto de entrada.

API consolidada en brahman_handshake::peer_policy:
- PeerPolicy::open() — todo permitido (default).
- PeerPolicy::from_sets(allow, deny) — politica inline para tests.
- PeerPolicy::from_files(allow_path?, deny_path?) — carga ambos
  archivos opcionales.
- PeerPolicy::evaluate(peer) -> Decision { Admit | DeniedByDenylist
  | NotInAllowlist }. Decision lleva reason() para logging.
- PeerPolicy::reload() — recarga atomica desde paths asociados.
  Si un archivo falla, conserva la version anterior (un typo no
  baja la politica activa).
- PeerPolicy::spawn_watcher() -> JoinHandle — vigila los archivos
  via notify, debounce 250ms (coalesce de eventos por save), recarga
  atomica al detectar cambio.

Orden de evaluacion: deny-first.
1. peer in denylist -> DeniedByDenylist.
2. allowlist set y peer no in allowlist -> NotInAllowlist.
3. resto -> Admit.

Deny gana sobre allow (un peer en ambas es rechazado): la denylist
es la primitiva de "kill switch".

Watcher: vigila el directorio padre del archivo, no el archivo
mismo. Razon: editores tipicos hacen rename-and-replace que rompe
el watch del archivo pero no del dir. Filtra eventos por path al
procesar.

Wire en server: ServerConfig.allowlist -> ServerConfig.policy:
Option<PeerPolicy> (rename, scope local).

Wire en Arje (ente-zero): nueva env BRAHMAN_PEER_DENYLIST complementa
BRAHMAN_PEER_ALLOWLIST. setup_brahman_policy carga + spawn watcher
y devuelve (policy, JoinHandle) — el handle se conserva en main
para que el thread no aborte.

Activacion completa con todas las capas:
  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
# Editar deny.txt en caliente entra en efecto en ~250ms sin restart.

Tests: 10 unit en peer_policy (incluido watcher_reloads_on_file_change
con notify real) + 1 E2E nuevo libp2p_handshake_denylist_blocks_
listed_peer. 30 tests verdes en brahman-handshake. Sin regresion en
ente-zero.

Lo que cierra: politica completa (open/allow/deny/both), hot reload
sin restart, atomicidad de la recarga, resiliencia ante typos.

Pendientes futuros: aplicar policy a nivel de swarm via
libp2p_allow_block_list::Behaviour (rechazar antes del Noise
handshake), rotacion de keypair sin perder peer_id.
This commit is contained in:
Sergio
2026-05-09 15:35:00 +00:00
parent 505748dd41
commit d98a2b6b7c
11 changed files with 796 additions and 259 deletions
+101
View File
@@ -6,6 +6,107 @@ ratio/diff ver `git show <sha>`.
## 2026-05-09 ## 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<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>)`
— 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<PeerPolicy>`
(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 ### 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 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; cualquier peer con keypair Ed25519 válida pasaba el handshake remoto;
Generated
+1
View File
@@ -1204,6 +1204,7 @@ dependencies = [
"brahman-card", "brahman-card",
"brahman-net", "brahman-net",
"futures", "futures",
"notify",
"postcard", "postcard",
"serde", "serde",
"tempfile", "tempfile",
+1
View File
@@ -14,6 +14,7 @@ brahman-broker = { path = "../brahman-broker" }
brahman-net = { path = "../../shared/brahman-net" } brahman-net = { path = "../../shared/brahman-net" }
blake3 = { workspace = true } blake3 = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
notify = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
postcard = { workspace = true } postcard = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
+1 -1
View File
@@ -22,7 +22,7 @@ pub mod messages;
pub mod server; pub mod server;
pub mod client; pub mod client;
pub mod network; pub mod network;
pub mod peer_allowlist; pub mod peer_policy;
pub mod signature; pub mod signature;
pub mod transport; pub mod transport;
@@ -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<PeerId>,
}
#[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<I>(peers: I) -> Self
where
I: IntoIterator<Item = PeerId>,
{
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<Path>) -> Result<Self, AllowlistError> {
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::<PeerId>()
.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<Item = &PeerId> {
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()));
}
}
@@ -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<RwLock<PolicyInner>>,
paths: Arc<PolicyPaths>,
}
#[derive(Default)]
struct PolicyInner {
/// `Some(set)`: sólo peers en el set pasan. `None`: modo abierto.
allow: Option<BTreeSet<PeerId>>,
/// Peers baneados. Vacío = sin denylist.
deny: BTreeSet<PeerId>,
}
#[derive(Default)]
struct PolicyPaths {
allow_path: Option<PathBuf>,
deny_path: Option<PathBuf>,
}
/// 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<BTreeSet<PeerId>>, deny: BTreeSet<PeerId>) -> 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<Self, PolicyError> {
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>, 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<std::thread::JoinHandle<()>> {
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<BTreeSet<PeerId>, 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::<PeerId>()
.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<PathBuf>,
deny_path: Option<PathBuf>,
) {
use notify::{RecursiveMode, Watcher};
let (tx, rx) = std::sync::mpsc::channel::<notify::Result<notify::Event>>();
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<Instant> = 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"
);
}
}
+19 -18
View File
@@ -57,13 +57,12 @@ pub struct ServerConfig {
/// locales (lo cual es correcto cuando no hay conectividad o no /// locales (lo cual es correcto cuando no hay conectividad o no
/// se desea exponer al exterior). /// se desea exponer al exterior).
pub net: Option<Arc<BrahmanNet>>, pub net: Option<Arc<BrahmanNet>>,
/// Política de admisión de peers libp2p. Si está presente, el /// Política de admisión de peers libp2p (allow + deny + hot
/// trust gate del path libp2p exige además que el `peer_id` /// reload opcional). Si está presente, el trust gate del path
/// autenticado por Noise esté en la lista. `None` → modo abierto /// libp2p evalúa cada `peer_id` (ya autenticado por Noise)
/// (cualquier peer Ed25519-válido pasa, comportamiento de Fase 3 /// contra esta política. `None` → modo totalmente abierto
/// sin restricción adicional). El path Unix la ignora — la /// (cualquier peer Ed25519-válido pasa). El path Unix la ignora.
/// allowlist es a nivel libp2p, no de filesystem. pub policy: Option<crate::peer_policy::PeerPolicy>,
pub allowlist: Option<crate::peer_allowlist::PeerAllowlist>,
} }
// Manual Debug porque BrahmanNet no implementa Debug (libp2p Swarm // 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("init_attached", &self.init_attached)
.field("broker", &self.broker.as_ref().map(|_| "<broker>")) .field("broker", &self.broker.as_ref().map(|_| "<broker>"))
.field("net", &self.net.as_ref().map(|_| "<net>")) .field("net", &self.net.as_ref().map(|_| "<net>"))
.field("allowlist", &self.allowlist.as_ref().map(|a| a.len())) .field("policy", &self.policy.as_ref().map(|p| p.sizes()))
.finish() .finish()
} }
} }
@@ -548,22 +547,24 @@ where
return Ok(None); return Ok(None);
} }
// Allowlist gate (path libp2p): si está configurada, el peer // Policy gate (path libp2p): si está configurada, el peer
// autenticado por Noise debe estar en la lista. Se chequea // autenticado por Noise debe pasar la política (deny first,
// ANTES de la firma porque es comparación O(log n) sin crypto // luego allow). Se chequea ANTES de la firma porque es
// — ahorra ciclos contra peers no permitidos. La allowlist no // comparación O(log n) sin crypto — ahorra ciclos contra peers
// se aplica al path Unix (autenticación por SO_PEERCRED, no // no permitidos. La política no se aplica al path Unix
// por libp2p PeerId). // (autenticación por SO_PEERCRED, no por libp2p PeerId).
if let (Some(peer), Some(allowlist)) = (expected_peer, &config.allowlist) { if let (Some(peer), Some(policy)) = (expected_peer, &config.policy) {
if !allowlist.is_allowed(&peer) { let decision = policy.evaluate(&peer);
if !decision.is_admitted() {
write_frame( write_frame(
stream, stream,
&Frame::Error(HandshakeError::Unauthorized(format!( &Frame::Error(HandshakeError::Unauthorized(format!(
"peer {peer} no está en la allowlist" "peer {peer}: {}",
decision.reason()
))), ))),
) )
.await?; .await?;
debug!(peer = %peer, "rechazado por allowlist"); debug!(peer = %peer, reason = decision.reason(), "rechazado por policy");
return Ok(None); return Ok(None);
} }
} }
@@ -58,7 +58,7 @@ fn sock_path(name: &str) -> std::path::PathBuf {
#[tokio::test] #[tokio::test]
async fn full_handshake_roundtrip() { async fn full_handshake_roundtrip() {
let path = sock_path("happy"); 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({ let session_handle = tokio::spawn({
async move { async move {
@@ -195,7 +195,7 @@ async fn broker_registers_and_unregisters_with_session() {
init_attached: false, init_attached: false,
broker: Some(broker.clone()), broker: Some(broker.clone()),
net: None, net: None,
allowlist: None, policy: None,
}, },
) )
.unwrap(); .unwrap();
@@ -239,7 +239,7 @@ async fn broker_matches_two_live_modules() {
init_attached: false, init_attached: false,
broker: Some(broker.clone()), broker: Some(broker.clone()),
net: None, net: None,
allowlist: None, policy: None,
}, },
) )
.unwrap(); .unwrap();
@@ -316,7 +316,7 @@ async fn match_event_pushed_on_producer_arrival() {
init_attached: false, init_attached: false,
broker: Some(broker.clone()), broker: Some(broker.clone()),
net: None, net: None,
allowlist: None, policy: None,
}, },
) )
.unwrap(); .unwrap();
@@ -85,7 +85,7 @@ async fn dht_discovery_finds_remote_provider() {
init_attached: true, init_attached: true,
broker: Some(a_broker.clone()), broker: Some(a_broker.clone()),
net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT
allowlist: None, policy: None,
}, },
) )
.unwrap(), .unwrap(),
@@ -172,7 +172,7 @@ async fn dht_discovery_negative_unknown_flow() {
init_attached: true, init_attached: true,
broker: Some(a_broker), broker: Some(a_broker),
net: Some(a_net.clone()), net: Some(a_net.clone()),
allowlist: None, policy: None,
}, },
) )
.unwrap(), .unwrap(),
@@ -251,7 +251,7 @@ async fn dht_discovery_withdraws_on_session_cleanup() {
init_attached: true, init_attached: true,
broker: Some(a_broker), broker: Some(a_broker),
net: Some(a_net.clone()), net: Some(a_net.clone()),
allowlist: None, policy: None,
}, },
) )
.unwrap(), .unwrap(),
@@ -19,7 +19,7 @@ use brahman_card::{
CARD_SCHEMA_VERSION, CARD_SCHEMA_VERSION,
}; };
use brahman_handshake::network::{connect_libp2p, run_libp2p_accept_loop}; 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_handshake::server::{Server, ServerConfig};
use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol}; use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol};
use tempfile::TempDir; use tempfile::TempDir;
@@ -57,7 +57,7 @@ async fn libp2p_handshake_roundtrip() {
init_attached: true, init_attached: true,
broker: Some(broker.clone()), broker: Some(broker.clone()),
net: None, net: None,
allowlist: None, policy: None,
}, },
) )
.unwrap(), .unwrap(),
@@ -136,7 +136,7 @@ async fn libp2p_handshake_rejects_mismatched_signing_key() {
init_attached: true, init_attached: true,
broker: None, broker: None,
net: None, net: None,
allowlist: None, policy: None,
}, },
) )
.unwrap(), .unwrap(),
@@ -197,7 +197,10 @@ async fn libp2p_handshake_allowlist_admits_listed_rejects_others() {
init_attached: true, init_attached: true,
broker: None, broker: None,
net: 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(), .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"); 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();
}
+65 -29
View File
@@ -168,11 +168,12 @@ async fn primordial_loop(
// reboots). // reboots).
let brahman_net = setup_brahman_net(dev_mode).await; let brahman_net = setup_brahman_net(dev_mode).await;
// Allowlist opcional de peers libp2p: si BRAHMAN_PEER_ALLOWLIST // Política opcional de peers libp2p: allowlist + denylist + hot
// apunta a un archivo, cualquier handshake remoto requiere que // reload. Activada si BRAHMAN_PEER_ALLOWLIST o BRAHMAN_PEER_DENYLIST
// su peer_id esté en la lista. Sin la env, modo abierto (todo // están set. Sin ninguna, modo totalmente abierto (Fase 3 sin
// peer Ed25519-válido pasa el trust gate de Fase 3). // restricción adicional). El watcher se queda vivo en background
let brahman_allowlist = setup_brahman_allowlist(); // observando los archivos para hot reload.
let (brahman_policy, _policy_watcher) = setup_brahman_policy();
let brahman_sock = brahman_handshake::transport::default_socket_path(); let brahman_sock = brahman_handshake::transport::default_socket_path();
match brahman_handshake::server::Server::bind( match brahman_handshake::server::Server::bind(
@@ -181,7 +182,7 @@ async fn primordial_loop(
init_attached: true, init_attached: true,
broker: Some(brahman_broker.clone()), broker: Some(brahman_broker.clone()),
net: brahman_net.clone(), net: brahman_net.clone(),
allowlist: brahman_allowlist.clone(), policy: brahman_policy.clone(),
}, },
) { ) {
Ok(server) => { Ok(server) => {
@@ -706,35 +707,70 @@ async fn setup_brahman_net(
Some(net) Some(net)
} }
/// Carga la allowlist de peers libp2p desde el archivo apuntado por /// Carga la política de peers libp2p desde los archivos apuntados por
/// `BRAHMAN_PEER_ALLOWLIST`. Sin la env, devuelve `None` (modo abierto: /// `BRAHMAN_PEER_ALLOWLIST` y/o `BRAHMAN_PEER_DENYLIST`, y arranca un
/// cualquier peer Ed25519-válido pasa el trust gate). Si la env está /// watcher para hot reload sobre cualquier cambio.
/// pero el archivo falla, loggea y degrada a None — la doctrina PID 1 ///
/// de no romper por subsistemas opcionales se mantiene. /// - Sin ninguna env: `(None, None)` → modo totalmente abierto.
fn setup_brahman_allowlist() -> Option<brahman_handshake::peer_allowlist::PeerAllowlist> { /// - Con cualquiera (o ambas) set: política activa + watcher vivo.
let path = match std::env::var("BRAHMAN_PEER_ALLOWLIST") { /// - Si los archivos fallan al cargar: degrada a `(None, None)`,
Ok(s) if !s.is_empty() => s, /// loggea, NO rompe el bucle primordial (doctrina PID 1).
_ => { ///
tracing::debug!("BRAHMAN_PEER_ALLOWLIST no set — modo abierto (todo peer pasa)"); /// Devuelve la política y el `JoinHandle` del watcher (que el caller
return None; /// 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() -> (
match brahman_handshake::peer_allowlist::PeerAllowlist::from_file(&path) { Option<brahman_handshake::peer_policy::PeerPolicy>,
Ok(list) => { Option<std::thread::JoinHandle<()>>,
info!( ) {
path = %path, let allow_path = std::env::var("BRAHMAN_PEER_ALLOWLIST")
peers = list.len(), .ok()
"allowlist de peers libp2p cargada" .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)"
); );
Some(list) 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) => { Err(e) => {
warn!( warn!(
path = %path,
?e, ?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 None
} }
} };
(Some(policy), watcher)
} }