feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm)
Primer paso del plan "el encuentro entre Entes no se restringe a local".
El swarm libp2p que vivía dentro de minga-p2p::network (282 LOC) sale
a una crate compartida brahman-net para que cualquier protocolo de la
familia (handshake brahman remoto en Fase 1, sync minga, futuros) reuse
una sola malla TCP+Noise+Yamux+Kad+Identify+Stream.
BrahmanNet expone:
- new() / with_keypair() para identidad efimera o persistente
- API de comandos uniforme: dial, listen, add_dht_peer,
find_closest_peers, start_providing, find_providers
- Publica peer_id (libp2p) y control (stream::Control) — cada
protocolo registra su StreamProtocol sin acoplarse al swarm
- Re-exporta Stream y StreamProtocol para evitar dep directa a libp2p
minga-p2p::network reduce de 282 LOC a 22: re-export del nuevo
BrahmanNet bajo el alias historico LibP2pNode (zero churn en
MingaPeer) y la const SYNC_PROTOCOL = "/minga/sync/1.0.0" especifica
del sub-protocolo de sync Minga.
Aclaracion semantica anclada por el usuario: Arje es el init (PID 1),
Brahman es el encuentro entre Entes. El nombre brahman-net refleja
que la malla pertenece al encuentro, no al runtime — Minga es un
cliente de la malla, no su dueño.
Tests: minga-p2p completo verde (58 tests, sin regresion). Behavior
identico — solo se movio codigo, ningun cambio funcional.
ad0d475a2e8519ebcd72c8707a1fd97118f5f369 · por Sergio · 2026-05-09T12:29:16Z (hace 5 meses) · padre 6f993f426 · ver el árbol en este commit
Delta semántico · 28
crates/modules/semantic_dht/minga-p2p/src/network.rs
crates/shared/brahman-net/src/lib.rs
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f74919..f8305f8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,46 @@ ratio/diff ver `git show <sha>`.
## 2026-05-09
+### feat(brahman-net): capa P2P compartida — Fase 0 (extracción del swarm libp2p)
+Primer paso del plan "el encuentro entre Entes no se restringe a local".
+El swarm libp2p que vivía dentro de `minga-p2p::network` (282 LOC) sale
+a una crate compartida `brahman-net` para que cualquier protocolo de la
+familia (handshake brahman remoto en Fase 1, sync minga, futuros) reuse
+una sola malla TCP+Noise+Yamux+Kad+Identify+Stream.
+
+Diseño:
+- `BrahmanNet::{new, with_keypair}` arma el swarm con DHT en modo
+ Server, Identify auto-poblando el routing table de Kad, y un
+ `stream::Control` accesible para que cada protocolo registre su
+ `StreamProtocol` y abra/acepte streams sin acoplarse al swarm.
+- API de comandos uniforme: `dial`, `listen`, `add_dht_peer`,
+ `find_closest_peers`, `start_providing`, `find_providers`.
+- Pública: `peer_id` (libp2p) + `control` (stream::Control).
+- Re-exporta `Stream` y `StreamProtocol` para que callers no necesiten
+ importar libp2p directo.
+
+Migración:
+- `minga-p2p::network` reduce de 282 LOC a 22: ahora sólo re-exporta
+ `BrahmanNet` bajo el alias histórico `LibP2pNode` (zero churn en
+ `MingaPeer`) y declara la const `SYNC_PROTOCOL = "/minga/sync/1.0.0"`
+ específica del sub-protocolo Minga.
+- Cualquier consumer que necesite armar un nodo P2P puede importar
+ `brahman_net::BrahmanNet` directo sin pasar por minga.
+- Deps de `minga-p2p` ganan `brahman-net`; el resto del grafo
+ (libp2p, libp2p-stream, futures, tokio-util) sigue igual porque
+ `MingaPeer` aún los usa para la lógica específica de sync.
+
+Aclaración semántica anclada por el usuario: **Arje** es el init
+(PID 1, runtime, ente-zero/kernel/soma); **Brahman** es el encuentro
+entre Entes (handshake/broker/card/sidecar/ahora también net). El
+nombre de la crate refleja que la malla pertenece al encuentro, no
+al runtime — Arje puede usar la malla, Minga usa la malla, cualquier
+futuro módulo (Nakui remoto, p.ej.) la usa, sin acoplarse a Minga.
+
+Tests: minga-p2p completo verde (58 tests, sin regresión). Behavior
+verificado idéntico — sólo se movió código, ningún cambio funcional.
+Próximo: Fase 1 (handshake brahman sobre libp2p stream).
+
### refactor(explorer+card): independencia jerárquica enforced — cliente con los wire types + fallback al default path
Cierra el único debt estructural detectado en el audit de
independencia: `nouser-explorer` ya no arrastra `nouser-core`
diff --git a/Cargo.lock b/Cargo.lock
index 4f1aef7..fe9aedd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1210,6 +1210,19 @@ dependencies = [
"ulid",
]
+[[package]]
+name = "brahman-net"
+version = "0.1.0"
+dependencies = [
+ "futures",
+ "libp2p",
+ "libp2p-stream",
+ "thiserror 2.0.18",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
[[package]]
name = "brahman-sidecar"
version = "0.1.0"
@@ -5860,6 +5873,7 @@ dependencies = [
name = "minga-p2p"
version = "0.1.0"
dependencies = [
+ "brahman-net",
"futures",
"libp2p",
"libp2p-stream",
diff --git a/Cargo.toml b/Cargo.toml
index 5530b1f..dcf14fd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,7 @@ members = [
"crates/core/brahman-broker",
"crates/core/brahman-admin",
"crates/shared/brahman-sidecar",
+ "crates/shared/brahman-net",
"crates/core/ente-card",
"crates/core/ente-bus",
"crates/core/ente-cas",
diff --git a/crates/modules/semantic_dht/minga-p2p/Cargo.toml b/crates/modules/semantic_dht/minga-p2p/Cargo.toml
index c94dfbe..f268559 100644
--- a/crates/modules/semantic_dht/minga-p2p/Cargo.toml
+++ b/crates/modules/semantic_dht/minga-p2p/Cargo.toml
@@ -9,6 +9,7 @@ description = "Minga P2P: protocolo de sincronización entre repositorios. Lógi
[dependencies]
minga-core = { path = "../minga-core" }
minga-store = { path = "../minga-store" }
+brahman-net = { path = "../../../shared/brahman-net" }
serde = { workspace = true }
postcard = { workspace = true }
thiserror = { workspace = true }
diff --git a/crates/modules/semantic_dht/minga-p2p/src/network.rs b/crates/modules/semantic_dht/minga-p2p/src/network.rs
index 9aadc83..70c7a10 100644
--- a/crates/modules/semantic_dht/minga-p2p/src/network.rs
+++ b/crates/modules/semantic_dht/minga-p2p/src/network.rs
@@ -1,282 +1,22 @@
-//! Integración libp2p con behaviour compuesto: streams Minga +
-//! Kademlia DHT.
+//! Re-export del nodo de la red Brahman especializado para Minga.
//!
-//! - **TCP + Noise + Yamux**: transporte autenticado y multiplexado.
-//! - **`stream::Behaviour`**: streams bidireccionales para el
-//! protocolo `/minga/sync/1.0.0`.
-//! - **`kad::Behaviour<MemoryStore>`**: tabla de routing distribuida
-//! para descubrimiento. Cada nodo arranca en modo `Server` y
-//! responde a queries del DHT.
+//! Antes este módulo contenía el swarm libp2p completo. Ahora vive en
+//! `brahman-net` (capa P2P compartida con el resto de la familia
+//! brahman: `/brahman/handshake/1.0.0`, futuros sub-protocolos). Este
+//! módulo se reduce a:
//!
-//! El swarm corre en una task tokio dedicada que procesa comandos
-//! externos (Dial, Listen, AddDhtPeer, FindClosestPeers) y eventos
-//! del swarm (NewListenAddr para señalar address resuelto, eventos
-//! Kad para completar queries). Los métodos públicos solo envían
-//! comandos por canal.
+//! - Re-exportar `BrahmanNet` bajo el alias histórico `LibP2pNode`
+//! para zero churn en `MingaPeer`.
+//! - Declarar la const `SYNC_PROTOCOL` específica de Minga
+//! (`/minga/sync/1.0.0`).
+//!
+//! Cualquier consumer que necesite armar un nodo P2P puede importar
+//! `brahman_net::BrahmanNet` directo y registrar sus propios protocolos
+//! sin pasar por minga.
-use std::collections::HashMap;
-use std::time::Duration;
+pub use brahman_net::{BrahmanNet as LibP2pNode, DiscoveredPeer, NodeError};
-use futures::StreamExt;
-use libp2p::{
- identify, identity, kad, noise,
- swarm::{NetworkBehaviour, SwarmEvent},
- tcp, yamux, Multiaddr, PeerId, StreamProtocol, Swarm, SwarmBuilder,
-};
-use libp2p_stream as stream;
-use tokio::sync::{mpsc, oneshot, Mutex};
+use libp2p::StreamProtocol;
+/// Sub-protocolo de sync Minga sobre la malla brahman-net.
pub const SYNC_PROTOCOL: StreamProtocol = StreamProtocol::new("/minga/sync/1.0.0");
-const IDENTIFY_PROTOCOL: &str = "/minga/0.1.0";
-
-#[derive(NetworkBehaviour)]
-struct MingaBehaviour {
- stream: stream::Behaviour,
- kad: kad::Behaviour<kad::store::MemoryStore>,
- identify: identify::Behaviour,
-}
-
-#[derive(Debug, thiserror::Error)]
-pub enum NodeError {
- #[error("transport build failed: {0}")]
- Build(String),
-}
-
-#[derive(Debug)]
-enum Command {
- Dial(Multiaddr),
- Listen(Multiaddr),
- AddDhtPeer(PeerId, Multiaddr),
- FindClosestPeers(PeerId, oneshot::Sender<Vec<DiscoveredPeer>>),
- StartProviding(Vec<u8>),
- GetProviders(Vec<u8>, oneshot::Sender<Vec<PeerId>>),
-}
-
-/// Peer descubierto vía DHT: identidad + direcciones conocidas.
-#[derive(Debug, Clone)]
-pub struct DiscoveredPeer {
- pub peer_id: PeerId,
- pub addrs: Vec<Multiaddr>,
-}
-
-pub struct LibP2pNode {
- pub peer_id: PeerId,
- cmd_tx: mpsc::UnboundedSender<Command>,
- listen_rx: Mutex<mpsc::UnboundedReceiver<Multiaddr>>,
- /// Control para abrir/aceptar streams.
- pub control: stream::Control,
-}
-
-impl LibP2pNode {
- pub fn new() -> Result<Self, NodeError> {
- let id = identity::Keypair::generate_ed25519();
- let peer_id = id.public().to_peer_id();
-
- let mut swarm: Swarm<MingaBehaviour> = SwarmBuilder::with_existing_identity(id)
- .with_tokio()
- .with_tcp(
- tcp::Config::default(),
- noise::Config::new,
- yamux::Config::default,
- )
- .map_err(|e| NodeError::Build(format!("{e}")))?
- .with_behaviour(|key| {
- let local = key.public().to_peer_id();
- let mut kad =
- kad::Behaviour::new(local, kad::store::MemoryStore::new(local));
- // Modo Server: respondemos a queries del DHT. Por
- // defecto kad arranca en Auto, que requiere detectar
- // reachability. Para tests en localhost forzamos Server.
- kad.set_mode(Some(kad::Mode::Server));
- let identify = identify::Behaviour::new(
- identify::Config::new(IDENTIFY_PROTOCOL.to_string(), key.public())
- .with_agent_version(format!("minga/{}", env!("CARGO_PKG_VERSION"))),
- );
- MingaBehaviour {
- stream: stream::Behaviour::new(),
- kad,
- identify,
- }
- })
- .map_err(|e| NodeError::Build(format!("{e}")))?
- .with_swarm_config(|c| c.with_idle_connection_timeout(Duration::from_secs(60)))
- .build();
-
- let control = swarm.behaviour().stream.new_control();
-
- let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<Command>();
- let (listen_tx, listen_rx) = mpsc::unbounded_channel::<Multiaddr>();
-
- tokio::spawn(async move {
- let mut pending_finds: HashMap<
- kad::QueryId,
- oneshot::Sender<Vec<DiscoveredPeer>>,
- > = HashMap::new();
- let mut pending_providers: HashMap<
- kad::QueryId,
- (Vec<PeerId>, oneshot::Sender<Vec<PeerId>>),
- > = HashMap::new();
-
- loop {
- tokio::select! {
- Some(cmd) = cmd_rx.recv() => {
- match cmd {
- Command::Dial(addr) => {
- let _ = swarm.dial(addr);
- }
- Command::Listen(addr) => {
- let _ = swarm.listen_on(addr);
- }
- Command::AddDhtPeer(peer, addr) => {
- swarm.behaviour_mut().kad.add_address(&peer, addr);
- }
- Command::FindClosestPeers(target, tx) => {
- let qid = swarm.behaviour_mut().kad.get_closest_peers(target);
- pending_finds.insert(qid, tx);
- }
- Command::StartProviding(key) => {
- // Best-effort: si falla (sin peers cercanos para
- // replicar), seguirá viviendo en el local store
- // y se servirá vía get_providers de quien
- // tenga conexión con nosotros.
- let _ = swarm.behaviour_mut().kad.start_providing(key.into());
- }
- Command::GetProviders(key, tx) => {
- let qid = swarm.behaviour_mut().kad.get_providers(key.into());
- pending_providers.insert(qid, (Vec::new(), tx));
- }
- }
- }
- event = swarm.select_next_some() => {
- match event {
- SwarmEvent::NewListenAddr { address, .. } => {
- let _ = listen_tx.send(address);
- }
- // Identify nos dice las listen-addrs reales del
- // peer. Las inyectamos a Kad para poblar el
- // routing table sin necesidad de add_dht_peer
- // manual — la propagación pasa a ser automática.
- SwarmEvent::Behaviour(MingaBehaviourEvent::Identify(
- identify::Event::Received { peer_id, info, .. }
- )) => {
- for addr in info.listen_addrs {
- swarm.behaviour_mut().kad.add_address(&peer_id, addr);
- }
- }
- SwarmEvent::Behaviour(MingaBehaviourEvent::Kad(
- kad::Event::OutboundQueryProgressed { id, result, step, .. }
- )) => {
- match result {
- kad::QueryResult::GetClosestPeers(Ok(ok)) if step.last => {
- if let Some(tx) = pending_finds.remove(&id) {
- let infos = ok.peers.into_iter()
- .map(|p| DiscoveredPeer {
- peer_id: p.peer_id,
- addrs: p.addrs,
- })
- .collect();
- let _ = tx.send(infos);
- }
- }
- kad::QueryResult::GetClosestPeers(Err(_)) if step.last => {
- if let Some(tx) = pending_finds.remove(&id) {
- let _ = tx.send(Vec::new());
- }
- }
- kad::QueryResult::GetProviders(Ok(ok)) => {
- if let Some((collected, _)) =
- pending_providers.get_mut(&id)
- {
- if let kad::GetProvidersOk::FoundProviders {
- providers, ..
- } = ok
- {
- for p in providers {
- if !collected.contains(&p) {
- collected.push(p);
- }
- }
- }
- }
- if step.last {
- if let Some((providers, tx)) =
- pending_providers.remove(&id)
- {
- let _ = tx.send(providers);
- }
- }
- }
- kad::QueryResult::GetProviders(Err(_)) if step.last => {
- if let Some((providers, tx)) =
- pending_providers.remove(&id)
- {
- let _ = tx.send(providers);
- }
- }
- _ => {}
- }
- }
- _ => {}
- }
- }
- }
- }
- });
-
- Ok(Self {
- peer_id,
- cmd_tx,
- listen_rx: Mutex::new(listen_rx),
- control,
- })
- }
-
- pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
- self.cmd_tx
- .send(Command::Listen(addr))
- .expect("swarm task alive");
- let mut rx = self.listen_rx.lock().await;
- rx.recv().await.expect("listen address arrives")
- }
-
- pub fn dial(&self, addr: Multiaddr) {
- let _ = self.cmd_tx.send(Command::Dial(addr));
- }
-
- /// Añade un peer al routing table de Kademlia. Punto de entrada
- /// para bootstrap: tras esto, el nodo puede dirigir queries DHT
- /// a través de este peer.
- pub fn add_dht_peer(&self, peer: PeerId, addr: Multiaddr) {
- let _ = self.cmd_tx.send(Command::AddDhtPeer(peer, addr));
- }
-
- /// Consulta el DHT por los peers más cercanos al `target` PeerId.
- /// Devuelve la lista resuelta (vacía si la query falla o si no
- /// hay peers conocidos). Bloquea hasta que la query completa.
- pub async fn find_closest_peers(&self, target: PeerId) -> Vec<DiscoveredPeer> {
- let (tx, rx) = oneshot::channel();
- let _ = self
- .cmd_tx
- .send(Command::FindClosestPeers(target, tx));
- rx.await.unwrap_or_default()
- }
-
- /// Anuncia en el DHT que este peer tiene el contenido identificado
- /// por `key`. Otros peers pueden luego descubrirlo vía
- /// `find_providers(key)`. Best-effort: si la replicación falla
- /// inicialmente, el record vive en el store local.
- pub fn start_providing(&self, key: &[u8]) {
- let _ = self.cmd_tx.send(Command::StartProviding(key.to_vec()));
- }
-
- /// Consulta el DHT por peers que han anunciado proveer `key`.
- /// Devuelve la lista de `PeerId`s que se reportan como providers.
- /// Lista vacía si nadie anuncia.
- pub async fn find_providers(&self, key: &[u8]) -> Vec<PeerId> {
- let (tx, rx) = oneshot::channel();
- let _ = self
- .cmd_tx
- .send(Command::GetProviders(key.to_vec(), tx));
- rx.await.unwrap_or_default()
- }
-}
diff --git a/crates/shared/brahman-net/Cargo.toml b/crates/shared/brahman-net/Cargo.toml
new file mode 100644
index 0000000..c4625f5
--- /dev/null
+++ b/crates/shared/brahman-net/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "brahman-net"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+authors.workspace = true
+publish.workspace = true
+description = "Brahman — capa de transporte P2P compartida (libp2p TCP+Noise+Yamux+Kad+Identify+Stream). Cualquier protocolo (handshake brahman, sync minga, futuros) puede registrar su StreamProtocol y abrir/aceptar streams sobre la malla común."
+
+[dependencies]
+futures = { workspace = true }
+libp2p = { workspace = true }
+libp2p-stream = { workspace = true }
+thiserror = { workspace = true }
+tokio = { workspace = true }
+tracing = { workspace = true }
+
+[dev-dependencies]
+tokio = { workspace = true }
+tokio-util = { workspace = true }
diff --git a/crates/shared/brahman-net/src/lib.rs b/crates/shared/brahman-net/src/lib.rs
new file mode 100644
index 0000000..f8b5a98
--- /dev/null
+++ b/crates/shared/brahman-net/src/lib.rs
@@ -0,0 +1,337 @@
+//! `brahman-net` — capa P2P compartida de la red Brahman.
+//!
+//! Provee un nodo libp2p genérico que cualquier protocolo de la
+//! familia (handshake brahman remoto, sync minga, futuros) puede
+//! reusar. La idea: una sola malla, múltiples sub-protocolos
+//! multiplexados por `StreamProtocol`.
+//!
+//! ## Stack
+//!
+//! - **TCP + Noise + Yamux**: transporte autenticado y multiplexado.
+//! - **`stream::Behaviour`**: streams bidireccionales por
+//! `StreamProtocol`. Cada protocolo (`/brahman/handshake/1.0.0`,
+//! `/minga/sync/1.0.0`, …) se registra independientemente vía el
+//! `stream::Control` que `BrahmanNet` expone.
+//! - **`kad::Behaviour<MemoryStore>`**: Kademlia DHT en modo Server
+//! para discovery (peers cercanos + content providers).
+//! - **`identify::Behaviour`**: cada peer anuncia sus listen-addrs
+//! reales; las inyectamos automáticamente al routing table de Kad.
+//!
+//! ## Modelo
+//!
+//! El swarm corre en una task tokio dedicada. La interfaz pública son:
+//! 1. **Comandos** (canal mpsc): `dial`, `listen`, `add_dht_peer`,
+//! `find_closest_peers`, `start_providing`, `find_providers`.
+//! 2. **`stream::Control`** (acceso directo): para abrir/aceptar
+//! streams de un protocolo concreto. Cada protocolo se ocupa de
+//! su propia lógica sobre el stream resultante.
+//!
+//! La separación entre comandos y control permite que la lógica de
+//! red (DHT, dial, listen) y la lógica de protocolos (handshake/sync)
+//! evolucionen independientes — el protocolo no necesita conocer al
+//! swarm, sólo pide streams.
+//!
+//! ## Identidad
+//!
+//! Por defecto se genera una keypair Ed25519 efímera. Para identidad
+//! persistente (la misma `peer_id` across reboots), pasar la keypair
+//! con [`BrahmanNet::with_keypair`]. Esa misma keypair puede ser la
+//! base para firmas de Cards (cuando se implemente trust remoto).
+
+#![forbid(unsafe_code)]
+#![warn(rust_2018_idioms)]
+
+use std::collections::HashMap;
+use std::time::Duration;
+
+use futures::StreamExt;
+use libp2p::{
+ identify, identity, kad, noise,
+ swarm::{NetworkBehaviour, SwarmEvent},
+ tcp, yamux, Multiaddr, PeerId, Swarm, SwarmBuilder,
+};
+use libp2p_stream as stream;
+use tokio::sync::{mpsc, oneshot, Mutex};
+
+pub use libp2p::{Stream, StreamProtocol};
+
+const IDENTIFY_PROTOCOL: &str = "/brahman-net/0.1.0";
+const IDLE_CONNECTION_TIMEOUT: Duration = Duration::from_secs(60);
+
+#[derive(NetworkBehaviour)]
+struct BrahmanBehaviour {
+ stream: stream::Behaviour,
+ kad: kad::Behaviour<kad::store::MemoryStore>,
+ identify: identify::Behaviour,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum NodeError {
+ #[error("transport build failed: {0}")]
+ Build(String),
+}
+
+#[derive(Debug)]
+enum Command {
+ Dial(Multiaddr),
+ Listen(Multiaddr),
+ AddDhtPeer(PeerId, Multiaddr),
+ FindClosestPeers(PeerId, oneshot::Sender<Vec<DiscoveredPeer>>),
+ StartProviding(Vec<u8>),
+ GetProviders(Vec<u8>, oneshot::Sender<Vec<PeerId>>),
+}
+
+/// Peer descubierto vía DHT: identidad + direcciones conocidas.
+#[derive(Debug, Clone)]
+pub struct DiscoveredPeer {
+ pub peer_id: PeerId,
+ pub addrs: Vec<Multiaddr>,
+}
+
+/// Nodo Brahman en la malla P2P. Maneja el swarm libp2p y expone
+/// API uniforme para listen/dial/DHT/streams.
+pub struct BrahmanNet {
+ /// Identidad libp2p de este nodo. Estable mientras viva la
+ /// keypair (efímera por default; persistente si pasaste una
+ /// vía [`with_keypair`]).
+ pub peer_id: PeerId,
+ cmd_tx: mpsc::UnboundedSender<Command>,
+ listen_rx: Mutex<mpsc::UnboundedReceiver<Multiaddr>>,
+ /// Control para abrir y aceptar streams. Cada protocolo
+ /// (handshake brahman, sync minga, etc.) llama
+ /// `control.accept(StreamProtocol::new("/foo/1.0.0"))` para
+ /// recibir streams entrantes, o `control.open_stream(peer, proto)`
+ /// para abrirlos. Multiplexado y demultiplexado lo hace libp2p.
+ pub control: stream::Control,
+}
+
+impl BrahmanNet {
+ /// Crea un nodo con keypair Ed25519 generada al vuelo (peer_id
+ /// efímero — cambia en cada arranque).
+ pub fn new() -> Result<Self, NodeError> {
+ Self::with_keypair(identity::Keypair::generate_ed25519())
+ }
+
+ /// Crea un nodo con una keypair libp2p específica. Usá esto para
+ /// `peer_id` estable (por ejemplo si tu identidad se persiste a
+ /// disco, o si la derivás de la identidad criptográfica del
+ /// módulo).
+ pub fn with_keypair(keypair: identity::Keypair) -> Result<Self, NodeError> {
+ let peer_id = keypair.public().to_peer_id();
+
+ let mut swarm: Swarm<BrahmanBehaviour> = SwarmBuilder::with_existing_identity(keypair)
+ .with_tokio()
+ .with_tcp(
+ tcp::Config::default(),
+ noise::Config::new,
+ yamux::Config::default,
+ )
+ .map_err(|e| NodeError::Build(format!("{e}")))?
+ .with_behaviour(|key| {
+ let local = key.public().to_peer_id();
+ let mut kad =
+ kad::Behaviour::new(local, kad::store::MemoryStore::new(local));
+ // Modo Server: respondemos a queries del DHT. Auto
+ // requiere detectar reachability; para entornos
+ // controlados (localhost, redes privadas) Server es
+ // lo correcto.
+ kad.set_mode(Some(kad::Mode::Server));
+ let identify = identify::Behaviour::new(
+ identify::Config::new(IDENTIFY_PROTOCOL.to_string(), key.public())
+ .with_agent_version(format!("brahman-net/{}", env!("CARGO_PKG_VERSION"))),
+ );
+ BrahmanBehaviour {
+ stream: stream::Behaviour::new(),
+ kad,
+ identify,
+ }
+ })
+ .map_err(|e| NodeError::Build(format!("{e}")))?
+ .with_swarm_config(|c| c.with_idle_connection_timeout(IDLE_CONNECTION_TIMEOUT))
+ .build();
+
+ let control = swarm.behaviour().stream.new_control();
+
+ let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<Command>();
+ let (listen_tx, listen_rx) = mpsc::unbounded_channel::<Multiaddr>();
+
+ tokio::spawn(async move {
+ let mut pending_finds: HashMap<
+ kad::QueryId,
+ oneshot::Sender<Vec<DiscoveredPeer>>,
+ > = HashMap::new();
+ let mut pending_providers: HashMap<
+ kad::QueryId,
+ (Vec<PeerId>, oneshot::Sender<Vec<PeerId>>),
+ > = HashMap::new();
+
+ loop {
+ tokio::select! {
+ Some(cmd) = cmd_rx.recv() => {
+ match cmd {
+ Command::Dial(addr) => {
+ let _ = swarm.dial(addr);
+ }
+ Command::Listen(addr) => {
+ let _ = swarm.listen_on(addr);
+ }
+ Command::AddDhtPeer(peer, addr) => {
+ swarm.behaviour_mut().kad.add_address(&peer, addr);
+ }
+ Command::FindClosestPeers(target, tx) => {
+ let qid = swarm.behaviour_mut().kad.get_closest_peers(target);
+ pending_finds.insert(qid, tx);
+ }
+ Command::StartProviding(key) => {
+ // Best-effort: si falla (sin peers cercanos para
+ // replicar), seguirá viviendo en el local store
+ // y se servirá vía get_providers de quien tenga
+ // conexión con nosotros.
+ let _ = swarm.behaviour_mut().kad.start_providing(key.into());
+ }
+ Command::GetProviders(key, tx) => {
+ let qid = swarm.behaviour_mut().kad.get_providers(key.into());
+ pending_providers.insert(qid, (Vec::new(), tx));
+ }
+ }
+ }
+ event = swarm.select_next_some() => {
+ match event {
+ SwarmEvent::NewListenAddr { address, .. } => {
+ let _ = listen_tx.send(address);
+ }
+ // Identify nos dice las listen-addrs reales del
+ // peer. Las inyectamos a Kad para poblar el
+ // routing table sin necesidad de add_dht_peer
+ // manual — la propagación pasa a ser automática.
+ SwarmEvent::Behaviour(BrahmanBehaviourEvent::Identify(
+ identify::Event::Received { peer_id, info, .. }
+ )) => {
+ for addr in info.listen_addrs {
+ swarm.behaviour_mut().kad.add_address(&peer_id, addr);
+ }
+ }
+ SwarmEvent::Behaviour(BrahmanBehaviourEvent::Kad(
+ kad::Event::OutboundQueryProgressed { id, result, step, .. }
+ )) => {
+ match result {
+ kad::QueryResult::GetClosestPeers(Ok(ok)) if step.last => {
+ if let Some(tx) = pending_finds.remove(&id) {
+ let infos = ok.peers.into_iter()
+ .map(|p| DiscoveredPeer {
+ peer_id: p.peer_id,
+ addrs: p.addrs,
+ })
+ .collect();
+ let _ = tx.send(infos);
+ }
+ }
+ kad::QueryResult::GetClosestPeers(Err(_)) if step.last => {
+ if let Some(tx) = pending_finds.remove(&id) {
+ let _ = tx.send(Vec::new());
+ }
+ }
+ kad::QueryResult::GetProviders(Ok(ok)) => {
+ if let Some((collected, _)) =
+ pending_providers.get_mut(&id)
+ {
+ if let kad::GetProvidersOk::FoundProviders {
+ providers, ..
+ } = ok
+ {
+ for p in providers {
+ if !collected.contains(&p) {
+ collected.push(p);
+ }
+ }
+ }
+ }
+ if step.last {
+ if let Some((providers, tx)) =
+ pending_providers.remove(&id)
+ {
+ let _ = tx.send(providers);
+ }
+ }
+ }
+ kad::QueryResult::GetProviders(Err(_)) if step.last => {
+ if let Some((providers, tx)) =
+ pending_providers.remove(&id)
+ {
+ let _ = tx.send(providers);
+ }
+ }
+ _ => {}
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+ });
+
+ Ok(Self {
+ peer_id,
+ cmd_tx,
+ listen_rx: Mutex::new(listen_rx),
+ control,
+ })
+ }
+
+ /// Empieza a escuchar en `addr`. Bloquea hasta que el listener
+ /// publique su dirección real (Multiaddr resuelta — útil cuando
+ /// pediste `/ip4/0.0.0.0/tcp/0` y querés saber qué puerto te tocó).
+ pub async fn listen(&self, addr: Multiaddr) -> Multiaddr {
+ self.cmd_tx
+ .send(Command::Listen(addr))
+ .expect("swarm task alive");
+ let mut rx = self.listen_rx.lock().await;
+ rx.recv().await.expect("listen address arrives")
+ }
+
+ /// Inicia conexión con un peer en `addr`. No-op si ya hay
+ /// conexión. Best-effort — fallos se loggean al swarm pero no se
+ /// propagan al caller (consistente con libp2p).
+ pub fn dial(&self, addr: Multiaddr) {
+ let _ = self.cmd_tx.send(Command::Dial(addr));
+ }
+
+ /// Añade un peer al routing table de Kademlia. Punto de entrada
+ /// para bootstrap: tras esto, el nodo puede dirigir queries DHT
+ /// a través de este peer.
+ pub fn add_dht_peer(&self, peer: PeerId, addr: Multiaddr) {
+ let _ = self.cmd_tx.send(Command::AddDhtPeer(peer, addr));
+ }
+
+ /// Consulta el DHT por los peers más cercanos al `target` PeerId.
+ /// Devuelve la lista resuelta (vacía si la query falla o si no
+ /// hay peers conocidos). Bloquea hasta que la query completa.
+ pub async fn find_closest_peers(&self, target: PeerId) -> Vec<DiscoveredPeer> {
+ let (tx, rx) = oneshot::channel();
+ let _ = self
+ .cmd_tx
+ .send(Command::FindClosestPeers(target, tx));
+ rx.await.unwrap_or_default()
+ }
+
+ /// Anuncia en el DHT que este peer tiene el contenido identificado
+ /// por `key`. Otros peers pueden luego descubrirlo vía
+ /// [`find_providers`](Self::find_providers). Best-effort: si la
+ /// replicación falla inicialmente, el record vive en el store
+ /// local hasta que llegue conexión.
+ pub fn start_providing(&self, key: &[u8]) {
+ let _ = self.cmd_tx.send(Command::StartProviding(key.to_vec()));
+ }
+
+ /// Consulta el DHT por peers que han anunciado proveer `key`.
+ /// Devuelve la lista de `PeerId`s que se reportan como providers.
+ /// Lista vacía si nadie anuncia.
+ pub async fn find_providers(&self, key: &[u8]) -> Vec<PeerId> {
+ let (tx, rx) = oneshot::channel();
+ let _ = self
+ .cmd_tx
+ .send(Command::GetProviders(key.to_vec(), tx));
+ rx.await.unwrap_or_default()
+ }
+}