diff --git a/CHANGELOG.md b/CHANGELOG.md index 09b6fad..a0a0e3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,80 @@ ratio/diff ver `git show `. ## 2026-05-09 +### feat(ente-zero): wire de Arje con brahman-net (red P2P opcional + identidad persistente) +Cierra el último pendiente del plan de red: Arje ahora puede arrancar +opcionalmente con `BrahmanNet` configurado, persistir su identidad +libp2p entre reboots, y participar en la malla brahman como nodo +público. Sin breaking changes: usuarios actuales (sin env vars) siguen +viendo el comportamiento Unix-only de antes. + +Activación por env vars: +- **`BRAHMAN_LISTEN_MULTIADDR`** — si set, activa la red P2P. Ej: + `/ip4/0.0.0.0/tcp/4101` (público), `/ip4/127.0.0.1/tcp/0` (loopback, + port aleatorio). Sin la var, `brahman_net = None` y todo sigue + como antes. +- **`BRAHMAN_KEYPAIR_PATH`** — override del path donde se persiste + la keypair Ed25519 de identidad libp2p del nodo. Defaults sensatos: + - PID 1 (root): `/var/lib/brahman/init-keypair.bin`. + - Dev mode: `$XDG_DATA_HOME/brahman/init-keypair.bin` → + `$HOME/.local/share/brahman/init-keypair.bin` → + `/tmp/brahman-init-keypair.bin` (último recurso). +- **`BRAHMAN_BOOTSTRAP_PEERS`** — lista coma-separada de multiaddrs + para dial-ear al arranque y entrar al DHT. Sin esto, el nodo + arranca aislado hasta que alguien se conecte a él. + +Comportamiento al activarse: +1. `keypair_store::load_or_generate(path)` carga la keypair de disco + o genera+persiste una nueva (32 bytes raw, permisos 0o600, + atomic rename). Reboots conservan el `peer_id`. +2. `BrahmanNet::with_keypair(kp)` arma el swarm con esa identidad. +3. `net.listen(multiaddr)` espera dirección resuelta y la loggea. +4. `BRAHMAN_BOOTSTRAP_PEERS` (si set) → dial a cada multiaddr. +5. El handshake server se levanta con `ServerConfig.net = Some(net)`, + que activa `announce_outputs` automático en el DHT por cada Card + con outputs. +6. Además del Unix accept loop (existing), se monta un libp2p accept + loop sobre el mismo `Server` compartido. Sesiones locales y + remotas conviven en las mismas tablas (sessions, push_table, + broker, last_matches). + +Refactor del Unix accept loop: antes consumía el server vía +`server.run().await`; ahora usa `Arc::accept_one().await` en +loop para coexistir con el libp2p accept loop sin moverse el server. + +Degradación grácil en cada paso: si la keypair no carga, si el +multiaddr es inválido, si el listen falla, si el bootstrap dial +revienta — loggeamos y seguimos en modo Unix-only. La doctrina de +PID 1 ("ningún subsistema opcional rompe el bucle primordial") se +mantiene. + +Tests: 4 unit en `keypair_store`: +- `generate_persist_and_reload_yields_same_peer_id` — peer_id estable + across reloads (la propiedad fundamental). +- `rejects_corrupted_file` — archivo de tamaño incorrecto rechazado. +- `persisted_file_is_owner_only` — permisos 0o600 verificados. +- `default_path_honors_env` — `BRAHMAN_KEYPAIR_PATH` override + respeta tanto dev como root mode. + +Ente-zero compila clean. Ningún test del workspace regresa. + +Lo que esto desbloquea hoy: +- Para activar Arje como nodo público, basta: + ```sh + BRAHMAN_LISTEN_MULTIADDR=/ip4/0.0.0.0/tcp/4101 ente-zero + ``` +- Cualquier consumer (en otra máquina) puede luego dial-ar a ese + multiaddr + descubrir Cards anunciadas via DHT + abrir handshake + remoto firmado. +- La identidad del nodo (su `peer_id`) sobrevive reboots, así que + los nodos remotos pueden cachear "este peer_id es Arje en + máquina X" sin invalidarse cada vez. + +Pendientes futuros: +- `stop_providing` al cleanup de sesión (records DHT con TTL ~24h). +- Allowlist/Denylist de peers (PKI explícito). +- Rotación de keypair sin perder peer_id (multi-key identity). + ### feat(brahman-handshake): Fase 3 — trust remoto vía firma Ed25519 anclada al peer libp2p Cuarto y último paso del plan "el encuentro entre Entes no se restringe a local". Cierra la falla de seguridad que dejaba la red diff --git a/Cargo.lock b/Cargo.lock index 258a2f8..60e809c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2969,6 +2969,7 @@ dependencies = [ "brahman-admin", "brahman-broker", "brahman-handshake", + "brahman-net", "ente-brain", "ente-bus", "ente-card", @@ -2982,6 +2983,7 @@ dependencies = [ "nix 0.29.0", "serde", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", diff --git a/crates/core/ente-zero/Cargo.toml b/crates/core/ente-zero/Cargo.toml index 8ea901a..b6f4d89 100644 --- a/crates/core/ente-zero/Cargo.toml +++ b/crates/core/ente-zero/Cargo.toml @@ -25,6 +25,7 @@ ente-echo = { path = "../ente-echo" } # solo para constantes del demo brahman-handshake = { path = "../brahman-handshake" } brahman-broker = { path = "../brahman-broker" } brahman-admin = { path = "../brahman-admin" } +brahman-net = { path = "../../shared/brahman-net" } # Runtime / utilidades de PID 1 serde = { workspace = true } @@ -36,3 +37,6 @@ libc = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } diff --git a/crates/core/ente-zero/src/keypair_store.rs b/crates/core/ente-zero/src/keypair_store.rs new file mode 100644 index 0000000..56c24e4 --- /dev/null +++ b/crates/core/ente-zero/src/keypair_store.rs @@ -0,0 +1,184 @@ +//! Persistencia de la keypair Ed25519 de identidad libp2p de Arje. +//! +//! El `peer_id` que Arje presenta en la malla `brahman-net` deriva de +//! esta keypair. Si se regenera en cada arranque, el peer_id cambia +//! y los nodos remotos pierden la referencia. Persistir el secret a +//! disco (32 bytes raw, permisos 0o600) garantiza identidad estable. +//! +//! ## Path +//! +//! Por orden de precedencia: +//! 1. `BRAHMAN_KEYPAIR_PATH` env var (override explícito). +//! 2. Si PID 1 / root: `/var/lib/brahman/init-keypair.bin`. +//! 3. Si dev mode: `$XDG_DATA_HOME/brahman/init-keypair.bin`, fallback +//! a `$HOME/.local/share/brahman/init-keypair.bin`, último recurso +//! `/tmp/brahman-init-keypair.bin` (sin persistencia útil pero al +//! menos no rompe en CI minimalista). +//! +//! ## Formato +//! +//! 32 bytes raw del secret Ed25519. Sin header, sin metadata. La +//! public key se deriva determinísticamente al cargar. Esto evita +//! depender de un schema de serialización (postcard, json) que +//! pudiera bumpear y romper compat de identidad. + +use std::path::{Path, PathBuf}; + +use anyhow::{bail, Context, Result}; +use brahman_net::Keypair; + +/// Tamaño exacto del secret Ed25519. +const SECRET_LEN: usize = 32; + +/// Carga la keypair desde `path` si existe, o genera una nueva, +/// la persiste y la devuelve. Devuelve también si fue cargada (true) +/// o generada (false), para logging. +pub fn load_or_generate(path: &Path) -> Result<(Keypair, bool)> { + if path.exists() { + let bytes = std::fs::read(path) + .with_context(|| format!("leer keypair de {}", path.display()))?; + if bytes.len() != SECRET_LEN { + bail!( + "keypair en {} tiene {} bytes, esperaba {}", + path.display(), + bytes.len(), + SECRET_LEN + ); + } + let mut secret = [0u8; SECRET_LEN]; + secret.copy_from_slice(&bytes); + let kp = Keypair::ed25519_from_bytes(secret) + .with_context(|| format!("decodificar keypair en {}", path.display()))?; + Ok((kp, true)) + } else { + let kp = Keypair::generate_ed25519(); + save(path, &kp).context("persistir keypair recién generada")?; + Ok((kp, false)) + } +} + +/// Persiste el secret de `keypair` a `path`. Crea directorios padres, +/// escribe atómico (vía rename), y aplica permisos 0o600 (sólo dueño). +fn save(path: &Path, keypair: &Keypair) -> Result<()> { + let secret = extract_secret_bytes(keypair)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("crear dir {}", parent.display()))?; + } + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, secret).with_context(|| format!("write tmp {}", tmp.display()))?; + apply_owner_only_perms(&tmp).context("permisos 0o600 en tmp")?; + std::fs::rename(&tmp, path) + .with_context(|| format!("rename {} → {}", tmp.display(), path.display()))?; + Ok(()) +} + +fn extract_secret_bytes(keypair: &Keypair) -> Result<[u8; SECRET_LEN]> { + // libp2p::Keypair no expone secret() directo; pasamos + // por la variante ed25519. Solo Ed25519 soportado en brahman-net, + // así que el unwrap es seguro tras with_keypair. + let ed = keypair + .clone() + .try_into_ed25519() + .map_err(|_| anyhow::anyhow!("la keypair no es Ed25519 (no debería pasar)"))?; + let bytes = ed.secret(); + let raw: &[u8] = bytes.as_ref(); + if raw.len() != SECRET_LEN { + bail!("ed25519 secret no es {} bytes", SECRET_LEN); + } + let mut out = [0u8; SECRET_LEN]; + out.copy_from_slice(raw); + Ok(out) +} + +#[cfg(unix)] +fn apply_owner_only_perms(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(path, perms) +} + +#[cfg(not(unix))] +fn apply_owner_only_perms(_path: &Path) -> std::io::Result<()> { + Ok(()) +} + +/// Resuelve el path del keystore según convención (env > root path > +/// XDG > HOME > tmp). +pub fn default_path(dev_mode: bool) -> PathBuf { + if let Ok(p) = std::env::var("BRAHMAN_KEYPAIR_PATH") { + return PathBuf::from(p); + } + + if !dev_mode { + // PID 1: paths del sistema. /var/lib es el lugar canónico + // para state persistente de servicios root. + return PathBuf::from("/var/lib/brahman/init-keypair.bin"); + } + + // Dev mode: paths de usuario. + if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { + return PathBuf::from(xdg).join("brahman").join("init-keypair.bin"); + } + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home) + .join(".local") + .join("share") + .join("brahman") + .join("init-keypair.bin"); + } + PathBuf::from("/tmp/brahman-init-keypair.bin") +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + #[test] + fn generate_persist_and_reload_yields_same_peer_id() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("identity.bin"); + let (kp1, loaded) = load_or_generate(&path).unwrap(); + assert!(!loaded, "primera vez debe generar"); + let peer1 = kp1.public().to_peer_id(); + + let (kp2, loaded) = load_or_generate(&path).unwrap(); + assert!(loaded, "segunda vez debe cargar"); + let peer2 = kp2.public().to_peer_id(); + + assert_eq!(peer1, peer2, "peer_id estable across reloads"); + } + + #[test] + fn rejects_corrupted_file() { + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("bad.bin"); + std::fs::write(&path, b"too short").unwrap(); + assert!(load_or_generate(&path).is_err()); + } + + #[test] + #[cfg(unix)] + fn persisted_file_is_owner_only() { + use std::os::unix::fs::PermissionsExt; + let tmp = TempDir::new().unwrap(); + let path = tmp.path().join("perm.bin"); + let _ = load_or_generate(&path).unwrap(); + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "permisos del keypair file deben ser 0o600 (solo dueño), got {:o}", + mode & 0o777 + ); + } + + #[test] + fn default_path_honors_env() { + std::env::set_var("BRAHMAN_KEYPAIR_PATH", "/custom/path.bin"); + assert_eq!(default_path(false), PathBuf::from("/custom/path.bin")); + assert_eq!(default_path(true), PathBuf::from("/custom/path.bin")); + std::env::remove_var("BRAHMAN_KEYPAIR_PATH"); + } +} diff --git a/crates/core/ente-zero/src/main.rs b/crates/core/ente-zero/src/main.rs index 5c9b7f1..bc5711d 100644 --- a/crates/core/ente-zero/src/main.rs +++ b/crates/core/ente-zero/src/main.rs @@ -19,6 +19,7 @@ mod brain_glue; mod bus; mod events; mod graph; +mod keypair_store; mod seed; use anyhow::Context; @@ -158,23 +159,66 @@ async fn primordial_loop( current_context: broker_context.clone(), }), )); + + // Brahman-net opcional: si BRAHMAN_LISTEN_MULTIADDR está set, + // levantamos la malla P2P y la pasamos como ServerConfig.net (Fase + // 2 wire) para que cada Card con outputs se anuncie al DHT y + // pueda ser descubierta por nodos remotos. Identidad libp2p + // persistida en disco vía keypair_store (peer_id estable across + // reboots). + let brahman_net = setup_brahman_net(dev_mode).await; + let brahman_sock = brahman_handshake::transport::default_socket_path(); match brahman_handshake::server::Server::bind( &brahman_sock, brahman_handshake::server::ServerConfig { init_attached: true, broker: Some(brahman_broker.clone()), - // Fase 2: el Init aún no expone red P2P por default. Cuando - // arje quiera publicar Cards al DHT remoto, pasar aquí un - // `Some(Arc)` con la malla configurada. - net: None, + net: brahman_net.clone(), }, ) { Ok(server) => { - info!(socket = %brahman_sock.display(), "brahman handshake escuchando"); + info!(socket = %brahman_sock.display(), "brahman handshake escuchando (Unix)"); + // Si hay malla P2P, además del Unix accept loop levantamos + // el accept loop libp2p sobre el mismo Server compartido. + // Las sesiones locales y remotas conviven en las mismas + // tablas (sessions, push_table, broker). + let server = std::sync::Arc::new(server); + if let Some(net) = brahman_net.clone() { + let s_libp2p = server.clone(); + let n_libp2p = net.clone(); + tokio::spawn(async move { + if let Err(e) = brahman_handshake::network::run_libp2p_accept_loop( + s_libp2p, n_libp2p, + ) + .await + { + warn!(?e, "brahman handshake libp2p accept loop cayó"); + } + }); + info!( + "brahman handshake escuchando también vía libp2p (peer_id {})", + net.peer_id + ); + } + // Unix accept loop: usa Arc en lugar del consume + // de run() para coexistir con el libp2p accept loop. + let s_unix = server.clone(); tokio::spawn(async move { - if let Err(e) = server.run().await { - warn!(?e, "brahman handshake server cayó"); + loop { + match s_unix.accept_one().await { + Ok(session) => { + tokio::spawn(async move { + if let Err(e) = session.handle().await { + warn!(?e, "session Unix terminó con error"); + } + }); + } + Err(e) => { + warn!(?e, "brahman handshake accept_one Unix falló"); + break; + } + } } }); } @@ -573,3 +617,84 @@ async fn feed_brain( ente_brain::dispatch_actions(&rules, sink).await; } } + +/// Inicializa la malla `brahman-net` opcional. Activa sólo si +/// `BRAHMAN_LISTEN_MULTIADDR` está set. Identidad libp2p persistente +/// vía `keypair_store`. Bootstrap del DHT vía `BRAHMAN_BOOTSTRAP_PEERS` +/// (lista coma-separada de multiaddrs, opcional). +/// +/// Toda fase de setup degrada grácilmente: si la keypair no carga, +/// si el listen falla, si bootstrap dial falla — loggea y devuelve +/// `None`. El Init sigue funcionando en modo Unix-only. +async fn setup_brahman_net( + dev_mode: bool, +) -> Option> { + let listen_addr = match std::env::var("BRAHMAN_LISTEN_MULTIADDR") { + Ok(s) if !s.is_empty() => s, + _ => { + tracing::debug!( + "brahman-net deshabilitado (sin BRAHMAN_LISTEN_MULTIADDR)" + ); + return None; + } + }; + + let multiaddr: brahman_net::Multiaddr = match listen_addr.parse() { + Ok(m) => m, + Err(e) => { + warn!(addr = %listen_addr, ?e, "BRAHMAN_LISTEN_MULTIADDR inválido — net deshabilitado"); + return None; + } + }; + + let keypair_path = keypair_store::default_path(dev_mode); + let (keypair, loaded) = match keypair_store::load_or_generate(&keypair_path) { + Ok(kp) => kp, + Err(e) => { + warn!(path = %keypair_path.display(), ?e, "no pude cargar/generar keypair libp2p — net deshabilitado"); + return None; + } + }; + info!( + path = %keypair_path.display(), + peer_id = %keypair.public().to_peer_id(), + loaded = loaded, + "identidad libp2p {}", + if loaded { "cargada" } else { "generada y persistida" } + ); + + let net = match brahman_net::BrahmanNet::with_keypair(keypair) { + Ok(n) => std::sync::Arc::new(n), + Err(e) => { + warn!(?e, "BrahmanNet::with_keypair falló — net deshabilitado"); + return None; + } + }; + + let actual = net.listen(multiaddr).await; + info!(addr = %actual, peer_id = %net.peer_id, "brahman-net escuchando"); + + // Bootstrap opcional: dial-ar a peers conocidos para entrar al + // DHT. Sin bootstrap, el nodo arranca aislado hasta que alguien + // se conecte a él. + if let Ok(bootstrap) = std::env::var("BRAHMAN_BOOTSTRAP_PEERS") { + let mut dialed = 0usize; + for entry in bootstrap.split(',').filter(|s| !s.is_empty()) { + match entry.parse::() { + Ok(addr) => { + net.dial(addr.clone()); + dialed += 1; + tracing::debug!(peer = %addr, "dial bootstrap"); + } + Err(e) => { + warn!(entry = %entry, ?e, "bootstrap multiaddr inválido — saltado"); + } + } + } + if dialed > 0 { + info!(count = dialed, "bootstrap peers dial-eados"); + } + } + + Some(net) +}