refactor(monorepo): reorganización lógica + renames + SDDs + split CHANGELOG

Reorganización física de crates/:
- core/ (mezclaba 6 propósitos) se divide en protocol/, init/, runtime/, compat/
- shared/ (3 crates) se redistribuye en protocol/ e init/
- lapaloma (sub-módulo de ui_engine) se promueve a modules/pineal/

Renames de proyectos:
- shipote → shuma (runtime de sandboxes)
- nouser → akasha (explorador de Mónadas)
- yahweh → nahual (motor GPUI, antes ui_engine/)
- lapaloma → pineal (data-viz agnóstica)

Fraccionamiento UI → core agnóstico:
- vista-core (DeckState + snap, 175 LOC, 5 tests verdes)
- barra-core (Task + render_html + sanitize, 90 LOC, 5 tests verdes)
- vista-web y barra-web ahora son thin DOM bindings

Documentación nueva:
- 16 SDDs por subdirectorio (≤80 LOC c/u): protocol/init/runtime/compat
  + 10 módulos + apps/
- docs/STATUS.md con cifras reales por proyecto
- docs/ROADMAP.md con plan a finalización (6 hitos, ~6-8 semanas)
- CHANGELOG.md particionado en docs/changelog/<proyecto>.md (7 buckets)

Automatización:
- scripts/reorg.py — script idempotente que: git mv directorios, renombra
  package names, recomputa path = refs, reescribe imports rust, actualiza
  workspace Cargo.toml. Soporta --dry-run.
- scripts/split-changelog.py — particiona CHANGELOG por componente.

Validación:
- cargo check --workspace pasa (124 crates + 2 nuevos cores).
- 10 tests adicionales (5 en vista-core + 5 en barra-core) verdes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
sergio
2026-05-19 14:48:34 +00:00
parent 86fb6ae20b
commit 550c98f275
375 changed files with 8512 additions and 7155 deletions
@@ -0,0 +1,37 @@
[package]
name = "brahman-handshake"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
authors.workspace = true
publish.workspace = true
description = "Brahman — handshake runtime Init↔módulo. Local sobre Unix socket; remoto sobre stream libp2p (brahman-net)."
[dependencies]
brahman-card = { path = "../brahman-card" }
brahman-broker = { path = "../brahman-broker" }
brahman-net = { path = "../brahman-net" }
blake3 = { workspace = true }
futures = { workspace = true }
notify = { workspace = true }
serde = { workspace = true }
postcard = { workspace = true }
tokio = { workspace = true }
tokio-util = { workspace = true }
thiserror = { workspace = true }
ulid = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }
tokio = { workspace = true }
anyhow = { workspace = true }
[[example]]
name = "probe"
path = "examples/probe.rs"
[[example]]
name = "subscriber"
path = "examples/subscriber.rs"
@@ -0,0 +1,51 @@
//! probe — herramienta de diagnóstico del handshake.
//!
//! Conecta a un Init brahman vivo, hace handshake, un ping, y se va.
//! Ruta del socket: `$BRAHMAN_INIT_SOCKET` o el default
//! ([`brahman_handshake::transport::default_socket_path`]).
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-handshake --example probe
//! ```
use std::collections::BTreeSet;
use brahman_card::{Card, Payload, Supervision, CARD_SCHEMA_VERSION};
use brahman_handshake::{client::Client, transport};
use ulid::Ulid;
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: "brahman-probe".into(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
provides: BTreeSet::new(),
requires: BTreeSet::new(),
..Default::default()
};
let path = transport::default_socket_path();
println!("connecting to {}", path.display());
let mut client = Client::connect(&path, card).await?;
let info = client.server_info();
println!(
" HelloAck: session={} server={} protocol={} init_attached={}",
client.session(),
info.server_version,
info.protocol_version,
info.init_attached
);
let ts = client.ping().await?;
println!(" Pong: ts={}ms", ts);
client.farewell().await?;
println!(" Farewell OK");
Ok(())
}
@@ -0,0 +1,83 @@
//! `subscriber` — cliente brahman que loguea cada `MatchEvent` recibido.
//!
//! Declara una Card con un input `in` de tipo `json`. Cada vez que el
//! broker matchea (o desmatch) ese input contra un productor, imprime
//! una línea. Útil para visualizar la dinámica del broker en vivo.
//!
//! Uso:
//! ```sh
//! cargo run -p brahman-handshake --example subscriber [label]
//! ```
use std::collections::BTreeSet;
use std::time::Duration;
use brahman_card::{
ulid::Ulid, Card, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use brahman_handshake::{client::Client, transport};
#[tokio::main(flavor = "current_thread")]
async fn main() -> anyhow::Result<()> {
let label = std::env::args()
.nth(1)
.unwrap_or_else(|| "subscriber".into());
let card = Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.clone(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
flow: Flows {
input: vec![Flow {
name: "in".into(),
ty: TypeRef::Primitive {
name: "json".into(),
},
pin_to: None,
}],
output: vec![],
},
..Default::default()
};
let path = transport::default_socket_path();
eprintln!("[{label}] connecting to {}", path.display());
let mut client = Client::connect(&path, card).await?;
eprintln!(
"[{label}] attached: session={} init={}",
client.session(),
client.server_info().init_attached
);
// Loop: espera hasta 25s por un MatchEvent. Si timeout, ping para
// mantener la conexión viva.
loop {
match client.await_event(Duration::from_secs(25)).await? {
Some(ev) => {
eprintln!(
"[{label}] {:?} {}{}.{} via={:?}{}",
ev.kind,
ev.consumer_flow,
if ev.producer_label.is_empty() {
"<none>"
} else {
&ev.producer_label
},
ev.producer_flow,
ev.via,
if ev.pinned { " 📌" } else { "" }
);
}
None => {
let _ts = client.ping().await?;
}
}
}
}
@@ -0,0 +1,312 @@
//! Cliente de handshake. Conecta a un Unix socket y mantiene la sesión.
use std::collections::VecDeque;
use std::path::Path;
use std::time::Duration;
use brahman_card::{Card, WitInterface, CARD_SCHEMA_VERSION};
use brahman_net::Keypair;
use thiserror::Error;
use tokio::io::{AsyncRead, AsyncWrite};
use tokio::net::UnixStream;
use crate::codec::{read_frame, write_frame};
use crate::identity::SessionCert;
use crate::messages::{Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, Ping, SessionId};
use crate::signature::{sign_hello, SignatureError};
/// Errores del cliente.
#[derive(Debug, Error)]
pub enum ClientError {
#[error("E/S: {0}")]
Io(#[from] std::io::Error),
/// El servidor respondió con un error explícito.
#[error("servidor: {0}")]
Server(#[source] HandshakeError),
/// El servidor envió un frame que no esperábamos en este punto del protocolo.
#[error("frame inesperado: {got}")]
UnexpectedFrame { got: &'static str },
/// La Card que el cliente intentó enviar no pasa su propia validación.
#[error("card inválida pre-envío: {0}")]
InvalidCard(String),
/// Firma del Hello falló al construirse (rara — sólo puede pasar
/// si la keypair pasada está en un estado inválido).
#[error("firma del Hello falló: {0}")]
Signature(#[from] SignatureError),
}
/// Cliente conectado y autenticado. Tras `connect` ya completó el handshake
/// y tiene su `SessionId`. Los `MatchEvent` recibidos durante operaciones
/// request/response se buferean en `pending_events` y se obtienen vía
/// [`Client::take_event`] o [`Client::await_event`].
///
/// Genérico sobre el transport (`AsyncRead + AsyncWrite + Unpin + Send`):
/// funciona indistintamente sobre `UnixStream` (path local) o sobre un
/// stream libp2p wrapped con `tokio_util::compat` (path remoto, vía
/// `brahman_handshake::network`).
#[derive(Debug)]
pub struct Client<S = UnixStream> {
stream: S,
session: SessionId,
server_info: HelloAck,
pending_events: VecDeque<MatchEvent>,
}
impl Client<UnixStream> {
/// Conecta como módulo agnóstico (sin WIT) sobre Unix socket.
/// Equivalente a `connect_with(path, card, None)`.
pub async fn connect(path: impl AsRef<Path>, card: Card) -> Result<Self, ClientError> {
Self::connect_with(path, card, None).await
}
/// Conecta al socket Unix enviando Hello con la Card dada y
/// opcionalmente una `WitInterface` ya extraída. Si `wit` es `Some`,
/// el server registra el módulo como "consciente".
pub async fn connect_with(
path: impl AsRef<Path>,
card: Card,
wit: Option<WitInterface>,
) -> Result<Self, ClientError> {
let stream = UnixStream::connect(path).await?;
Self::connect_with_stream(stream, card, wit).await
}
}
impl<S> Client<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send,
{
/// Constructor genérico sobre un stream ya abierto, **sin firma**.
/// Apto para path Unix (donde SO_PEERCRED del kernel ya autentica)
/// o tests in-memory. Para libp2p remoto usá
/// [`connect_with_stream_signed`](Self::connect_with_stream_signed) —
/// el server libp2p rechaza Hello sin firma.
pub async fn connect_with_stream(
stream: S,
card: Card,
wit: Option<WitInterface>,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, None, None).await
}
/// Igual que `connect_with_stream` pero firma el Hello con
/// `keypair`. Usar para conexiones libp2p donde el server exige
/// firma. La public key derivada de `keypair` debe coincidir con
/// el `peer_id` libp2p autenticado por Noise — típicamente la
/// keypair pasada a [`brahman_net::BrahmanNet::with_keypair`].
pub async fn connect_with_stream_signed(
stream: S,
card: Card,
wit: Option<WitInterface>,
keypair: &Keypair,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, Some(keypair), None).await
}
/// Igual que `connect_with_stream_signed` pero además adjunta un
/// `SessionCert` que vincula la session keypair a una identity
/// master estable. El server, al recibir el cert, evalúa la
/// política de admisión contra el `master_peer_id` (no contra
/// el session peer_id) — permitiendo rotar la session sin perder
/// la identidad reconocida en allowlists remotas.
pub async fn connect_with_stream_signed_with_cert(
stream: S,
card: Card,
wit: Option<WitInterface>,
session_keypair: &Keypair,
identity_cert: SessionCert,
) -> Result<Self, ClientError> {
Self::connect_inner(stream, card, wit, Some(session_keypair), Some(identity_cert)).await
}
async fn connect_inner(
mut stream: S,
card: Card,
wit: Option<WitInterface>,
keypair: Option<&Keypair>,
identity_cert: Option<SessionCert>,
) -> Result<Self, ClientError> {
card.validate()
.map_err(|e| ClientError::InvalidCard(e.to_string()))?;
let wire_card = brahman_card::WireCard::from(card);
let signature = match keypair {
Some(kp) => Some(sign_hello(kp, &wire_card, &wit)?),
None => None,
};
let hello = Hello {
schema_version: CARD_SCHEMA_VERSION,
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
card: wire_card,
wit,
signature,
identity_cert,
};
write_frame(&mut stream, &Frame::Hello(hello)).await?;
let frame = read_frame(&mut stream).await?;
let ack = match frame {
Frame::HelloAck(a) => a,
Frame::Error(e) => return Err(ClientError::Server(e)),
Frame::Hello(_) => return Err(ClientError::UnexpectedFrame { got: "Hello" }),
Frame::Ping(_) => return Err(ClientError::UnexpectedFrame { got: "Ping" }),
Frame::Pong(_) => return Err(ClientError::UnexpectedFrame { got: "Pong" }),
Frame::Farewell(_) => return Err(ClientError::UnexpectedFrame { got: "Farewell" }),
Frame::MatchEvent(_) => {
return Err(ClientError::UnexpectedFrame {
got: "MatchEvent (pre-handshake)",
});
}
Frame::ListSessions(_) => {
return Err(ClientError::UnexpectedFrame {
got: "ListSessions (pre-handshake)",
});
}
Frame::SessionList(_) => {
return Err(ClientError::UnexpectedFrame {
got: "SessionList (pre-handshake)",
});
}
Frame::ListMatches(_) => {
return Err(ClientError::UnexpectedFrame {
got: "ListMatches (pre-handshake)",
});
}
Frame::MatchList(_) => {
return Err(ClientError::UnexpectedFrame {
got: "MatchList (pre-handshake)",
});
}
};
Ok(Self {
stream,
session: ack.session,
server_info: ack,
pending_events: VecDeque::new(),
})
}
/// `SessionId` asignado por el servidor.
pub fn session(&self) -> SessionId {
self.session
}
/// Información del servidor recibida en el handshake.
pub fn server_info(&self) -> &HelloAck {
&self.server_info
}
/// Envía un Ping y devuelve el timestamp del servidor. Los frames
/// `MatchEvent` que lleguen mezclados se buferean en `pending_events`.
pub async fn ping(&mut self) -> Result<u64, ClientError> {
write_frame(
&mut self.stream,
&Frame::Ping(Ping {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::Pong(p) => return Ok(p.timestamp_ms),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => return Err(ClientError::UnexpectedFrame { got: "non-pong" }),
}
}
}
/// Saca un evento pendiente del buffer, sin bloquear ni leer del wire.
pub fn take_event(&mut self) -> Option<MatchEvent> {
self.pending_events.pop_front()
}
/// Espera un `MatchEvent` con timeout. Drena primero el buffer; si
/// está vacío, lee del wire hasta el timeout. Otros frames recibidos
/// (Pong huérfano, Error) cortan la espera con error.
pub async fn await_event(
&mut self,
timeout: Duration,
) -> Result<Option<MatchEvent>, ClientError> {
if let Some(ev) = self.pending_events.pop_front() {
return Ok(Some(ev));
}
match tokio::time::timeout(timeout, read_frame(&mut self.stream)).await {
Err(_) => Ok(None),
Ok(Err(e)) => Err(ClientError::Io(e)),
Ok(Ok(Frame::MatchEvent(ev))) => Ok(Some(ev)),
Ok(Ok(Frame::Error(e))) => Err(ClientError::Server(e)),
Ok(Ok(_)) => Err(ClientError::UnexpectedFrame {
got: "non-event en await_event",
}),
}
}
/// Pide al servidor el listado de sesiones activas. Pensado para
/// observadores (broker-explorer, CLIs de diagnóstico). Como
/// `ping`, los `MatchEvent` que lleguen intercalados se bufean
/// en `pending_events` y no rompen la respuesta.
pub async fn list_sessions(&mut self) -> Result<crate::messages::SessionList, ClientError> {
write_frame(
&mut self.stream,
&Frame::ListSessions(crate::messages::ListSessions {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::SessionList(list) => return Ok(list),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => {
return Err(ClientError::UnexpectedFrame {
got: "non-session-list",
});
}
}
}
}
/// Pide al servidor el listado de matches actuales del broker
/// (consumer↔producer pares con tipo y estrategia). Mismo patrón
/// de drenado de `MatchEvent`s intermedios.
pub async fn list_matches(&mut self) -> Result<crate::messages::MatchList, ClientError> {
write_frame(
&mut self.stream,
&Frame::ListMatches(crate::messages::ListMatches {
session: self.session,
}),
)
.await?;
loop {
match read_frame(&mut self.stream).await? {
Frame::MatchList(list) => return Ok(list),
Frame::MatchEvent(ev) => self.pending_events.push_back(ev),
Frame::Error(e) => return Err(ClientError::Server(e)),
_ => {
return Err(ClientError::UnexpectedFrame {
got: "non-match-list",
});
}
}
}
}
/// Cierre cooperativo. Consume el cliente.
pub async fn farewell(mut self) -> Result<(), ClientError> {
write_frame(
&mut self.stream,
&Frame::Farewell(Farewell {
session: self.session,
}),
)
.await?;
Ok(())
}
}
@@ -0,0 +1,72 @@
//! Codec de wire: frames length-prefixed con cuerpo postcard.
//!
//! Cada frame en el stream tiene la forma:
//! ```text
//! [4 bytes LE: longitud N] [N bytes: postcard(Frame)]
//! ```
//!
//! El `MAX_FRAME_BYTES` evita que un cliente malicioso/buggy reserve memoria
//! arbitraria al anunciar un length absurdo.
use std::io::{Error, ErrorKind, Result};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
use crate::messages::Frame;
/// Tamaño máximo de un frame antes de que el reader rechace la conexión.
/// 4 MiB cubre cualquier Card razonable con margen amplio.
pub const MAX_FRAME_BYTES: usize = 4 * 1024 * 1024;
/// Escribe un frame al stream.
pub async fn write_frame<W: AsyncWrite + Unpin>(w: &mut W, frame: &Frame) -> Result<()> {
let bytes = postcard::to_allocvec(frame)
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard encode: {e}")))?;
if bytes.len() > MAX_FRAME_BYTES {
return Err(Error::new(
ErrorKind::InvalidData,
format!("frame demasiado grande: {} bytes", bytes.len()),
));
}
let len = bytes.len() as u32;
w.write_all(&len.to_le_bytes()).await?;
w.write_all(&bytes).await?;
w.flush().await?;
Ok(())
}
/// Lee un frame del stream.
pub async fn read_frame<R: AsyncRead + Unpin>(r: &mut R) -> Result<Frame> {
let mut len_buf = [0u8; 4];
r.read_exact(&mut len_buf).await?;
let len = u32::from_le_bytes(len_buf) as usize;
if len > MAX_FRAME_BYTES {
return Err(Error::new(
ErrorKind::InvalidData,
format!("frame anunciado demasiado grande: {len} bytes"),
));
}
let mut buf = vec![0u8; len];
r.read_exact(&mut buf).await?;
postcard::from_bytes(&buf)
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("postcard decode: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::messages::{Frame, HandshakeError};
#[tokio::test]
async fn frame_roundtrip() {
let frame = Frame::Error(HandshakeError::Rejected("test".into()));
let mut buf = Vec::new();
write_frame(&mut buf, &frame).await.unwrap();
let mut cursor = std::io::Cursor::new(buf);
let decoded = read_frame(&mut cursor).await.unwrap();
match decoded {
Frame::Error(HandshakeError::Rejected(s)) => assert_eq!(s, "test"),
_ => panic!("variant mismatch"),
}
}
}
@@ -0,0 +1,358 @@
//! Identidad multi-key del nodo: separación entre **identity** (master,
//! persistente forever) y **session** (keypair libp2p efímera, rotable).
//!
//! ## Problema que resuelve
//!
//! Hasta Fase 3, el `peer_id` libp2p era la única identidad. Rotar la
//! keypair (por compromiso, por higiene, por cambio de hardware)
//! cambiaba el peer_id, lo que invalidaba todas las allowlists
//! remotas y desconectaba al nodo de la malla. Imposible rotar sin
//! coordinar.
//!
//! ## Modelo
//!
//! Cada nodo tiene **dos** keypairs Ed25519:
//!
//! - **Identity** (master): persistente para siempre. Identifica al
//! nodo como entidad lógica. Su `peer_id` es lo que va en
//! allowlists/denylists remotas.
//! - **Session** (operacional): la que libp2p usa para Noise. Puede
//! rotarse libremente sin coordinar — el nodo emite un
//! [`SessionCert`] firmado con la identity que prueba "esta session
//! key pertenece a mí".
//!
//! ## Wire
//!
//! El cert viaja en `Hello.identity_cert: Option<SessionCert>`. El
//! server valida:
//! 1. La session key del cert == public key de `Hello.signature` ==
//! deriva al peer_id autenticado por Noise (consistencia interna).
//! 2. La firma del cert verifica con la master pubkey declarada.
//! 3. El cert no está expirado.
//! 4. La política (allowlist/denylist) se evalúa contra
//! `master.to_peer_id()`, NO contra el session peer_id.
//!
//! Sin cert, el server cae al modelo de Fase 3: policy contra session
//! peer_id (compat). Esto permite migración gradual.
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use brahman_net::{Keypair, PeerId, PublicKey};
use serde::{Deserialize, Serialize};
/// TTL recomendado para un session cert: 24 horas. Suficiente para
/// que un nodo "viva" un día sin re-emitir; corto enough para que
/// un cert robado no sirva por mucho. Operadores con políticas
/// estrictas pueden bajarlo; con uptime largo, subirlo.
pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
/// Identidad lógica del nodo. Wraps la master keypair y emite certs
/// de session firmados.
///
/// **Critical**: la master keypair NUNCA debe filtrarse a la red.
/// Sólo se usa para firmar certs locales y para derivar
/// `master_peer_id`. Ni siquiera el swarm libp2p la ve — ese usa la
/// session keypair.
#[derive(Clone)]
pub struct Identity {
master: Arc<Keypair>,
}
impl Identity {
/// Construye una Identity a partir de una keypair existente.
/// Típicamente cargada desde disco vía `keypair_store::load_or_generate`.
pub fn from_keypair(master: Keypair) -> Self {
Self {
master: Arc::new(master),
}
}
/// Variante para callers que ya tienen la keypair en `Arc`.
pub fn from_arc(master: Arc<Keypair>) -> Self {
Self { master }
}
/// PeerId derivado de la master pubkey. Ésta es la identidad
/// "lógica" estable del nodo — lo que va en allowlists/denylists.
pub fn master_peer_id(&self) -> PeerId {
self.master.public().to_peer_id()
}
/// Emite un [`SessionCert`] firmado: certifica que la session
/// keypair `session` pertenece a esta identity hasta `now + ttl`.
pub fn issue_session_cert(
&self,
session: &Keypair,
ttl: Duration,
) -> Result<SessionCert, CertError> {
let now_ms = now_unix_ms();
let expires_at_ms = now_ms.saturating_add(ttl.as_millis() as u64);
let session_pubkey = session.public().encode_protobuf();
let master_pubkey = self.master.public().encode_protobuf();
let payload = sign_payload(&session_pubkey, expires_at_ms);
let signature = self
.master
.sign(&payload)
.map_err(|e| CertError::Sign(e.to_string()))?;
Ok(SessionCert {
version: SESSION_CERT_VERSION,
session_pubkey,
master_pubkey,
expires_at_ms,
signature,
})
}
}
impl std::fmt::Debug for Identity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Identity")
.field("master_peer_id", &self.master_peer_id())
.finish()
}
}
/// Versión del esquema del cert. Bump al cambiar `sign_payload` o
/// el shape de `SessionCert`.
pub const SESSION_CERT_VERSION: u8 = 1;
/// Certificado firmado por la identity que vincula una session key
/// libp2p a la identidad master del nodo, con expiración.
///
/// **Wire**: viaja en `Hello.identity_cert`. Las pubkeys van en
/// formato canónico libp2p (`encode_protobuf`) — mismo encoding que
/// `HelloSignature.public_key`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionCert {
/// Versión del esquema (ver `SESSION_CERT_VERSION`).
pub version: u8,
/// Public key de la session libp2p (la que firma el Hello), en
/// formato libp2p protobuf.
pub session_pubkey: Vec<u8>,
/// Public key de la master identity, en formato libp2p protobuf.
/// El verificador deriva el `master_peer_id` desde acá.
pub master_pubkey: Vec<u8>,
/// Expiración en milisegundos desde UNIX_EPOCH. Tras esto, el
/// cert no es válido y el nodo debe re-emitirse uno nuevo
/// (rotando o re-firmando la misma session).
pub expires_at_ms: u64,
/// Firma Ed25519 del master sobre `sign_payload(session_pubkey, expires_at_ms)`.
pub signature: Vec<u8>,
}
#[derive(Debug, thiserror::Error)]
pub enum CertError {
#[error("versión de cert desconocida: {0} (esperaba {SESSION_CERT_VERSION})")]
UnknownVersion(u8),
#[error("decode master_pubkey: {0}")]
DecodeMaster(String),
#[error("decode session_pubkey: {0}")]
DecodeSession(String),
#[error("firma del cert inválida")]
InvalidSignature,
#[error("cert expirado: expires_at_ms={expires}, now_ms={now}")]
Expired { expires: u64, now: u64 },
#[error("session_pubkey del cert no coincide con la del Hello.signature")]
SessionMismatch,
#[error("error al firmar: {0}")]
Sign(String),
}
impl SessionCert {
/// Verifica el cert: versión, firma criptográfica, no expiración.
/// Devuelve el `(master_peer_id, session_peer_id)` derivados.
///
/// El caller debe además chequear que `session_peer_id` coincide
/// con el peer_id autenticado por Noise (lo verifica
/// [`verify_against_session`]).
pub fn verify(&self) -> Result<(PeerId, PeerId), CertError> {
if self.version != SESSION_CERT_VERSION {
return Err(CertError::UnknownVersion(self.version));
}
let master_pk = PublicKey::try_decode_protobuf(&self.master_pubkey)
.map_err(|e| CertError::DecodeMaster(e.to_string()))?;
let session_pk = PublicKey::try_decode_protobuf(&self.session_pubkey)
.map_err(|e| CertError::DecodeSession(e.to_string()))?;
let payload = sign_payload(&self.session_pubkey, self.expires_at_ms);
if !master_pk.verify(&payload, &self.signature) {
return Err(CertError::InvalidSignature);
}
let now = now_unix_ms();
if now >= self.expires_at_ms {
return Err(CertError::Expired {
expires: self.expires_at_ms,
now,
});
}
Ok((master_pk.to_peer_id(), session_pk.to_peer_id()))
}
/// Verifica el cert Y exige que su `session_pubkey` matchee a
/// `expected_session_pubkey` (la que firmó el Hello). Esto
/// previene que un atacante reutilice un cert válido con una
/// session key distinta.
///
/// Devuelve el `master_peer_id` derivado, que es el que el server
/// debe usar para evaluar la política de admisión.
pub fn verify_against_session(
&self,
expected_session_pubkey: &[u8],
) -> Result<PeerId, CertError> {
if self.session_pubkey.as_slice() != expected_session_pubkey {
return Err(CertError::SessionMismatch);
}
let (master_peer, _session_peer) = self.verify()?;
Ok(master_peer)
}
}
/// Concat canónico de los campos firmados. Cualquier cambio aquí
/// rompe compatibilidad — bump `SESSION_CERT_VERSION`.
fn sign_payload(session_pubkey: &[u8], expires_at_ms: u64) -> Vec<u8> {
let mut buf = Vec::with_capacity(1 + 4 + session_pubkey.len() + 8);
buf.push(SESSION_CERT_VERSION);
buf.extend_from_slice(b"sess");
buf.extend_from_slice(&(session_pubkey.len() as u32).to_le_bytes());
buf.extend_from_slice(session_pubkey);
buf.extend_from_slice(&expires_at_ms.to_le_bytes());
buf
}
fn now_unix_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_and_verify_cert() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
let (master_peer, session_peer) = cert.verify().unwrap();
assert_eq!(master_peer, id.master_peer_id());
assert_eq!(session_peer, session.public().to_peer_id());
}
#[test]
fn verify_against_session_admits_matching() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
let session_pk = session.public().encode_protobuf();
let master_peer = cert.verify_against_session(&session_pk).unwrap();
assert_eq!(master_peer, id.master_peer_id());
}
#[test]
fn verify_against_session_rejects_mismatch() {
let master = Keypair::generate_ed25519();
let session_a = Keypair::generate_ed25519();
let session_b = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session_a, DEFAULT_SESSION_TTL)
.unwrap();
let other_pk = session_b.public().encode_protobuf();
let err = cert.verify_against_session(&other_pk).unwrap_err();
assert!(matches!(err, CertError::SessionMismatch), "got {err:?}");
}
#[test]
fn cert_with_zero_ttl_is_expired() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let cert = id
.issue_session_cert(&session, Duration::from_secs(0))
.unwrap();
// Pequeña espera para asegurar que now_ms > expires_at_ms.
std::thread::sleep(Duration::from_millis(5));
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::Expired { .. }), "got {err:?}");
}
#[test]
fn tampered_signature_rejected() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
if let Some(b) = cert.signature.last_mut() {
*b ^= 0x01;
}
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
}
#[test]
fn tampered_expires_at_rejected() {
// Si alguien extiende el expires_at sin re-firmar, la firma
// no cuadra → InvalidSignature.
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
cert.expires_at_ms = cert.expires_at_ms.saturating_add(1_000_000);
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::InvalidSignature), "got {err:?}");
}
#[test]
fn unknown_version_rejected() {
let master = Keypair::generate_ed25519();
let session = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let mut cert = id
.issue_session_cert(&session, DEFAULT_SESSION_TTL)
.unwrap();
cert.version = 99;
let err = cert.verify().unwrap_err();
assert!(matches!(err, CertError::UnknownVersion(99)), "got {err:?}");
}
#[test]
fn rotated_session_with_same_master_yields_same_master_peer_id() {
// La propiedad fundamental: rotar la session key NO cambia el
// master_peer_id derivado del cert.
let master = Keypair::generate_ed25519();
let id = Identity::from_keypair(master);
let original_master_peer = id.master_peer_id();
let session1 = Keypair::generate_ed25519();
let cert1 = id
.issue_session_cert(&session1, DEFAULT_SESSION_TTL)
.unwrap();
let (master_from_cert1, _) = cert1.verify().unwrap();
// Rotar: nueva session keypair, mismo master.
let session2 = Keypair::generate_ed25519();
let cert2 = id
.issue_session_cert(&session2, DEFAULT_SESSION_TTL)
.unwrap();
let (master_from_cert2, _) = cert2.verify().unwrap();
assert_eq!(master_from_cert1, original_master_peer);
assert_eq!(master_from_cert2, original_master_peer);
assert_eq!(
master_from_cert1, master_from_cert2,
"rotar session NO debe cambiar el master_peer_id"
);
}
}
@@ -0,0 +1,33 @@
//! `brahman-handshake` — protocolo runtime Init↔módulo sobre Unix socket.
//!
//! Implementa la versión concreta de `shared_wit/protocol.wit` (handshake +
//! lifecycle): un servidor que vive en el Init (o un Admin proxy) y clientes
//! que son los módulos Brahman. Cada conexión arranca con un `Hello` que
//! lleva una [`brahman_card::Card`]; el servidor valida la Card, deriva el
//! [`TrustLevel`], emite un `HelloAck` con `session-id` ULID, y a partir de
//! ahí acepta `Ping`/`Farewell`.
//!
//! Wire format: frames length-prefixed (4 bytes LE) con cuerpo
//! [`postcard`]-codificado. Compacto, rápido y reversible.
//!
//! Esto NO es la implementación WIT/WASM (que generaría wit-bindgen). Es la
//! implementación nativa Rust↔Rust que cubre el caso común antes de que los
//! módulos WASM consuman el mismo contrato vía ABI generada.
#![forbid(unsafe_code)]
#![warn(rust_2018_idioms)]
pub mod codec;
pub mod identity;
pub mod messages;
pub mod server;
pub mod client;
pub mod network;
pub mod peer_policy;
pub mod signature;
pub mod transport;
pub use brahman_card::PROTOCOL_VERSION;
/// Versión del crate de handshake (independiente de `PROTOCOL_VERSION`).
pub const HANDSHAKE_VERSION: &str = env!("CARGO_PKG_VERSION");
@@ -0,0 +1,234 @@
//! Mensajes del protocolo de handshake.
//!
//! Todos los mensajes que cruzan el wire son variantes de [`Frame`].
use std::path::PathBuf;
use brahman_broker::MatchStrategy;
use brahman_card::{TypeRef, WireCard, WitInterface};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
/// Identificador de sesión emitido por el servidor en `HelloAck`.
pub type SessionId = Ulid;
/// Saludo inicial del módulo. Lleva la Card en forma `WireCard`
/// (postcard-friendly: sin extensiones JSON arbitrarias). El servidor
/// la convierte a `Card` para uso interno. Opcionalmente, una
/// `WitInterface` ya extraída — si está presente, el módulo es
/// "consciente" y el server lo registra como `ResolvedCard::from_conscious`.
///
/// **Firma (Fase 3, trust remoto)**: el campo `signature` es
/// obligatorio para conexiones libp2p (donde el server exige que la
/// public key derive al `peer_id` autenticado por Noise) y opcional
/// para Unix socket (donde SO_PEERCRED del kernel ya provee
/// autenticación). La firma cubre los bytes postcard de
/// `(WireCard, Option<WitInterface>)` — ver
/// [`HelloSignature::sign_payload`].
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hello {
/// Versión del schema de Card que el cliente sigue.
pub schema_version: u16,
/// Versión del protocolo handshake del cliente.
pub protocol_version: String,
/// Tarjeta de Presentación, proyectada al wire.
pub card: WireCard,
/// Interfaz WIT extraída por el cliente (típicamente con
/// `brahman-card-wit`). `None` si el módulo es agnóstico.
#[serde(default)]
pub wit: Option<WitInterface>,
/// Firma Ed25519 sobre `(card, wit)`. Requerida para conexiones
/// remotas (libp2p); opcional para Unix socket. Ver módulo
/// [`super::signature`] para construcción y verificación.
#[serde(default)]
pub signature: Option<HelloSignature>,
/// Cert opcional que vincula la session keypair (la que firma el
/// Hello) a una **identity master** estable. Si está presente,
/// la política de admisión se evalúa contra el `master_peer_id`
/// derivado del cert — no contra el session peer_id. Esto permite
/// rotar la session sin invalidar las allowlists remotas.
///
/// Ver [`super::identity::SessionCert`] para shape y semantics.
/// Si es `None`, fallback al modelo de Fase 3: la política
/// evalúa el session peer_id directamente.
#[serde(default)]
pub identity_cert: Option<crate::identity::SessionCert>,
}
/// Firma de un Hello. La `public_key` viaja en el formato canónico
/// libp2p (protobuf) — el verificador la decodifica y compara su
/// `peer_id` derivado con la identidad libp2p autenticada por Noise.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct HelloSignature {
/// Public key del firmante, encoded como `libp2p::identity::PublicKey::encode_protobuf()`.
pub public_key: Vec<u8>,
/// Bytes de la firma Ed25519 sobre el payload canonical.
pub signature: Vec<u8>,
}
/// Respuesta del servidor a un `Hello` aceptado.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelloAck {
/// Versión del crate del servidor.
pub server_version: String,
/// Versión del protocolo soportada por el servidor.
pub protocol_version: String,
/// Identificador de sesión asignado.
pub session: SessionId,
/// `true` si el Init está vinculado al servidor; `false` si el servidor
/// corre standalone (modo degradado).
pub init_attached: bool,
}
/// Latido del cliente. El servidor responde con [`Pong`] llevando su reloj.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ping {
pub session: SessionId,
}
/// Respuesta a un `Ping` con timestamp del servidor (ms desde UNIX_EPOCH).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pong {
pub timestamp_ms: u64,
}
/// Cierre cooperativo de la sesión por parte del cliente.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Farewell {
pub session: SessionId,
}
/// Errores del protocolo emitidos por el servidor.
#[derive(Debug, Clone, Serialize, Deserialize, thiserror::Error)]
pub enum HandshakeError {
#[error("protocolo incompatible: {0}")]
ProtocolMismatch(String),
#[error("card inválida: {0}")]
InvalidCard(String),
#[error("schema de card incompatible: cliente={client}, servidor={server}")]
SchemaMismatch { client: u16, server: u16 },
#[error("sin autorización: {0}")]
Unauthorized(String),
#[error("capacidad requerida no satisfecha: {0}")]
CapabilityUnmet(String),
#[error("rechazado: {0}")]
Rejected(String),
#[error("error interno: {0}")]
Internal(String),
}
/// Notificación push del server al consumer: un match disponible o perdido.
///
/// El server emite `Available` cuando un productor empieza a satisfacer un
/// `flow.input` del consumer (ya sea porque el productor acaba de
/// registrarse, o porque cambió el match anterior). Emite `Lost` cuando
/// el productor previo dejó de satisfacer el input (desregistro o
/// cambio de match).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchEvent {
pub kind: MatchEventKind,
/// Nombre del input del consumer al que aplica el evento.
pub consumer_flow: String,
/// Sesión y label del productor (en `Lost` puede ser nil/vacío).
pub producer_session: SessionId,
pub producer_label: String,
pub producer_flow: String,
/// Tipo del flujo matcheado.
pub ty: TypeRef,
/// Estrategia que ganó (relevante en `Available`).
pub via: MatchStrategy,
/// `true` si fue resuelto por `pin_to`.
pub pinned: bool,
/// Socket de servicio (data plane) que declaró el productor.
/// Si está presente, el consumer puede conectar directo sin
/// pasar por discovery adicional. `None` si el productor no
/// declaró service_socket en su Card.
#[serde(default)]
pub producer_service_socket: Option<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MatchEventKind {
Available,
Lost,
}
/// Pedido de listado de sesiones activas registradas en el broker. La
/// `session` es el id propio del que pregunta — el server lo valida
/// contra la sesión actual de la conexión, mismo patrón que `Ping`.
///
/// Pensado para herramientas de observabilidad (broker-explorer y
/// CLIs de diagnóstico). No expone secrets: sólo metadata pública
/// que el módulo ya anunció en su `Hello`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListSessions {
pub session: SessionId,
}
/// Una entrada en la respuesta a `ListSessions`. Slim por diseño —
/// el observer arma la UI con esto sin tener que abrir conexiones
/// adicionales por sesión.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionEntry {
pub session: SessionId,
/// Label declarado en `WireCard.label` — el "nombre humano" del
/// módulo.
pub label: String,
/// Versión del schema de Card que el módulo declaró.
pub schema_version: u16,
/// Nombres de los `flow.output` que la Card declara producir.
pub outputs: Vec<String>,
/// Nombres de los `flow.input` que la Card declara consumir.
pub inputs: Vec<String>,
/// `true` si el módulo se anunció como "consciente" (trajo
/// `WitInterface` extraída en el Hello).
pub conscious: bool,
}
/// Respuesta a `ListSessions`. El orden no está garantizado — los
/// clientes que necesiten estabilidad pueden ordenar por `session`
/// (Ulid es ordenable temporal).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionList {
pub entries: Vec<SessionEntry>,
}
/// Pedido del listado de matches actuales del broker. La `session`
/// se valida igual que `ListSessions`. Si el server no tiene broker
/// configurado, devuelve la lista vacía (no es un error — refleja
/// que no hay matching activo).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListMatches {
pub session: SessionId,
}
/// Respuesta a `ListMatches` con el snapshot de matches consumidor↔productor
/// actualmente computados por el broker.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MatchList {
pub matches: Vec<brahman_broker::Match>,
}
/// Frame único de wire — discriminada por variante. Cada conexión es un
/// stream de frames.
///
/// Direcciones:
/// - Cliente → Server: `Hello`, `Ping`, `Farewell`, `ListSessions`,
/// `ListMatches`.
/// - Server → Cliente: `HelloAck`, `Pong`, `Error`, `MatchEvent`,
/// `SessionList`, `MatchList`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Frame {
Hello(Hello),
HelloAck(HelloAck),
Ping(Ping),
Pong(Pong),
Farewell(Farewell),
Error(HandshakeError),
MatchEvent(MatchEvent),
ListSessions(ListSessions),
SessionList(SessionList),
ListMatches(ListMatches),
MatchList(MatchList),
}
@@ -0,0 +1,289 @@
//! Backend libp2p del handshake brahman: el mismo protocolo (Hello /
//! HelloAck / Ping / Pong / MatchEvent / Farewell, frames postcard
//! length-prefixed) ahora también viaja sobre streams libp2p de la
//! malla `brahman-net`.
//!
//! El servidor expone el bucle [`run_libp2p_accept_loop`] que acepta
//! streams del protocolo `BRAHMAN_HANDSHAKE_PROTOCOL` y los delega al
//! mismo `Server` que ya escucha por Unix socket — la `Session` es
//! genérica sobre el transporte, así que ambas vías comparten broker,
//! tablas de sesiones, push de MatchEvents, todo.
//!
//! El cliente se conecta vía [`connect_libp2p`]: abre un stream
//! libp2p hacia un `PeerId` ya conocido y arranca el handshake como
//! cualquier `Client`.
//!
//! Identidad: cada nodo libp2p tiene su `PeerId` (ed25519 derivado).
//! La identidad del Ente (Card.id ULID + futura firma) viaja en el
//! Hello, como en el path Unix. Trust remoto (verificación de firma
//! antes de aceptar el Hello) es Fase 3.
//!
//! Ejemplo (servidor — Arje):
//! ```ignore
//! let server = Arc::new(Server::bind("/run/brahman-init.sock", config)?);
//! let net = Arc::new(BrahmanNet::new()?);
//! net.listen("/ip4/0.0.0.0/tcp/4101".parse()?).await;
//!
//! tokio::spawn(brahman_handshake::network::run_libp2p_accept_loop(
//! server.clone(),
//! net.clone(),
//! ));
//! // Server::run sigue escuchando Unix en paralelo.
//! ```
//!
//! Ejemplo (cliente — sidecar de un Ente remoto):
//! ```ignore
//! let net = BrahmanNet::new()?;
//! net.dial(remote_multiaddr);
//! let mut client = brahman_handshake::network::connect_libp2p(
//! &net, peer_id, my_card, None,
//! ).await?;
//! client.ping().await?;
//! ```
use std::sync::Arc;
use brahman_card::{Card, TypeRef, WitInterface};
use brahman_net::{BrahmanNet, Keypair, OpenStreamError, PeerId, Stream, StreamProtocol};
use crate::identity::SessionCert;
use futures::StreamExt;
use tokio_util::compat::{Compat, FuturesAsyncReadCompatExt};
use tracing::{debug, warn};
use crate::client::{Client, ClientError};
use crate::server::Server;
/// Sub-protocolo del handshake brahman sobre streams libp2p.
pub const BRAHMAN_HANDSHAKE_PROTOCOL: StreamProtocol =
StreamProtocol::new("/brahman/handshake/1.0.0");
/// Tipo del stream que ve la lógica del handshake una vez convertido
/// del mundo `futures::AsyncRead/Write` (libp2p) al mundo
/// `tokio::io::AsyncRead/Write` (resto del crate).
pub type LibP2pHandshakeStream = Compat<Stream>;
/// Errores específicos del backend libp2p.
#[derive(Debug, thiserror::Error)]
pub enum NetworkError {
#[error("abrir stream libp2p: {0}")]
OpenStream(#[from] OpenStreamError),
#[error("handshake: {0}")]
Handshake(#[from] ClientError),
#[error("aceptar stream libp2p: {0}")]
AcceptStream(String),
}
/// Loop de aceptación de streams libp2p del protocolo handshake.
/// Cada stream entrante se construye como `Session` reutilizando las
/// tablas compartidas del `Server`, así que conviven con sesiones
/// Unix indistinguibles.
///
/// Vive hasta que el control libp2p se cierre o el caller drop el
/// future. Errores por sesión se loggean (no tumban el loop).
pub async fn run_libp2p_accept_loop(
server: Arc<Server>,
net: Arc<BrahmanNet>,
) -> Result<(), NetworkError> {
let mut control = net.control.clone();
let mut incoming = control
.accept(BRAHMAN_HANDSHAKE_PROTOCOL)
.map_err(|e| NetworkError::AcceptStream(e.to_string()))?;
while let Some((peer, stream)) = incoming.next().await {
let server = server.clone();
// .compat() debe pasar al spawn ADENTRO; si lo hacemos afuera
// y capturamos `Compat<Stream>` en la closure, el future
// resultante hereda traits que dyn AsyncReadWrite no satisface
// (compatibility con thread-safety de tokio::spawn).
tokio::spawn(handle_libp2p_session(server, stream, peer));
}
Ok(())
}
async fn handle_libp2p_session(
server: Arc<Server>,
stream: Stream,
peer: PeerId,
) {
// session_from_libp2p_stream propaga el peer_id al `do_handshake`,
// que exige firma del Hello cuya public key derive a este peer.
let session = server.session_from_libp2p_stream(stream.compat(), peer);
if let Err(e) = session.handle().await {
warn!(
target: "brahman_handshake::network",
peer = %peer,
error = %e,
"sesión libp2p terminó con error"
);
}
}
/// Conecta como cliente a un Ente remoto vía libp2p y completa el
/// handshake **firmado** con `keypair`. Requiere que `net` ya tenga
/// conexión (o pueda dial-ar) al `peer`; típicamente el caller hace
/// `net.dial(multiaddr)` antes.
///
/// La `keypair` debe ser la misma que la del nodo libp2p (la que
/// pasaste a [`brahman_net::BrahmanNet::with_keypair`]). Si no coincide
/// con el `peer_id` autenticado por Noise, el server rechaza el Hello
/// con `Unauthorized`.
///
/// Devuelve un `Client` típico — los métodos `ping`, `await_event`,
/// `farewell` funcionan idéntico al path Unix. El stream subyacente
/// es libp2p convertido vía `tokio_util::compat`.
pub async fn connect_libp2p(
net: &BrahmanNet,
peer: PeerId,
card: Card,
wit: Option<WitInterface>,
keypair: &Keypair,
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
let mut control = net.control.clone();
let stream = control
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
.await?;
let client = Client::connect_with_stream_signed(stream.compat(), card, wit, keypair).await?;
Ok(client)
}
/// Igual que `connect_libp2p` pero adjunta un `SessionCert` al Hello.
/// El server, al verificar el cert, evalúa la política de admisión
/// contra el `master_peer_id` derivado — no contra el `peer_id`
/// libp2p. Esto permite **rotar** la session keypair sin perder
/// reconocimiento en allowlists remotas.
///
/// El `keypair` debe ser la session libp2p (la que firma la conexión
/// Noise); el `cert` debe haber sido emitido por una identity master
/// para esa misma session pubkey (ver
/// [`crate::identity::Identity::issue_session_cert`]).
pub async fn connect_libp2p_with_cert(
net: &BrahmanNet,
peer: PeerId,
card: Card,
wit: Option<WitInterface>,
session_keypair: &Keypair,
cert: SessionCert,
) -> Result<Client<LibP2pHandshakeStream>, NetworkError> {
let mut control = net.control.clone();
let stream = control
.open_stream(peer, BRAHMAN_HANDSHAKE_PROTOCOL)
.await?;
let client = Client::connect_with_stream_signed_with_cert(
stream.compat(),
card,
wit,
session_keypair,
cert,
)
.await?;
Ok(client)
}
// =====================================================================
// Discovery remoto via DHT — Fase 2
// =====================================================================
//
// Cuando un Ente registra una Card con outputs en el Init local, el
// Init anuncia al DHT (`net.start_providing(key)`) bajo una key
// derivada de `(flow_name, TypeRef)`. Cualquier nodo conectado al
// mismo DHT puede consultar `find_remote_providers(flow_name, type)`
// y obtener la lista de `PeerId`s que dijeron proveer ese flow.
//
// La key es **estable y libre de colisiones** entre versiones del
// monorepo: usa blake3 sobre un canon textual `brahman-flow|{name}|{type_canon}`.
// Cambiar la canonicalización rompe el discovery cross-version, así
// que cualquier modificación requiere bump de versión documentado.
/// Prefijo de namespace para todas las keys DHT del subprotocolo
/// brahman. Discrimina contra otros usos del mismo DHT (sync minga,
/// futuros) — protege contra colisiones accidentales.
const FLOW_KEY_PREFIX: &str = "brahman-flow|v1|";
/// Canonicaliza un `TypeRef` a string estable. Cambios aquí rompen
/// la compatibilidad de discovery cross-version; bump documentado
/// en `FLOW_KEY_PREFIX` al modificar.
fn canonicalize_type(t: &TypeRef) -> String {
match t {
TypeRef::Primitive { name } => format!("prim:{}", name),
TypeRef::Wit {
package,
interface,
name,
} => format!(
"wit:{}#{}#{}",
package,
interface.as_deref().unwrap_or(""),
name
),
}
}
/// Deriva la key del DHT para un `(flow_name, type_ref)` específico.
/// blake3-32B determinístico — la misma tupla en cualquier máquina
/// produce la misma key.
pub fn flow_dht_key(flow_name: &str, type_ref: &TypeRef) -> [u8; 32] {
let canon = format!(
"{}{}|{}",
FLOW_KEY_PREFIX,
flow_name,
canonicalize_type(type_ref)
);
*blake3::hash(canon.as_bytes()).as_bytes()
}
/// Anuncia al DHT que este nodo provee cada output flow declarado
/// en `card`. Llamarlo tras `register_session` propaga la
/// disponibilidad a todos los peers que comparten DHT con éste.
///
/// Idempotente: re-anunciar la misma key actualiza el TTL del record
/// en el DHT. Best-effort: si `start_providing` falla por falta de
/// peers cercanos (DHT vacío), el record vive en el store local
/// hasta que llegue una conexión.
pub fn announce_outputs(net: &BrahmanNet, card: &Card) {
for flow in &card.flow.output {
let key = flow_dht_key(&flow.name, &flow.ty);
debug!(
target: "brahman_handshake::network",
flow = %flow.name,
"announce_output → DHT"
);
net.start_providing(&key);
}
}
/// Retira los anuncios DHT previos de [`announce_outputs`] para esta
/// `card`. Llamado desde `cleanup` cuando una sesión cierra (Farewell,
/// EOF, error). El record local se borra al instante; copias
/// replicadas en peers remotos expiran por TTL natural de kad.
pub fn withdraw_outputs(net: &BrahmanNet, card: &Card) {
for flow in &card.flow.output {
let key = flow_dht_key(&flow.name, &flow.ty);
debug!(
target: "brahman_handshake::network",
flow = %flow.name,
"withdraw_output → DHT (stop_providing)"
);
net.stop_providing(&key);
}
}
/// Consulta el DHT por peers que han anunciado proveer el flow
/// `(flow_name, type_ref)`. Devuelve la lista resuelta de `PeerId`s.
/// Lista vacía si nadie anuncia, si la query timeout-ea, o si el
/// DHT no ha encontrado providers.
///
/// Para cada `PeerId` devuelto, el caller puede luego dial-ar al
/// peer (a sus addrs conocidas vía Identify) y abrir un sub-handshake
/// remoto con [`connect_libp2p`].
pub async fn find_remote_providers(
net: &BrahmanNet,
flow_name: &str,
type_ref: &TypeRef,
) -> Vec<PeerId> {
let key = flow_dht_key(flow_name, type_ref);
net.find_providers(&key).await
}
@@ -0,0 +1,582 @@
//! 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::{BrahmanNet, 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>,
/// `BrahmanNet` opcional asociado vía [`Self::attach_to_net`].
/// Si está set, cada cambio en la denylist se sincroniza con el
/// `block_list` behaviour del swarm — los peers baneados son
/// rechazados ANTES del Noise handshake. `RwLock<Option<...>>`
/// para que `attach_to_net` se pueda llamar después del
/// constructor (típico en ente-zero: primero arma la policy,
/// después el net, después attach).
net: Arc<RwLock<Option<Arc<BrahmanNet>>>>,
}
#[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()),
net: Arc::new(RwLock::new(None)),
}
}
/// 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()),
net: Arc::new(RwLock::new(None)),
}
}
/// 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),
}),
net: Arc::new(RwLock::new(None)),
})
}
/// 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.
///
/// Si hay un `BrahmanNet` attached vía [`Self::attach_to_net`],
/// el cambio de denylist se sincroniza con el `block_list` del
/// swarm: se calcula el diff (added/removed) y se aplican
/// `block_peer`/`unblock_peer` por cada cambio.
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(),
};
// Snapshot de la deny actual ANTES de mutar, para diff.
let prev_deny = self
.inner
.read()
.map(|g| g.deny.clone())
.unwrap_or_default();
if let Ok(mut inner) = self.inner.write() {
inner.allow = new_allow;
inner.deny = new_deny.clone();
}
self.sync_deny_to_swarm(&prev_deny, &new_deny);
Ok(())
}
/// Asocia esta política a un `BrahmanNet`. Sincroniza el snapshot
/// actual de la denylist con el `block_list` behaviour del swarm
/// (cada peer baneado se rechaza ANTES del Noise handshake), y
/// registra el net para re-sincronizarse en cada [`Self::reload`].
///
/// Si ya había un net attached, se reemplaza (caso esperado:
/// un Init no debería tener dos `BrahmanNet`s).
pub fn attach_to_net(&self, net: Arc<BrahmanNet>) {
// Sync inicial: bloquear todos los peers actualmente en deny.
if let Ok(inner) = self.inner.read() {
for peer in &inner.deny {
net.block_peer(*peer);
}
}
if let Ok(mut slot) = self.net.write() {
*slot = Some(net);
}
}
/// Calcula el diff entre `prev` y `new` y aplica
/// `block_peer`/`unblock_peer` al net asociado (si hay).
/// No-op si no hay net attached.
fn sync_deny_to_swarm(&self, prev: &BTreeSet<PeerId>, new: &BTreeSet<PeerId>) {
let net = match self.net.read() {
Ok(g) => match g.as_ref() {
Some(n) => n.clone(),
None => return,
},
Err(_) => return,
};
for added in new.difference(prev) {
net.block_peer(*added);
}
for removed in prev.difference(new) {
net.unblock_peer(*removed);
}
}
/// 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"
);
}
}
@@ -0,0 +1,855 @@
//! Servidor de handshake. Listener Unix socket → sesiones por conexión.
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use brahman_broker::{Broker, Endpoint};
use brahman_card::{Card, ResolvedCard, WitInterface, CARD_SCHEMA_VERSION};
use brahman_net::{BrahmanNet, PeerId};
use tokio::io::{split, AsyncRead, AsyncWrite, WriteHalf};
use tokio::net::{UnixListener, UnixStream};
use tokio::sync::{mpsc, Mutex};
use tracing::{debug, warn};
use ulid::Ulid;
use crate::codec::{read_frame, write_frame};
use crate::messages::{
Farewell, Frame, HandshakeError, Hello, HelloAck, MatchEvent, MatchEventKind, Ping, Pong,
SessionId,
};
/// Tabla de sesiones vivas indexada por `SessionId`.
pub type SessionRegistry = Arc<Mutex<HashMap<SessionId, ResolvedCard>>>;
/// Broker compartido (opcional) que el servidor mantiene en sincronía con
/// el ciclo de vida de las sesiones.
pub type SharedBroker = Arc<Mutex<Broker>>;
/// Tabla de canales push por sesión: el server inyecta frames hacia el
/// cliente (p. ej. `MatchEvent`) sin requerir que el cliente haga request.
type SessionTxTable = Arc<Mutex<HashMap<SessionId, mpsc::Sender<Frame>>>>;
/// Por sesión, último match conocido por nombre de input. Se usa para
/// emitir diffs (Available/Lost) en lugar del estado completo.
type LastMatches = Arc<Mutex<HashMap<SessionId, HashMap<String, Endpoint>>>>;
/// Capacidad del canal push por sesión. Si se llena (cliente lento), los
/// eventos extra se descartan — el cliente puede re-consultar el estado.
const PUSH_CHANNEL_CAPACITY: usize = 32;
/// Configuración del servidor.
#[derive(Clone, Default)]
pub struct ServerConfig {
/// `true` si el Init está atado al servidor (se reporta en `HelloAck`).
pub init_attached: bool,
/// Broker compartido. Si está presente, el servidor llama
/// `register` tras un Hello aceptado y `unregister` al cerrar la
/// sesión (Farewell o EOF). Si es `None`, el broker no se usa.
pub broker: Option<SharedBroker>,
/// Capa P2P compartida. Si está presente, cada Card registrada
/// con outputs se anuncia automáticamente al DHT vía
/// [`brahman_handshake::network::announce_outputs`], permitiendo
/// que un consumer remoto los descubra con
/// [`brahman_handshake::network::find_remote_providers`]. Si es
/// `None`, el server queda "ciego al DHT" — sólo matchea sesiones
/// 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 (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
// no es Debug). Sólo loggeamos los campos relevantes para tracing.
impl std::fmt::Debug for ServerConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ServerConfig")
.field("init_attached", &self.init_attached)
.field("broker", &self.broker.as_ref().map(|_| "<broker>"))
.field("net", &self.net.as_ref().map(|_| "<net>"))
.field("policy", &self.policy.as_ref().map(|p| p.sizes()))
.finish()
}
}
/// Servidor de handshake escuchando en un Unix socket.
pub struct Server {
listener: UnixListener,
socket_path: PathBuf,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
}
impl Server {
/// Crea el listener en `path`. Si el archivo existe, lo elimina (asume
/// que es un socket stale de una sesión previa).
pub fn bind(path: impl Into<PathBuf>, config: ServerConfig) -> std::io::Result<Self> {
let socket_path = path.into();
if socket_path.exists() {
std::fs::remove_file(&socket_path)?;
}
if let Some(parent) = socket_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let listener = UnixListener::bind(&socket_path)?;
Ok(Self {
listener,
socket_path,
sessions: Arc::new(Mutex::new(HashMap::new())),
push_table: Arc::new(Mutex::new(HashMap::new())),
last_matches: Arc::new(Mutex::new(HashMap::new())),
config,
})
}
/// Devuelve la ruta del socket (útil para clientes en el mismo proceso).
pub fn socket_path(&self) -> &Path {
&self.socket_path
}
/// Vista compartida del registro de sesiones — útil para el Init/Admin
/// para inspeccionar quién está conectado.
pub fn sessions(&self) -> SessionRegistry {
self.sessions.clone()
}
/// Acepta UNA conexión Unix, devuelve la `Session` lista para `handle()`.
/// No corre el handler — eso es responsabilidad del llamante.
/// Path Unix → `expected_peer = None` (firma del Hello opcional;
/// SO_PEERCRED del kernel ya autentica al cliente).
pub async fn accept_one(&self) -> std::io::Result<Session<UnixStream>> {
let (stream, _addr) = self.listener.accept().await?;
Ok(self.session_from_stream(stream))
}
/// Construye una `Session` a partir de un stream arbitrario que
/// implemente `AsyncRead + AsyncWrite + Unpin + Send`. Path
/// agnóstico al transport (Unix, in-memory, etc.) — `expected_peer`
/// queda en `None`, así que la firma del Hello es opcional.
pub fn session_from_stream<S>(&self, stream: S) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
self.session_from_stream_inner(stream, None)
}
/// Variante para conexiones libp2p: el `peer_id` viene autenticado
/// por Noise. La sesión exige firma del Hello cuya public key
/// derive a este `peer_id` exacto. Ver
/// [`super::network::run_libp2p_accept_loop`].
pub fn session_from_libp2p_stream<S>(
&self,
stream: S,
peer: PeerId,
) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
self.session_from_stream_inner(stream, Some(peer))
}
fn session_from_stream_inner<S>(
&self,
stream: S,
expected_peer: Option<PeerId>,
) -> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
Session {
stream,
sessions: self.sessions.clone(),
push_table: self.push_table.clone(),
last_matches: self.last_matches.clone(),
config: self.config.clone(),
expected_peer,
}
}
/// Loop de aceptación: cada conexión se despacha en una task separada.
/// Vive hasta que el listener falle o el caller drop el future.
pub async fn run(self) -> std::io::Result<()> {
loop {
let session = self.accept_one().await?;
tokio::spawn(async move {
if let Err(e) = session.handle().await {
warn!(error = %e, "session terminó con error");
}
});
}
}
}
impl Drop for Server {
fn drop(&mut self) {
// Limpieza best-effort del socket. Si falla, log y seguir.
if let Err(e) = std::fs::remove_file(&self.socket_path) {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(path = %self.socket_path.display(), error = %e, "no se pudo limpiar socket");
}
}
}
}
/// Conexión individual aceptada por el servidor. Genérica sobre el
/// transport — funciona indistinguiblemente sobre `UnixStream` (modo
/// local), libp2p stream wrapped con `tokio_util::compat`, in-memory
/// duplex (tests), etc.
pub struct Session<S> {
stream: S,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
/// Si está set, el path es libp2p y `do_handshake` exige firma
/// del Hello cuya public key derive a este `peer_id`. Si es
/// `None`, el path es Unix/in-memory y la firma es opcional
/// (pero si está, se verifica anyway por defensa en profundidad).
expected_peer: Option<PeerId>,
}
impl<S> Session<S>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
/// Procesa la conexión hasta `Farewell` o EOF.
///
/// Estructura: handshake (sobre el stream entero) → split en
/// halves (read + write) → reader loop principal + writer task
/// que drena el push channel. Garantiza cleanup (sessions + broker
/// + canales) sin importar la rama de salida.
///
/// El split es necesario para soportar streams `!Sync` como
/// `libp2p::Stream`: `tokio::select!` sobre `&mut self.stream`
/// requeriría `S: Sync`. Con `tokio::io::split` cada mitad va a
/// su propia task, eliminando el requirement y permitiendo que
/// la misma `Session` sirva indistinta sobre Unix socket o
/// stream libp2p remoto.
pub async fn handle(self) -> std::io::Result<()> {
let Self {
mut stream,
sessions,
push_table,
last_matches,
config,
expected_peer,
} = self;
let session_id = match do_handshake(&mut stream, &config, &sessions, expected_peer).await?
{
Some(id) => id,
None => return Ok(()), // Hello rechazado, no se registró nada
};
let result = run_post_handshake(
stream,
session_id,
sessions.clone(),
push_table.clone(),
last_matches.clone(),
config.clone(),
)
.await;
cleanup(
session_id,
&sessions,
&push_table,
&last_matches,
&config,
)
.await;
result
}
}
// ============================================================================
// Free functions (post-refactor): la lógica del post-handshake corre sobre
// halves del stream; no necesita más `&mut Session<S>` y por eso vive afuera.
// ============================================================================
async fn run_post_handshake<S>(
stream: S,
session_id: SessionId,
sessions: SessionRegistry,
push_table: SessionTxTable,
last_matches: LastMatches,
config: ServerConfig,
) -> std::io::Result<()>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
// Canal por donde el server inyecta frames push (MatchEvent, etc.).
let (tx, mut rx) = mpsc::channel::<Frame>(PUSH_CHANNEL_CAPACITY);
push_table.lock().await.insert(session_id, tx);
// Tras registrar el canal, recomputar matches y emitir diffs.
broadcast_match_diffs(&push_table, &last_matches, &config).await;
// Split: reader en el hilo principal, writer compartido bajo Mutex
// entre la writer task (push channel) y el handler de inbound
// (que escribe Pong/Error). Mutex serializa writes; es OK porque
// la frecuencia de writes por sesión es baja.
let (mut reader, writer) = split(stream);
let writer = Arc::new(Mutex::new(writer));
// Writer task: drena el push channel.
let writer_for_push = writer.clone();
let writer_task = tokio::spawn(async move {
while let Some(frame) = rx.recv().await {
let mut w = writer_for_push.lock().await;
if write_frame(&mut *w, &frame).await.is_err() {
break;
}
}
});
// Reader loop principal.
let broker_for_loop = config.broker.clone();
let result: std::io::Result<()> = loop {
match read_frame(&mut reader).await {
Ok(frame) => {
match handle_inbound_frame(
session_id,
frame,
&writer,
&sessions,
broker_for_loop.as_ref(),
)
.await
{
Ok(true) => continue,
Ok(false) => break Ok(()),
Err(e) => break Err(e),
}
}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
debug!(session = %session_id, "cliente cerró sin Farewell");
break Ok(());
}
Err(e) => break Err(e),
}
};
// Cerrar writer: drop nuestro Arc y abortar la task. La task
// saldrá igual cuando rx se cierre por drop del último Sender,
// pero abortarla es más rápido que esperar a que próximo recv()
// observe el cierre.
drop(writer);
writer_task.abort();
let _ = writer_task.await;
result
}
async fn handle_inbound_frame<S>(
session_id: SessionId,
frame: Frame,
writer: &Arc<Mutex<WriteHalf<S>>>,
sessions: &SessionRegistry,
broker_for_match: Option<&SharedBroker>,
) -> std::io::Result<bool>
where
S: AsyncRead + AsyncWrite + Unpin + Send + 'static,
{
match frame {
Frame::Ping(Ping { session }) if session == session_id => {
let pong = Pong {
timestamp_ms: now_ms(),
};
let mut w = writer.lock().await;
write_frame(&mut *w, &Frame::Pong(pong)).await?;
Ok(true)
}
Frame::Ping(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
Frame::Farewell(Farewell { session }) if session == session_id => Ok(false),
Frame::Farewell(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
Frame::ListSessions(crate::messages::ListSessions { session })
if session == session_id =>
{
let list = build_session_list(sessions).await;
let mut w = writer.lock().await;
write_frame(&mut *w, &Frame::SessionList(list)).await?;
Ok(true)
}
Frame::ListSessions(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
Frame::ListMatches(crate::messages::ListMatches { session })
if session == session_id =>
{
let matches = match &broker_for_match {
Some(b) => b.lock().await.all_matches(),
None => Vec::new(),
};
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::MatchList(crate::messages::MatchList { matches }),
)
.await?;
Ok(true)
}
Frame::ListMatches(_) => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Unauthorized(
"session-id no coincide".into(),
)),
)
.await?;
Ok(true)
}
_ => {
let mut w = writer.lock().await;
write_frame(
&mut *w,
&Frame::Error(HandshakeError::Rejected(
"frame inesperado tras handshake".into(),
)),
)
.await?;
Ok(true)
}
}
}
/// Snapshot read-only de la `SessionRegistry` proyectado a la forma
/// de wire para el frame `SessionList`. Suelta el lock antes de
/// retornar para que el writer del frame no contenga el mutex.
async fn build_session_list(sessions: &SessionRegistry) -> crate::messages::SessionList {
let table = sessions.lock().await;
let entries = table
.iter()
.map(|(id, resolved)| crate::messages::SessionEntry {
session: *id,
label: resolved.card.label.clone(),
schema_version: resolved.card.schema_version,
outputs: resolved
.card
.flow
.output
.iter()
.map(|f| f.name.clone())
.collect(),
inputs: resolved
.card
.flow
.input
.iter()
.map(|f| f.name.clone())
.collect(),
conscious: resolved.wit.is_some(),
})
.collect();
crate::messages::SessionList { entries }
}
/// Limpieza atómica de las vistas registradas + (si net activo) retiro
/// de anuncios DHT de los outputs de la Card. Se ejecuta tanto si la
/// sesión cierra por Farewell, EOF, o error. Tras desregistrar, emite
/// diffs a las sesiones que perdieron el match contra ésta.
async fn cleanup(
session_id: SessionId,
sessions: &SessionRegistry,
push_table: &SessionTxTable,
last_matches: &LastMatches,
config: &ServerConfig,
) {
// Tomamos la Card ANTES de borrarla — si net está configurado
// necesitamos sus outputs para llamar withdraw_outputs. `remove`
// devuelve el valor extraído.
let removed_card = sessions.lock().await.remove(&session_id);
push_table.lock().await.remove(&session_id);
last_matches.lock().await.remove(&session_id);
if let Some(broker) = &config.broker {
broker.lock().await.unregister(session_id);
}
if let (Some(net), Some(resolved)) = (&config.net, removed_card) {
crate::network::withdraw_outputs(net, &resolved.card);
}
broadcast_match_diffs(push_table, last_matches, config).await;
}
/// Recomputa los matches para todas las sesiones registradas y empuja
/// `MatchEvent::Available` / `MatchEvent::Lost` por las que cambiaron
/// respecto al último estado conocido.
async fn broadcast_match_diffs(
push_table: &SessionTxTable,
last_matches: &LastMatches,
config: &ServerConfig,
) {
let broker = match &config.broker {
Some(b) => b,
None => return,
};
let b = broker.lock().await;
let push_table = push_table.lock().await;
let mut last = last_matches.lock().await;
debug!(
target: "brahman_handshake::broadcast",
cards = b.len(),
push_subscribers = push_table.len(),
"broadcast_match_diffs"
);
let cards: Vec<_> = b.cards().cloned().collect();
for cons in &cards {
let cons_session = cons.session;
let tx = match push_table.get(&cons_session) {
Some(tx) => tx,
None => continue,
};
let cons_last = last.entry(cons_session).or_default();
for input in &cons.inputs {
let new_match = b.find_producer_for(cons_session, &input.name);
let new_endpoint = new_match.as_ref().map(|m| m.producer.clone());
let old_endpoint = cons_last.get(&input.name).cloned();
if old_endpoint == new_endpoint {
continue;
}
if let Some(m) = &new_match {
let producer_service_socket = b
.cards()
.find(|c| c.session == m.producer.session)
.and_then(|c| c.service_socket.clone());
let event = MatchEvent {
kind: MatchEventKind::Available,
consumer_flow: input.name.clone(),
producer_session: m.producer.session,
producer_label: m.producer_label.clone(),
producer_flow: m.producer.flow_name.clone(),
ty: m.ty.clone(),
via: m.via,
pinned: m.pinned,
producer_service_socket,
};
let send_res = tx.try_send(Frame::MatchEvent(event));
debug!(
target: "brahman_handshake::broadcast",
consumer = %cons_session,
flow = %input.name,
producer = %m.producer_label,
result = ?send_res.as_ref().map(|_| "ok").unwrap_or_else(|e| match e {
tokio::sync::mpsc::error::TrySendError::Full(_) => "full",
tokio::sync::mpsc::error::TrySendError::Closed(_) => "closed",
}),
"Available pushed"
);
} else {
let event = MatchEvent {
kind: MatchEventKind::Lost,
consumer_flow: input.name.clone(),
producer_session: Ulid::nil(),
producer_label: String::new(),
producer_flow: String::new(),
ty: input.ty.clone(),
via: brahman_broker::MatchStrategy::Exact,
pinned: false,
producer_service_socket: None,
};
let _ = tx.try_send(Frame::MatchEvent(event));
}
if let Some(ep) = new_endpoint {
cons_last.insert(input.name.clone(), ep);
} else {
cons_last.remove(&input.name);
}
}
}
}
/// Lee el Hello, valida (incluyendo firma cuando aplica), registra la
/// sesión y emite HelloAck.
async fn do_handshake<S>(
stream: &mut S,
config: &ServerConfig,
sessions: &SessionRegistry,
expected_peer: Option<PeerId>,
) -> std::io::Result<Option<SessionId>>
where
S: AsyncRead + AsyncWrite + Unpin,
{
let frame = read_frame(stream).await?;
let hello = match frame {
Frame::Hello(h) => h,
_ => {
write_frame(
stream,
&Frame::Error(HandshakeError::Rejected(
"primer frame debe ser Hello".into(),
)),
)
.await?;
return Ok(None);
}
};
if let Some(err) = validate_hello(&hello) {
write_frame(stream, &Frame::Error(err)).await?;
return Ok(None);
}
// Identity cert (multi-key identity, opcional): si el cliente
// adjuntó cert, la "identidad lógica" del peer es el master
// derivado del cert (estable across rotaciones), no el session
// peer_id (efímero). Sin cert, fallback al modelo de Fase 3
// (logical = session). Esto permite migración gradual y
// backwards compatibility con clientes que no usan identity.
let logical_peer = if let (Some(session_peer), Some(cert)) =
(expected_peer, &hello.identity_cert)
{
let session_pk_bytes: &[u8] = match &hello.signature {
Some(sig) => &sig.public_key,
None => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"Hello con identity_cert requiere también signature".into(),
)),
)
.await?;
return Ok(None);
}
};
match cert.verify_against_session(session_pk_bytes) {
Ok(master_peer) => {
debug!(
session = %session_peer,
master = %master_peer,
"identity cert válido — policy se evalúa contra master_peer"
);
Some(master_peer)
}
Err(e) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"identity cert inválido: {e}"
))),
)
.await?;
debug!(peer = %session_peer, error = %e, "cert rechazado");
return Ok(None);
}
}
} else {
expected_peer
};
// Policy gate (path libp2p): si está configurada, el peer
// autenticado debe pasar la política (deny first, luego allow).
// El peer evaluado es `logical_peer`: master si hay cert,
// session si no. Se chequea ANTES de la firma porque es
// comparación O(log n) sin crypto. La política no se aplica
// al path Unix (autenticación por SO_PEERCRED, no por PeerId).
if let (Some(peer), Some(policy)) = (logical_peer, &config.policy) {
let decision = policy.evaluate(&peer);
if !decision.is_admitted() {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"peer {peer}: {}",
decision.reason()
))),
)
.await?;
debug!(peer = %peer, reason = decision.reason(), "rechazado por policy");
return Ok(None);
}
}
// Trust gate: en path libp2p (expected_peer = Some), exigir
// firma cuya public key derive al peer autenticado por Noise.
// En path Unix (expected_peer = None), si la firma viene se
// verifica anyway por defensa en profundidad — no es un error
// que esté ahí, pero si está debe ser válida.
if let Some(peer) = expected_peer {
let sig = match &hello.signature {
Some(s) => s,
None => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"Hello sin firma en conexión remota libp2p".into(),
)),
)
.await?;
return Ok(None);
}
};
if let Err(e) = crate::signature::verify_hello(sig, &hello.card, &hello.wit, peer) {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!("firma inválida: {e}"))),
)
.await?;
debug!(peer = %peer, error = %e, "firma rechazada");
return Ok(None);
}
} else if let Some(sig) = &hello.signature {
// Firma presente en path local: no exigida pero verificada.
// Si está y no valida, es un signo de Hello mal-construido y
// rechazamos por seguridad.
// Para Unix no tenemos peer_id contra el cual comparar; se
// verifica sólo la consistencia interna (firma sobre payload
// con la public_key declarada).
match brahman_net::PublicKey::try_decode_protobuf(&sig.public_key) {
Ok(pk) => {
let payload = match postcard::to_allocvec(&(
crate::signature::SIGNATURE_VERSION,
&hello.card,
&hello.wit,
)) {
Ok(b) => b,
Err(_) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Internal(
"no pude codificar payload para verificar firma".into(),
)),
)
.await?;
return Ok(None);
}
};
if !pk.verify(&payload, &sig.signature) {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(
"firma del Hello presente pero inválida".into(),
)),
)
.await?;
return Ok(None);
}
}
Err(e) => {
write_frame(
stream,
&Frame::Error(HandshakeError::Unauthorized(format!(
"public_key inválida en firma: {e}"
))),
)
.await?;
return Ok(None);
}
}
}
let session_id = Ulid::new();
let card: Card = hello.card.into();
register_session(session_id, card, hello.wit, config, sessions).await;
let ack = HelloAck {
server_version: crate::HANDSHAKE_VERSION.to_string(),
protocol_version: brahman_card::PROTOCOL_VERSION.to_string(),
session: session_id,
init_attached: config.init_attached,
};
write_frame(stream, &Frame::HelloAck(ack)).await?;
debug!(session = %session_id, "handshake completado");
Ok(Some(session_id))
}
async fn register_session(
session_id: SessionId,
card: Card,
wit: Option<WitInterface>,
config: &ServerConfig,
sessions: &SessionRegistry,
) {
if let Some(broker) = &config.broker {
broker
.lock()
.await
.register(session_id, &card, wit.clone());
}
// Si el server tiene net configurado, anunciar los outputs al
// DHT para que peers remotos puedan descubrirlos. Idempotente
// y best-effort — fallos de Kad no propagan al handshake.
if let Some(net) = &config.net {
crate::network::announce_outputs(net, &card);
}
let resolved = match wit {
Some(w) => ResolvedCard::from_conscious(card, w),
None => ResolvedCard::from_agnostic(card),
};
sessions.lock().await.insert(session_id, resolved);
}
fn validate_hello(hello: &Hello) -> Option<HandshakeError> {
if hello.schema_version != CARD_SCHEMA_VERSION {
return Some(HandshakeError::SchemaMismatch {
client: hello.schema_version,
server: CARD_SCHEMA_VERSION,
});
}
if hello.protocol_version != brahman_card::PROTOCOL_VERSION {
return Some(HandshakeError::ProtocolMismatch(format!(
"cliente={}, servidor={}",
hello.protocol_version,
brahman_card::PROTOCOL_VERSION
)));
}
let as_card: Card = Card::from(hello.card.clone());
if let Err(e) = as_card.validate() {
return Some(HandshakeError::InvalidCard(e.to_string()));
}
None
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
@@ -0,0 +1,155 @@
//! Firma y verificación del payload del `Hello` para trust remoto.
//!
//! Usa la identidad Ed25519 de libp2p (la misma keypair que el peer
//! presenta al swarm vía Noise). Esto ancla la identidad criptográfica
//! del Ente a la identidad de transporte: si Noise autenticó al
//! `peer_id` X, sólo X puede firmar Cards válidas para esa conexión.
//!
//! ## Payload firmado
//!
//! Bytes postcard de la tupla `(WireCard, Option<WitInterface>)`. Se
//! eligió postcard porque ya es el wire format del resto del protocolo:
//! mismo determinismo, sin convertir a otro formato sólo para firmar.
//!
//! Cualquier campo que entre al payload firmado en el futuro debe
//! añadirse al final de la tupla (postcard es position-dependent), o
//! bumpearse el [`SIGNATURE_VERSION`] para distinguir esquemas.
use brahman_card::{WireCard, WitInterface};
use brahman_net::{Keypair, PeerId, PublicKey};
use crate::messages::HelloSignature;
/// Versión del esquema de payload firmado. Si cambia el shape de
/// `(WireCard, Option<WitInterface>)` o cómo se serializa, bump este
/// número y el verificador rechaza firmas antiguas.
pub const SIGNATURE_VERSION: u8 = 1;
/// Errores de verificación de firma.
#[derive(Debug, thiserror::Error)]
pub enum SignatureError {
#[error("public_key inválida (libp2p decode protobuf): {0}")]
DecodeKey(String),
#[error("encode del payload falló: {0}")]
EncodePayload(String),
#[error("firma rechazada: bytes inválidos para la public_key")]
Invalid,
#[error("peer_id de la firma ({signer}) no coincide con el peer libp2p autenticado ({expected})")]
PeerMismatch { signer: PeerId, expected: PeerId },
#[error("firma del Hello faltante (requerida para conexión remota libp2p)")]
Missing,
#[error("firma del Hello inesperada en path local sin trust remoto")]
Unexpected,
}
/// Construye los bytes canónicos a firmar/verificar para un Hello.
/// Postcard determinístico de `(version, WireCard, Option<WitInterface>)`.
fn payload_bytes(card: &WireCard, wit: &Option<WitInterface>) -> Result<Vec<u8>, SignatureError> {
let tup = (SIGNATURE_VERSION, card, wit);
postcard::to_allocvec(&tup).map_err(|e| SignatureError::EncodePayload(e.to_string()))
}
/// Firma `(card, wit)` con la `keypair`. La public key derivada de
/// `keypair` debe coincidir con la identidad libp2p del peer cuando
/// el verificador la chequee.
pub fn sign_hello(
keypair: &Keypair,
card: &WireCard,
wit: &Option<WitInterface>,
) -> Result<HelloSignature, SignatureError> {
let bytes = payload_bytes(card, wit)?;
let signature_bytes = keypair
.sign(&bytes)
.map_err(|e| SignatureError::EncodePayload(e.to_string()))?;
Ok(HelloSignature {
public_key: keypair.public().encode_protobuf(),
signature: signature_bytes,
})
}
/// Verifica que `sig` es una firma válida sobre `(card, wit)` y que
/// la public key declarada coincide con `expected_peer` (la identidad
/// libp2p autenticada por Noise).
///
/// Devuelve `Ok(())` si todo cuadra; si no, el error concreto.
pub fn verify_hello(
sig: &HelloSignature,
card: &WireCard,
wit: &Option<WitInterface>,
expected_peer: PeerId,
) -> Result<(), SignatureError> {
let public_key = PublicKey::try_decode_protobuf(&sig.public_key)
.map_err(|e| SignatureError::DecodeKey(e.to_string()))?;
let signer_peer = public_key.to_peer_id();
if signer_peer != expected_peer {
return Err(SignatureError::PeerMismatch {
signer: signer_peer,
expected: expected_peer,
});
}
let bytes = payload_bytes(card, wit)?;
if !public_key.verify(&bytes, &sig.signature) {
return Err(SignatureError::Invalid);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use brahman_card::Card;
fn sample_card() -> WireCard {
Card::new("test.signed").into()
}
#[test]
fn sign_then_verify_roundtrip() {
let kp = Keypair::generate_ed25519();
let peer = kp.public().to_peer_id();
let card = sample_card();
let wit = None;
let sig = sign_hello(&kp, &card, &wit).unwrap();
verify_hello(&sig, &card, &wit, peer).expect("firma propia debe verificar");
}
#[test]
fn verify_rejects_wrong_peer() {
let kp = Keypair::generate_ed25519();
let other = Keypair::generate_ed25519().public().to_peer_id();
let card = sample_card();
let wit = None;
let sig = sign_hello(&kp, &card, &wit).unwrap();
let err = verify_hello(&sig, &card, &wit, other).unwrap_err();
assert!(matches!(err, SignatureError::PeerMismatch { .. }), "got {err:?}");
}
#[test]
fn verify_rejects_tampered_card() {
let kp = Keypair::generate_ed25519();
let peer = kp.public().to_peer_id();
let original = sample_card();
let wit = None;
let sig = sign_hello(&kp, &original, &wit).unwrap();
// Verificamos contra una Card distinta (mismo shape, distinto label).
let tampered: WireCard = Card::new("test.tampered").into();
let err = verify_hello(&sig, &tampered, &wit, peer).unwrap_err();
assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
}
#[test]
fn verify_rejects_corrupted_signature() {
let kp = Keypair::generate_ed25519();
let peer = kp.public().to_peer_id();
let card = sample_card();
let wit = None;
let mut sig = sign_hello(&kp, &card, &wit).unwrap();
// Flip un bit de la firma.
if let Some(b) = sig.signature.last_mut() {
*b ^= 0x01;
}
let err = verify_hello(&sig, &card, &wit, peer).unwrap_err();
assert!(matches!(err, SignatureError::Invalid), "got {err:?}");
}
}
@@ -0,0 +1,52 @@
//! Convenciones de transporte: dónde vive el socket del Init.
//!
//! Resolución del path canónico:
//! 1. Variable de entorno [`SOCKET_ENV`] si está definida (override
//! explícito, prioridad máxima).
//! 2. `$XDG_RUNTIME_DIR/brahman-init.sock` (sesión usuario).
//! 3. `$TMPDIR/brahman-init.sock` (fallback portable).
use std::path::PathBuf;
/// Variable de entorno que sobreescribe la ruta del socket del Init.
pub const SOCKET_ENV: &str = "BRAHMAN_INIT_SOCKET";
/// Nombre del socket dentro del runtime dir.
pub const SOCKET_NAME: &str = "brahman-init.sock";
/// Ruta canónica al socket del Init brahman.
pub fn default_socket_path() -> PathBuf {
if let Ok(p) = std::env::var(SOCKET_ENV) {
return PathBuf::from(p);
}
let base = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
base.join(SOCKET_NAME)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_override_wins() {
// Nota: estos tests modifican entorno del proceso. `cargo test`
// los corre paralelos por defecto pero usamos un nombre de var
// único y restablecemos al final.
let key = "BRAHMAN_INIT_SOCKET_TEST_OVERRIDE";
// SAFETY: sólo escribimos una variable local al test; sin
// contaminar SOCKET_ENV.
std::env::set_var(key, "/tmp/explicit.sock");
let saved = std::env::var(SOCKET_ENV).ok();
std::env::set_var(SOCKET_ENV, "/tmp/explicit.sock");
let p = default_socket_path();
assert_eq!(p, PathBuf::from("/tmp/explicit.sock"));
// Restaurar
match saved {
Some(v) => std::env::set_var(SOCKET_ENV, v),
None => std::env::remove_var(SOCKET_ENV),
}
std::env::remove_var(key);
}
}
@@ -0,0 +1,480 @@
//! Tests de integración: levanta server + client en el mismo proceso,
//! ejercita el round-trip completo del protocolo.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use brahman_broker::{Broker, BrokerConfig};
use brahman_card::{
Card, CgroupSpec, Flow, Flows, NamespaceSet, Payload, ResourceLimits, SomaSpec, Supervision,
TypeRef, CARD_SCHEMA_VERSION,
};
use brahman_handshake::{
client::{Client, ClientError},
codec::{read_frame, write_frame},
messages::{Frame, HandshakeError, Hello, Ping},
server::{Server, ServerConfig},
};
use tokio::net::UnixStream;
use tokio::sync::Mutex;
use ulid::Ulid;
fn sample_card(label: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
lineage: None,
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
soma: SomaSpec {
cgroup: CgroupSpec {
path: "ente.slice/test".into(),
cpu_weight: None,
io_weight: None,
},
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cpu_affinity: None,
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
..Default::default()
}
}
/// Genera una ruta de socket única bajo TMPDIR. No la creamos —
/// el server la creará al hacer bind.
fn sock_path(name: &str) -> std::path::PathBuf {
std::env::temp_dir().join(format!(
"brahman-test-{}-{}-{}.sock",
name,
std::process::id(),
Ulid::new()
))
}
#[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, policy: None }).unwrap();
let session_handle = tokio::spawn({
async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
}
});
let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
assert!(client.server_info().init_attached);
assert_eq!(
client.server_info().protocol_version,
brahman_card::PROTOCOL_VERSION
);
let mut last = 0u64;
for _ in 0..3 {
let ts = client.ping().await.unwrap();
assert!(ts >= last);
last = ts;
tokio::time::sleep(Duration::from_millis(2)).await;
}
client.farewell().await.unwrap();
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server hung after farewell")
.unwrap();
}
#[tokio::test]
async fn list_sessions_returns_currently_registered() {
// Levantamos un server con broker (requerido para que el registro
// pase por el path real) y conectamos 3 clientes. El último pide
// ListSessions y debe ver a los 2 anteriores + a sí mismo.
let path = sock_path("listsess");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: true,
broker: Some(broker),
net: None,
policy: None,
},
)
.unwrap();
// Una task accept loop genérica para los 3 clientes.
let server_handle = tokio::spawn(async move {
for _ in 0..3 {
let session = server.accept_one().await.unwrap();
tokio::spawn(async move {
let _ = session.handle().await;
});
}
// Mantener el server vivo para que las sesiones puedan
// mantenerse abiertas mientras el observer pregunta.
std::future::pending::<()>().await;
});
let mut alpha = Client::connect(&path, sample_card("producer-alpha"))
.await
.unwrap();
let mut beta = Client::connect(&path, sample_card("producer-beta"))
.await
.unwrap();
// observer es el que va a preguntar.
let mut observer = Client::connect(&path, sample_card("observer"))
.await
.unwrap();
let list = observer.list_sessions().await.unwrap();
assert_eq!(list.entries.len(), 3, "deberían verse 3 sesiones activas");
let labels: BTreeSet<&str> = list.entries.iter().map(|e| e.label.as_str()).collect();
assert!(labels.contains("producer-alpha"));
assert!(labels.contains("producer-beta"));
assert!(labels.contains("observer"));
// schema_version + conscious sanity en la propia entry del observer.
let me = list
.entries
.iter()
.find(|e| e.label == "observer")
.unwrap();
assert_eq!(me.schema_version, brahman_card::CARD_SCHEMA_VERSION);
assert!(!me.conscious, "observer no envió WIT — debería ser agnostic");
alpha.farewell().await.unwrap();
beta.farewell().await.unwrap();
observer.farewell().await.unwrap();
server_handle.abort();
}
#[tokio::test]
async fn rejects_invalid_card_client_side() {
let path = sock_path("invalid");
let server = Server::bind(&path, ServerConfig::default()).unwrap();
let session_handle = tokio::spawn(async move {
// No esperamos que el server complete: el cliente corta antes.
let _ = tokio::time::timeout(Duration::from_secs(1), async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
})
.await;
});
let mut bad = sample_card("placeholder");
bad.label = String::new();
let err = Client::connect(&path, bad).await.unwrap_err();
assert!(matches!(err, ClientError::InvalidCard(_)));
session_handle.abort();
}
#[tokio::test]
async fn server_rejects_protocol_mismatch() {
let path = sock_path("mismatch");
let server = Server::bind(&path, ServerConfig::default()).unwrap();
let session_handle = tokio::spawn(async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
});
let mut stream = UnixStream::connect(&path).await.unwrap();
let hello = Hello {
schema_version: CARD_SCHEMA_VERSION,
protocol_version: "999.0.0".into(),
card: sample_card("future-module").into(),
wit: None,
signature: None,
identity_cert: None,
};
write_frame(&mut stream, &Frame::Hello(hello)).await.unwrap();
match read_frame(&mut stream).await.unwrap() {
Frame::Error(HandshakeError::ProtocolMismatch(_)) => {}
other => panic!("esperado ProtocolMismatch, got {other:?}"),
}
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server hung after rejecting")
.unwrap();
}
// =====================================================================
// Integración handshake ↔ broker
// =====================================================================
fn card_with_flows(label: &str, input: Vec<Flow>, output: Vec<Flow>) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
soma: SomaSpec {
cgroup: CgroupSpec {
path: "ente.slice/test".into(),
cpu_weight: None,
io_weight: None,
},
namespaces: NamespaceSet::default(),
rlimits: ResourceLimits::default(),
cpu_affinity: None,
},
payload: Payload::Virtual,
supervision: Supervision::OneShot,
flow: Flows { input, output },
..Default::default()
}
}
fn flow(name: &str, ty: TypeRef) -> Flow {
Flow {
name: name.into(),
ty,
pin_to: None,
}
}
/// Espera hasta que `broker.len() >= n` o timeout.
async fn wait_for_broker_len(broker: &Arc<Mutex<Broker>>, n: usize) {
for _ in 0..50 {
if broker.lock().await.len() >= n {
return;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
panic!("broker no alcanzó {n} entradas en 500ms");
}
#[tokio::test]
async fn broker_registers_and_unregisters_with_session() {
let path = sock_path("broker-lifecycle");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap();
let session_handle = tokio::spawn(async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
});
let mut client = Client::connect(&path, sample_card("alpha")).await.unwrap();
let session_id = client.session();
// Tras el handshake, la Card debe estar registrada en el broker.
wait_for_broker_len(&broker, 1).await;
{
let b = broker.lock().await;
assert_eq!(b.len(), 1);
assert!(b.sessions().any(|s| s == session_id));
}
client.farewell().await.unwrap();
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server colgó tras farewell")
.unwrap();
// Tras el cleanup, el broker queda vacío.
{
let b = broker.lock().await;
assert_eq!(b.len(), 0);
}
}
#[tokio::test]
async fn broker_matches_two_live_modules() {
let path = sock_path("broker-match");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap();
// Server loop: usa la API run() para manejar accept+spawn.
let server_handle = tokio::spawn(async move {
let _ = server.run().await;
});
// Productor: emite "out" tipo string.
let producer_card = card_with_flows(
"dht",
vec![],
vec![flow(
"out",
TypeRef::Primitive {
name: "string".into(),
},
)],
);
let mut producer = Client::connect(&path, producer_card).await.unwrap();
wait_for_broker_len(&broker, 1).await;
// Consumidor: pide "in" tipo string.
let consumer_card = card_with_flows(
"ui",
vec![flow(
"in",
TypeRef::Primitive {
name: "string".into(),
},
)],
vec![],
);
let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
wait_for_broker_len(&broker, 2).await;
// El broker debe encontrar el match consumer.in ← producer.out.
let m = {
let b = broker.lock().await;
b.find_producer_for(consumer.session(), "in")
}
.expect("broker no encontró match");
assert_eq!(m.consumer_label, "ui");
assert_eq!(m.producer_label, "dht");
assert_eq!(m.producer.flow_name, "out");
// Cuando el productor se va, el match desaparece.
producer.farewell().await.unwrap();
for _ in 0..50 {
if broker.lock().await.len() < 2 {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
{
let b = broker.lock().await;
assert!(b.find_producer_for(consumer.session(), "in").is_none());
}
consumer.farewell().await.unwrap();
server_handle.abort();
}
#[tokio::test]
async fn match_event_pushed_on_producer_arrival() {
use brahman_handshake::messages::MatchEventKind;
let path = sock_path("push-match");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Server::bind(
&path,
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap();
let server_handle = tokio::spawn(async move {
let _ = server.run().await;
});
// El consumidor llega primero — sin productor, no hay match aún.
let consumer_card = card_with_flows(
"ui",
vec![flow(
"in",
TypeRef::Primitive {
name: "json".into(),
},
)],
vec![],
);
let mut consumer = Client::connect(&path, consumer_card).await.unwrap();
// No debería haber evento todavía.
let no_event = consumer
.await_event(Duration::from_millis(100))
.await
.unwrap();
assert!(no_event.is_none(), "evento inesperado: {no_event:?}");
// Llega el productor → consumer debe recibir Available.
let producer_card = card_with_flows(
"dht",
vec![],
vec![flow(
"out",
TypeRef::Primitive {
name: "json".into(),
},
)],
);
let mut producer = Client::connect(&path, producer_card).await.unwrap();
let ev = consumer
.await_event(Duration::from_secs(2))
.await
.unwrap()
.expect("Available no llegó");
assert_eq!(ev.kind, MatchEventKind::Available);
assert_eq!(ev.consumer_flow, "in");
assert_eq!(ev.producer_label, "dht");
assert_eq!(ev.producer_flow, "out");
// El productor se va → consumer debe recibir Lost.
producer.farewell().await.unwrap();
let ev = consumer
.await_event(Duration::from_secs(2))
.await
.unwrap()
.expect("Lost no llegó");
assert_eq!(ev.kind, MatchEventKind::Lost);
assert_eq!(ev.consumer_flow, "in");
consumer.farewell().await.unwrap();
server_handle.abort();
}
#[tokio::test]
async fn ping_before_hello_rejected() {
let path = sock_path("ping-no-hello");
let server = Server::bind(&path, ServerConfig::default()).unwrap();
let session_handle = tokio::spawn(async move {
let session = server.accept_one().await.unwrap();
session.handle().await.unwrap();
});
// Conectamos y mandamos un Ping sin haber saludado.
let mut stream = UnixStream::connect(&path).await.unwrap();
write_frame(
&mut stream,
&Frame::Ping(Ping {
session: Ulid::new(),
}),
)
.await
.unwrap();
match read_frame(&mut stream).await.unwrap() {
Frame::Error(HandshakeError::Rejected(_)) => {}
other => panic!("esperado Rejected, got {other:?}"),
}
tokio::time::timeout(Duration::from_secs(2), session_handle)
.await
.expect("server hung after rejecting")
.unwrap();
}
@@ -0,0 +1,340 @@
//! Test E2E de Fase 2: discovery remoto vía DHT.
//!
//! Pipeline:
//! 1. **Provider node (A)**: arma server con `BrahmanNet` configurado;
//! listen TCP; un cliente local registra una Card con un output
//! flow. El server llama `announce_outputs` automáticamente, lo
//! que hace `start_providing` en el DHT bajo la key derivada del
//! flow.
//! 2. **Consumer node (B)**: arma su propio `BrahmanNet`; dial-ea al
//! multiaddr del provider para que ambos se conozcan vía Identify
//! (esto popula sus respectivos routing tables de Kademlia).
//! 3. **B llama `find_remote_providers(flow_name, type)`**: la query
//! DHT propaga vía Kad, y eventually el provider responde con su
//! `PeerId`.
//! 4. **Verificación**: el `PeerId` que B descubre coincide con el
//! de A.
//!
//! Notas:
//! - Kademlia replication factor por defecto es 20; con 2 nodos no
//! hay propagación material — A es el único provider, B llega a A
//! vía la conexión directa establecida en step 2 y obtiene el record
//! del store local de A.
//! - El test usa flow `monad-list:json` por familiaridad (es el flow
//! real que `akasha daemon` declara). Sirve también como prueba de
//! que el sistema completo (daemon + DHT) funcionaría con cero
//! cambios en la Card.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use brahman_broker::{Broker, BrokerConfig};
use brahman_card::{
ulid::Ulid, Card, CardKind, Flow, Flows, Lifecycle, Payload, Priority, Supervision, TypeRef,
CARD_SCHEMA_VERSION,
};
use brahman_handshake::network::{find_remote_providers, run_libp2p_accept_loop};
use brahman_handshake::server::{Server, ServerConfig};
use brahman_net::{BrahmanNet, Multiaddr, Protocol};
use tempfile::TempDir;
use tokio::sync::Mutex;
fn provider_card(label: &str, flow_name: &str, type_name: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
permissions: Default::default(),
soma: Default::default(),
payload: Payload::Virtual,
supervision: Supervision::Delegate,
lifecycle: Lifecycle::Daemon,
priority: Priority::Normal,
kind: CardKind::Ente,
flow: Flows {
input: vec![],
output: vec![Flow {
name: flow_name.into(),
ty: TypeRef::Primitive {
name: type_name.into(),
},
pin_to: None,
}],
},
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dht_discovery_finds_remote_provider() {
// ---- Node A (provider): server + libp2p net + Card con output ----
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker.clone()),
net: Some(a_net.clone()), // ← clave Fase 2: anuncia al DHT
policy: None,
},
)
.unwrap(),
);
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let a_addr = a_net.listen(listen_addr).await;
let mut a_full_addr = a_addr.clone();
a_full_addr.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
// Unix accept loop: necesario para que Client::connect al socket
// local no cuelgue (Server no se auto-accepta; el caller arma el
// loop). Cada session entrante corre en su propia task.
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
// Registrar la Card local en A con un flow output.
let card = provider_card("test.engine_remote", "monad-list", "json");
let mut local_client = brahman_handshake::client::Client::connect(&a_unix, card)
.await
.expect("registro local en A");
// ---- Node B (consumer): otro net que dial-a a A ----
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full_addr.clone());
// Esperar a que la conexión se establezca y Identify popule el
// routing table de Kad. En localhost con 2 peers, ~250ms es de
// sobra; sumamos margen para CI.
tokio::time::sleep(Duration::from_millis(500)).await;
// ---- Discovery: B busca providers de "monad-list:json" ----
let providers = find_remote_providers(
&b_net,
"monad-list",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
providers.contains(&a_peer),
"B debería descubrir a A vía DHT. Encontrados: {:?}, esperado: {}",
providers,
a_peer
);
// Sanidad: el cliente local sigue vivo durante todo el test (lo
// que mantiene la Card registrada y por tanto el record DHT vivo).
local_client.farewell().await.ok();
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dht_discovery_negative_unknown_flow() {
// Mismo setup que el test happy-path, pero B busca un flow que A
// NO ofrece. Debe devolver lista vacía dentro del timeout
// razonable (no colgarse).
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker),
net: Some(a_net.clone()),
policy: None,
},
)
.unwrap(),
);
let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let mut a_full = a_addr.clone();
a_full.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
// Unix accept loop: necesario para que Client::connect al socket
// local no cuelgue (Server no se auto-accepta; el caller arma el
// loop). Cada session entrante corre en su propia task.
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
let card = provider_card("test.engine_other", "monad-list", "json");
let mut local = brahman_handshake::client::Client::connect(&a_unix, card)
.await
.unwrap();
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full);
tokio::time::sleep(Duration::from_millis(500)).await;
// Buscamos un flow que NADIE anunció.
let providers = find_remote_providers(
&b_net,
"flow-que-no-existe",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
providers.is_empty(),
"no debería haber providers para un flow inexistente, got: {:?}",
providers
);
local.farewell().await.ok();
}
/// stop_providing test: A registra Card con flow X, B descubre a A.
/// El cliente local de A hace farewell → cleanup llama
/// withdraw_outputs → A se quita del provider local store. Una nueva
/// query desde B (que rutea por A, único peer en el DHT) ya no debe
/// listarlo.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn dht_discovery_withdraws_on_session_cleanup() {
let tmp = TempDir::new().unwrap();
let a_unix = tmp.path().join("a.sock");
let a_broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let a_net = Arc::new(BrahmanNet::new().unwrap());
let a_peer = a_net.peer_id;
let a_server = Arc::new(
Server::bind(
&a_unix,
ServerConfig {
init_attached: true,
broker: Some(a_broker),
net: Some(a_net.clone()),
policy: None,
},
)
.unwrap(),
);
let sessions = a_server.sessions();
let a_addr = a_net.listen("/ip4/127.0.0.1/tcp/0".parse().unwrap()).await;
let mut a_full = a_addr.clone();
a_full.push(Protocol::P2p(a_peer));
tokio::spawn(run_libp2p_accept_loop(a_server.clone(), a_net.clone()));
{
let s = a_server.clone();
tokio::spawn(async move {
loop {
match s.accept_one().await {
Ok(session) => {
tokio::spawn(async move {
let _ = session.handle().await;
});
}
Err(_) => break,
}
}
});
}
// Card con un flow output anunciable.
let card = provider_card("test.withdraws", "monad-list", "json");
let local = brahman_handshake::client::Client::connect(&a_unix, card)
.await
.expect("registro local en A");
let b_net = BrahmanNet::new().unwrap();
b_net.dial(a_full);
tokio::time::sleep(Duration::from_millis(500)).await;
// Confirmación previa: A es discoverable.
let before = find_remote_providers(
&b_net,
"monad-list",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
before.contains(&a_peer),
"antes del farewell A debería ser discoverable. got: {:?}",
before
);
// Farewell del cliente local → server.cleanup → withdraw_outputs.
local.farewell().await.ok();
// Esperamos a que la sesión salga del registro de A (señal de
// que cleanup completó).
let mut waited = 0;
while !sessions.lock().await.is_empty() && waited < 50 {
tokio::time::sleep(Duration::from_millis(20)).await;
waited += 1;
}
assert!(
sessions.lock().await.is_empty(),
"sesión debería estar removida tras farewell"
);
// Pequeño margen extra para que el Command::StopProviding lo
// procese el swarm task (no es await-able desde fuera).
tokio::time::sleep(Duration::from_millis(100)).await;
// Nueva query: A ya no debería listarse como provider.
let after = find_remote_providers(
&b_net,
"monad-list",
&TypeRef::Primitive {
name: "json".into(),
},
)
.await;
assert!(
!after.contains(&a_peer),
"tras farewell + withdraw_outputs, A NO debería ser discoverable. got: {:?}",
after
);
}
@@ -0,0 +1,525 @@
//! Test E2E: handshake brahman remoto sobre libp2p stream.
//!
//! Pipeline:
//! 1. Server: bind Unix socket (necesario aunque no lo use el cliente);
//! crear `BrahmanNet` y escuchar en `/ip4/127.0.0.1/tcp/0`;
//! montar `run_libp2p_accept_loop`.
//! 2. Client: crear su propio `BrahmanNet`; dial al multiaddr del
//! server; `connect_libp2p` con su Card; `ping`; `farewell`.
//! 3. Verificar: el server registró la sesión; sessions.len() == 1
//! durante la sesión, == 0 después del farewell.
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use brahman_broker::{Broker, BrokerConfig};
use brahman_card::{
ulid::Ulid, Card, CardKind, Lifecycle, Payload, Priority, Supervision,
CARD_SCHEMA_VERSION,
};
use brahman_handshake::identity::{Identity, DEFAULT_SESSION_TTL};
use brahman_handshake::network::{connect_libp2p, connect_libp2p_with_cert, run_libp2p_accept_loop};
use brahman_handshake::peer_policy::PeerPolicy;
use brahman_handshake::server::{Server, ServerConfig};
use brahman_net::{BrahmanNet, Keypair, Multiaddr, PeerId, Protocol};
use tempfile::TempDir;
use tokio::sync::Mutex;
fn sample_card(label: &str) -> Card {
Card {
schema_version: CARD_SCHEMA_VERSION,
id: Ulid::new(),
label: label.into(),
provides: BTreeSet::new(),
requires: BTreeSet::new(),
permissions: Default::default(),
soma: Default::default(),
payload: Payload::Virtual,
supervision: Supervision::OneShot,
lifecycle: Lifecycle::default(),
priority: Priority::default(),
kind: CardKind::Ente,
..Default::default()
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_roundtrip() {
// ---- Server side ----
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let broker = Arc::new(Mutex::new(Broker::new(BrokerConfig::default())));
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: Some(broker.clone()),
net: None,
policy: None,
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer_id = server_net.peer_id;
// Listen on a random TCP port.
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let actual_addr = server_net.listen(listen_addr).await;
// Inject the libp2p PeerId into the multiaddr so the client knows
// who to dial.
let mut full_addr = actual_addr.clone();
full_addr.push(Protocol::P2p(server_peer_id));
// Spawn the libp2p accept loop.
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
// ---- Client side ----
let client_net = BrahmanNet::new().unwrap();
client_net.dial(full_addr.clone());
// Pequeña espera para que el dial conecte. En un entorno real el
// caller usaría un mecanismo de barrier, pero para tests un sleep
// corto es suficiente y deterministic en localhost.
tokio::time::sleep(Duration::from_millis(200)).await;
let card = sample_card("test.remote_ente");
let client_kp = client_net.keypair();
let mut client = connect_libp2p(&client_net, server_peer_id, card, None, &client_kp)
.await
.expect("handshake remoto debería completar");
// Verificación: el server vio la sesión.
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "una sesión registrada");
let resolved = s.values().next().unwrap();
assert_eq!(resolved.card.label, "test.remote_ente");
}
// Ping roundtrip.
let ts = client.ping().await.expect("ping debería responder");
assert!(ts > 0, "timestamp del Pong > 0");
// Farewell limpio.
client.farewell().await.expect("farewell debería completar");
// Tras el farewell, el cleanup remueve la sesión.
// Damos un tick para que el handler procese el frame.
tokio::time::sleep(Duration::from_millis(100)).await;
{
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "sesión removida tras farewell");
}
// peer_id no usado aquí, pero validamos que la API existe.
let _ = PeerId::random();
}
/// Fase 3 negativo: el cliente intenta firmar el Hello con una keypair
/// distinta a la del peer libp2p. El server (que verifica que la
/// public key del Hello derive al peer_id autenticado por Noise) debe
/// rechazar con `Unauthorized`.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_rejects_mismatched_signing_key() {
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: None,
},
)
.unwrap(),
);
let sessions = server.sessions();
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let actual = server_net.listen(listen_addr).await;
let mut full = actual.clone();
full.push(Protocol::P2p(server_peer));
tokio::spawn(run_libp2p_accept_loop(server.clone(), server_net.clone()));
let client_net = BrahmanNet::new().unwrap();
client_net.dial(full);
tokio::time::sleep(Duration::from_millis(200)).await;
// Keypair fraudulenta: NO es la del client_net.
let evil_keypair = Keypair::generate_ed25519();
let card = sample_card("test.evil");
let result = connect_libp2p(&client_net, server_peer, card, None, &evil_keypair).await;
assert!(
result.is_err(),
"handshake con keypair fraudulenta debe fallar"
);
// Sanidad: ninguna sesión registrada.
let s = sessions.lock().await;
assert_eq!(s.len(), 0, "no debería haber sesión registrada");
}
/// Allowlist gate: A configura `allowlist = [client_authorized_peer]`.
/// Un cliente con peer_id en la lista pasa el handshake; otro con
/// peer_id distinto es rechazado con `Unauthorized` ANTES de la
/// verificación de firma (la allowlist se chequea primero, es más
/// barata).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn libp2p_handshake_allowlist_admits_listed_rejects_others() {
// Pre-generamos las dos identidades cliente para que A pueda
// construir la allowlist conociendo cuál es la "permitida".
let allowed_kp = Keypair::generate_ed25519();
let allowed_peer = allowed_kp.public().to_peer_id();
let denied_kp = Keypair::generate_ed25519();
// (denied_peer no se necesita para la lista — sólo para clarity)
let _ = denied_kp.public().to_peer_id();
// ---- Server con allowlist activa ----
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(
Some([allowed_peer].into_iter().collect()),
std::collections::BTreeSet::new(),
)),
},
)
.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 PERMITIDO ----
let allowed_net = BrahmanNet::with_keypair(allowed_kp.clone()).unwrap();
allowed_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_ok = sample_card("test.allowed");
let mut allowed_client = connect_libp2p(&allowed_net, server_peer, card_ok, None, &allowed_kp)
.await
.expect("peer en allowlist debe pasar");
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "sesión del peer permitido registrada");
}
allowed_client.farewell().await.ok();
tokio::time::sleep(Duration::from_millis(100)).await;
// ---- Cliente DENEGADO ----
let denied_net = BrahmanNet::with_keypair(denied_kp.clone()).unwrap();
denied_net.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let card_no = sample_card("test.denied");
let result = connect_libp2p(&denied_net, server_peer, card_no, None, &denied_kp).await;
assert!(
result.is_err(),
"peer fuera de allowlist debe ser rechazado, got: {:?}",
result.is_ok()
);
{
let s = sessions.lock().await;
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();
}
/// Swarm-level deny via `PeerPolicy::attach_to_net`: cuando la deny
/// se aplica al swarm vía `block_list`, el peer baneado es rechazado
/// en el dial — la conexión TCP/Noise nunca completa, así que el
/// cliente nunca llega siquiera a mandar el Hello. Más eficiente que
/// el handshake-level deny.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn swarm_level_deny_blocks_before_noise() {
let banned_kp = Keypair::generate_ed25519();
let banned_peer = banned_kp.public().to_peer_id();
let tmp = TempDir::new().unwrap();
let unix_socket = tmp.path().join("brahman-init.sock");
let policy = brahman_handshake::peer_policy::PeerPolicy::from_sets(
None,
[banned_peer].into_iter().collect(),
);
let server = Arc::new(
Server::bind(
&unix_socket,
ServerConfig {
init_attached: true,
broker: None,
net: None,
policy: Some(policy.clone()),
},
)
.unwrap(),
);
let server_net = Arc::new(BrahmanNet::new().unwrap());
let server_peer = server_net.peer_id;
// ATTACH: la deny se proyecta al swarm. Es lo nuevo de este
// commit — sin esta llamada, el deny seguiría aplicando sólo
// al nivel de handshake brahman (lo que también funciona pero
// gasta un round-trip Noise).
policy.attach_to_net(server_net.clone());
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 intenta dial + handshake. Con swarm-level
// deny, la conexión libp2p ni siquiera completa: `connect_libp2p`
// falla con error de open_stream (peer inalcanzable / connection
// refused) en lugar del Unauthorized del handshake-level path.
let banned_net = BrahmanNet::with_keypair(banned_kp.clone()).unwrap();
banned_net.dial(full.clone());
let card = sample_card("test.swarm_banned");
// Timeout corto: si el block falla, el handshake completaría
// rápido en localhost. Si funciona, debería fallar el dial casi
// instantáneo o colgarse hasta el timeout.
let result = tokio::time::timeout(
Duration::from_secs(3),
connect_libp2p(&banned_net, server_peer, card, None, &banned_kp),
)
.await;
match result {
Ok(Ok(_)) => panic!("peer baneado a nivel swarm NO debería completar handshake"),
Ok(Err(e)) => {
// Esperado: error de transporte/stream, no de handshake.
tracing::info!(error = %e, "swarm-level deny rechazó como esperado");
}
Err(_) => {
// También aceptable: timeout porque el dial nunca completa.
tracing::info!("swarm-level deny → connect timeout (también OK)");
}
}
}
/// Multi-key identity: la propiedad fundamental que cierra el
/// proyecto. El cliente B tiene una identity master estable; el
/// server A le permite el master_peer en allowlist. B se conecta con
/// **session1**; pasa. B "rota": genera **session2** distinta, emite
/// un nuevo cert con la misma identity, se conecta de nuevo. Pasa
/// también — sin que A toque su allowlist.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn identity_cert_allows_session_rotation_without_policy_change() {
// Master de B (estable, persistente).
let master_kp = Keypair::generate_ed25519();
let master_peer = master_kp.public().to_peer_id();
let identity = Identity::from_keypair(master_kp);
// A configura policy: allowlist con master_peer (NO sessions).
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(
Some([master_peer].into_iter().collect()),
std::collections::BTreeSet::new(),
)),
},
)
.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()));
// ---- Conexión 1: session1 ----
let session1_kp = Keypair::generate_ed25519();
let cert1 = identity
.issue_session_cert(&session1_kp, DEFAULT_SESSION_TTL)
.unwrap();
let net1 = BrahmanNet::with_keypair(session1_kp.clone()).unwrap();
net1.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let mut client1 = connect_libp2p_with_cert(
&net1,
server_peer,
sample_card("test.session1"),
None,
&session1_kp,
cert1,
)
.await
.expect("session1 con cert válido del master allowlisted debe pasar");
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "session1 registrada");
}
client1.farewell().await.ok();
tokio::time::sleep(Duration::from_millis(100)).await;
// ---- ROTACIÓN: session2 distinta, mismo master ----
let session2_kp = Keypair::generate_ed25519();
assert_ne!(
session1_kp.public().to_peer_id(),
session2_kp.public().to_peer_id(),
"test inválido si las sessions son iguales"
);
let cert2 = identity
.issue_session_cert(&session2_kp, DEFAULT_SESSION_TTL)
.unwrap();
let net2 = BrahmanNet::with_keypair(session2_kp.clone()).unwrap();
net2.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let mut client2 = connect_libp2p_with_cert(
&net2,
server_peer,
sample_card("test.session2"),
None,
&session2_kp,
cert2,
)
.await
.expect(
"session2 (rotada) con cert del MISMO master debe pasar sin tocar allowlist",
);
{
let s = sessions.lock().await;
assert_eq!(s.len(), 1, "session2 registrada");
}
client2.farewell().await.ok();
// Sanity: una session sin cert (path Fase 3) cuyo session_peer_id
// NO está en la allowlist (porque la allowlist tiene master, no
// sessions) DEBE ser rechazada.
let session_other = Keypair::generate_ed25519();
let net_other = BrahmanNet::with_keypair(session_other.clone()).unwrap();
net_other.dial(full.clone());
tokio::time::sleep(Duration::from_millis(200)).await;
let result = connect_libp2p(
&net_other,
server_peer,
sample_card("test.no_cert"),
None,
&session_other,
)
.await;
assert!(
result.is_err(),
"sin cert, session_peer_id (no listado) debe ser rechazado"
);
}