feat(brahman-handshake): Fase 2 — discovery remoto via DHT por flow type

Tercer paso del plan "el encuentro entre Entes no se restringe a
local". Cuando un Init local acepta una sesion cuya Card declara
outputs, anuncia al DHT (Kademlia, via brahman-net) que el provee
esos flow types. Cualquier nodo conectado al mismo DHT puede
consultar y obtener la lista de PeerId's que sirven el flow.

API nueva en brahman_handshake::network:
- flow_dht_key(flow_name, type_ref) -> [u8; 32]: blake3 hash de
  "brahman-flow|v1|{flow}|{type_canon}". Determinista cross-host.
  Cambiar la canonicalizacion rompe compatibilidad — el prefijo v1
  documenta version del esquema y obliga a bump al modificar.
- announce_outputs(net, card): start_providing por cada flow.output.
  Idempotente, fire-and-forget.
- find_remote_providers(net, flow_name, type_ref) -> Vec<PeerId>:
  query DHT. Lista vacia si nadie anuncia.

Wire en el server:
- ServerConfig gana pub net: Option<Arc<BrahmanNet>>. Si esta set,
  cada Card registrada con outputs se anuncia automaticamente al DHT
  desde register_session. None = server "ciego al DHT".
- Debug manual de ServerConfig (BrahmanNet no es Debug).

Canonicalizacion del TypeRef:
- Primitive { name } -> "prim:{name}"
- Wit { package, interface, name } -> "wit:{package}#{interface_or_empty}#{name}"

Tests: 2 nuevos en tests/network_discovery.rs:
- dht_discovery_finds_remote_provider: 2 nodos, A registra Card con
  flow.output = monad-list:json, B dial-ea a A, B llama
  find_remote_providers y descubre el peer_id de A.
- dht_discovery_negative_unknown_flow: B busca flow inexistente,
  devuelve [] sin colgarse.

Callers actualizados con net: None: tests existentes + ente-zero
(arje aun no expone red; pasar Some(Arc<BrahmanNet>) cuando quiera
publicar al DHT remoto).

Lo que esto desbloquea: un nouser daemon en maquina A puede ser
descubierto por nouser-explorer en maquina B sin conocimiento previo
del peer — solo necesitan compartir DHT (via bootstrap inicial).

Pendiente para Fase 3: trust (firma Ed25519 en Cards remotas) +
stop_providing al cleanup de sesion.
This commit is contained in:
Sergio
2026-05-09 14:37:15 +00:00
parent 73dadbb166
commit 2059af4fb9
9 changed files with 412 additions and 4 deletions
@@ -58,7 +58,7 @@ fn sock_path(name: &str) -> std::path::PathBuf {
#[tokio::test]
async fn full_handshake_roundtrip() {
let path = sock_path("happy");
let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None }).unwrap();
let server = Server::bind(&path, ServerConfig { init_attached: true, broker: None, net: None }).unwrap();
let session_handle = tokio::spawn({
async move {
@@ -193,6 +193,7 @@ async fn broker_registers_and_unregisters_with_session() {
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
},
)
.unwrap();
@@ -235,6 +236,7 @@ async fn broker_matches_two_live_modules() {
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
},
)
.unwrap();
@@ -310,6 +312,7 @@ async fn match_event_pushed_on_producer_arrival() {
ServerConfig {
init_attached: false,
broker: Some(broker.clone()),
net: None,
},
)
.unwrap();
@@ -0,0 +1,230 @@
//! 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 `nouser 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
},
)
.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()),
},
)
.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();
}
@@ -55,6 +55,7 @@ async fn libp2p_handshake_roundtrip() {
ServerConfig {
init_attached: true,
broker: Some(broker.clone()),
net: None,
},
)
.unwrap(),