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
+67 -31
View File
@@ -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<brahman_handshake::peer_allowlist::PeerAllowlist> {
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<brahman_handshake::peer_policy::PeerPolicy>,
Option<std::thread::JoinHandle<()>>,
) {
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)
}