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:
@@ -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"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user