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
+19 -18
View File
@@ -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<Arc<BrahmanNet>>,
/// 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<crate::peer_allowlist::PeerAllowlist>,
/// 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<crate::peer_policy::PeerPolicy>,
}
// 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(|_| "<broker>"))
.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()
}
}
@@ -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);
}
}