diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e4dee24..c53db617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,12 +9,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Changed - **Daemon install no longer depends on `~/.ww/config.glia`.** `ww daemon install` now renders launchd/systemd service definitions directly from flags/env/defaults, removing the extra host-side Glia config control plane. - **Routing capability gains write-path v1 mutation surface (CID-transform API).** Added explicit mutation methods that take a base CID and return a new root CID: `mkdir`, `writeFile`, and `remove`, plus `publish` for IPNS updates with optional compare-and-set (`expectedCurrent`) conflict checks. This keeps reads on WASI paths while making writes explicit, attenuable effects with no hidden mutable daemon root. -- **Release-facing docs synchronized with shell-migration state.** README/CLI/shell/routing docs now consistently state that `ww shell` is currently a forward-stable stub (`NOT IMPLEMENTED`), reflect persistent-identity defaults and explicit `--insecure-ephemeral`, document routing write-path v1 semantics, and call out shell transport follow-up issue #470. +- **`ww shell` remote path is live again over libp2p transport.** `ww shell` now dials `/ww/0.1.0`, authenticates through `Terminal(Membrane)` with the local identity key (`WW_IDENTITY` or `~/.ww/identity`), loads the shell cell through `runtime.load`, and opens an interactive REPL. - **`ww run` now requires a persistent identity by default.** Identity resolution no longer silently falls back to ephemeral keys when `--identity` is missing or points to a nonexistent file. Default lookup is `~/.ww/identity`; if absent, startup fails with a clear message and remediation. Operators can explicitly bypass with `--insecure-ephemeral` (named insecure on purpose), which restores prior ephemeral behavior for quick trial runs. -- **Admin UDS interface removed; `ww shell` is temporarily unavailable.** Removed the daemon-side Unix-domain admin service and its local socket discovery path. `ww shell` now remains as a forward-stable CLI surface but exits with `NOT IMPLEMENTED` until the replacement remote transport/auth path lands. +- **Removed `~/.ww/config.glia` daemon control-plane dependency.** Daemon service definitions are now rendered directly from CLI/env/defaults in `ww daemon install` instead of reading/writing `config.glia`. This removes overlap with pid0 Glia scripts and keeps host config source-of-truth in flags/env. +- **Admin UDS interface removed; shell transport is now libp2p-first.** The daemon-side UDS admin service remains removed, and shell connectivity is now through the replacement libp2p + terminal-auth path. - **Filesystem data-plane contract tightened: backend is now root-layer-only and `perform fs` reads are removed.** The shell/MCP evaluation wrapper no longer routes data-plane reads through `(perform fs ...)`; filesystem reads now go through WASI path I/O (`load`, `import`, `/ipfs/...`, `/ipns/...`). The legacy `fs` handler has been removed. Backend virtual mount resolution now rejects targeted mounts (`source:/guest/path`) and accepts root layers only; `ww run` enforces this early with a CLI preflight error that lists offending mounts. Docs updated across `doc/shell.md`, `doc/capabilities.md`, and `doc/architecture.md` to reflect the single-path model. - **CompilationService now uses a dedicated worker pool with in-flight dedupe.** The compiler subsystem moved from single-thread compile handling to a fixed worker pool (`WW_COMPILE_WORKERS`, default derived from CPU count), keys cache entries by `(wasm_blake3, engine identity)`, and coalesces concurrent duplicate compile requests so one cold compile serves all waiters. -- **Shell local-discovery policy aligned to per-user run dir.** `ww shell` local discovery now scans only `~/.ww/run/` for `.sock` entries (no `/var/run/ww` fallback), and the client fails deterministically with a disambiguation error when multiple local daemons are present instead of prompting interactively. Updated `src/discovery.rs`, `src/cli/shell.rs`, and shell/CLI docs to match this behavior and keep the local admin auth boundary consistently user-scoped. +- **Shell local discovery moved to mDNS (no lockfile/runtime-record dependency).** No-arg `ww shell` now discovers local candidates over mDNS, prefers deterministic identity matches, auto-connects only when unambiguous, and refuses to guess on multi-result ambiguity (explicit target required). Interactive multi-select UX is tracked separately in #479. - **CLI module boundary cleanup (no behavior change).** Extracted daemon-management helpers, namespace command handlers, and doctor checks from `src/cli/main.rs` into `src/cli/daemon_cmd.rs`, `src/cli/ns_cmd.rs`, and `src/cli/doctor_cmd.rs`, leaving `Commands` as thin delegators. This reduces `main.rs` surface area and improves maintainability while preserving existing command behavior. - **Runtime load path now supports staged component precompilation via a dedicated compiler service.** Added `ProcBuilder::with_component(...)` so executor spawns can instantiate precompiled components instead of recompiling WASM on worker threads, threaded an optional compile-request channel through runtime construction, and wired daemon startup to spawn `CompilationService` and pass it into kernel/admin runtimes. This keeps behavior compatible while moving CPU-heavy compile work off the executor hot path. diff --git a/Cargo.toml b/Cargo.toml index 0d1e35b0..a82bbe58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,7 +56,7 @@ blake3 = "1.8.3" # Host-only dependencies (not needed for WASM guests) [target.'cfg(not(target_arch = "wasm32"))'.dependencies] -libp2p = { version = "0.55.0", features = ["tokio", "tcp", "noise", "yamux", "macros", "identify", "rsa", "request-response", "ed25519", "kad", "autonat", "relay", "dcutr", "quic", "dns"] } +libp2p = { version = "0.55.0", features = ["tokio", "tcp", "noise", "yamux", "macros", "identify", "rsa", "request-response", "ed25519", "kad", "autonat", "relay", "dcutr", "quic", "dns", "mdns"] } libp2p-core = "0.43" ipfs = { path = "crates/ipfs" } clap = { version = "4.5.51", features = ["derive", "env"] } diff --git a/README.md b/README.md index 2d4a3d72..3d1434c7 100644 --- a/README.md +++ b/README.md @@ -63,12 +63,12 @@ Requires a Rust toolchain with the `wasm32-wasip2` target. Optional: [Kubo](http ```bash ww run . # boot a node from current dir -ww shell # shell transport currently unavailable +ww shell # discover a local node via mDNS, then open REPL ``` -`ww shell` is currently a forward-stable CLI stub and exits -`NOT IMPLEMENTED` while the replacement remote transport/auth path lands. -See issue #470 for the transport cutover follow-up. +`ww shell` uses libp2p transport and Terminal(Membrane) auth. By default it +discovers local hosts via mDNS and auto-connects only when resolution is +unambiguous. ### Boot a cell diff --git a/doc/cli.md b/doc/cli.md index 79309820..62949d77 100644 --- a/doc/cli.md +++ b/doc/cli.md @@ -96,34 +96,28 @@ ww run . --stem 0x1234...abcd --rpc-url http://rpc.example.com:8545 Connect to a running daemon and open a Glia REPL. ``` -ww shell [ADDR] [--discover] +ww shell [ADDR] ``` The admin UDS path has been removed. The command surface is preserved -for forward compatibility while remote transport/auth work lands, and -currently exits with `NOT IMPLEMENTED`. +with libp2p transport + Terminal(Membrane) auth: -- *(no args)* — **NOT IMPLEMENTED.** -- `` — **NOT IMPLEMENTED.** Future libp2p remote dial. -- `--discover` — **NOT IMPLEMENTED.** Future mDNS LAN browse. - -If both `` and `--discover` are given, `` takes -precedence and `--discover` is ignored (documented for forward -compatibility; today both exit `NOT IMPLEMENTED`). +- *(no args)* — discover via mDNS, auto-connect only when unambiguous. +- `` — explicit remote dial. ### Examples ```sh -ww shell # NOT IMPLEMENTED -ww shell /dnsaddr/master.wetware.run # NOT IMPLEMENTED (clap parse OK) -ww shell /ip4/127.0.0.1/tcp/2025 # NOT IMPLEMENTED (clap parse OK) +ww shell # mDNS discover + connect +ww shell /dnsaddr/master.wetware.run # explicit dial +ww shell /ip4/127.0.0.1/tcp/2025/p2p/12D3KooW... ww shell garbage # clap parse error: invalid multiaddr ``` ### Auth model -No shell auth model is active right now because there is no live shell -transport path. The replacement design will use explicit remote auth. +Shell uses Terminal(Membrane) challenge-response auth over libp2p. +The signer key comes from `WW_IDENTITY` or `~/.ww/identity`. See [shell.md](shell.md) for Glia syntax and the capabilities the shell cell exposes. diff --git a/doc/shell.md b/doc/shell.md index 6e36c802..e969e3ed 100644 --- a/doc/shell.md +++ b/doc/shell.md @@ -1,22 +1,27 @@ # Shell -The `ww shell` transport is currently unavailable. +`ww shell` connects to a running node and opens a Glia REPL. -The previous local admin UDS path has been removed, and the replacement -remote shell transport/auth path has not landed yet. For now, all -invocations of `ww shell` return `NOT IMPLEMENTED`. - -## CLI Surface (Forward-Compatible) +## Modes ```sh ww shell ww shell -ww shell --discover ``` -The command shape is intentionally preserved so the remote-shell rollout -can land without another CLI-breaking change. +- `ww shell`: discover hosts via mDNS, then connect only when target + selection is unambiguous. +- `ww shell `: dial an explicit target. + +## Auth + +Shell transport uses libp2p streams and Terminal(Membrane) challenge-response +authentication. `ww shell` signs terminal challenges with the local identity +key (`WW_IDENTITY` or `~/.ww/identity`). + +## Multi-Result Discovery -## Follow-ups +When mDNS returns multiple candidates and no deterministic preferred target +is found, `ww shell` refuses to guess and asks for an explicit multiaddr. -- Transport cutover work is tracked in issue #470. +Interactive multi-select UX is tracked in issue #479. diff --git a/src/cli/main.rs b/src/cli/main.rs index f14ad860..df94d28e 100644 --- a/src/cli/main.rs +++ b/src/cli/main.rs @@ -256,26 +256,12 @@ enum Commands { /// Connect to a running node and open a Glia REPL. /// - /// Remote shell transport/auth is currently being reworked. - /// This command exists as a forward-stable CLI surface. - /// /// Example: /// ww shell - /// ww shell /ip4/127.0.0.1/tcp/2025/p2p/12D3KooW... # NOT IMPLEMENTED - /// ww shell --discover # NOT IMPLEMENTED - /// - /// If both ADDR and --discover are given, ADDR takes precedence - /// and --discover is ignored with a warning. (When ADDR / --discover - /// are implemented, both will use libp2p with Noise.) + /// ww shell /ip4/127.0.0.1/tcp/2025/p2p/12D3KooW... Shell { - /// Multiaddr of a remote node (NOT YET IMPLEMENTED — forward-stable - /// CLI surface for future libp2p remote shell support). + /// Multiaddr of a remote node. addr: Option, - - /// Browse the LAN for a wetware daemon via mDNS (NOT YET - /// IMPLEMENTED — forward-stable CLI surface). - #[arg(long)] - discover: bool, }, /// Effectful operations that mutate state beyond the current directory. @@ -590,7 +576,7 @@ impl Commands { private_key, } => Self::push(path, ipfs_url, stem, rpc_url, private_key).await, Commands::Keygen { output } => Self::keygen(output).await, - Commands::Shell { addr, discover } => shell::run_shell(addr, discover).await, + Commands::Shell { addr } => shell::run_shell(addr).await, Commands::Perform { action } => match action { PerformAction::Install => Self::perform_install().await, PerformAction::Uninstall => Self::perform_uninstall().await, @@ -1356,7 +1342,6 @@ wasip2::cli::command::export!({iface_name}Guest); tracing::debug!(source = identity_source, "identity resolved"); let keypair = ww::keys::to_libp2p(&sk)?; - // Attempt to fetch Kubo's identity so we can bootstrap the in-process // Kad client against the local node (Amino DHT /ipfs/kad/1.0.0). // Non-fatal: if Kubo is unreachable we still start, just without Kad. diff --git a/src/cli/shell.rs b/src/cli/shell.rs index d4c86e60..4386a409 100644 --- a/src/cli/shell.rs +++ b/src/cli/shell.rs @@ -1,46 +1,524 @@ //! `ww shell` CLI surface. -//! -//! The UDS admin path has been removed. Remote shell transport/auth -//! replacement is tracked separately. -use anyhow::{bail, Result}; -use libp2p::Multiaddr; +use anyhow::{bail, Context, Result}; +use auth::SigningDomain; +use capnp::capability::FromClientHook; +use capnp_rpc::{new_client, pry}; +use libp2p::multiaddr::Protocol; +use libp2p::{Multiaddr, PeerId, StreamProtocol}; +use libp2p_core::SignedEnvelope; +use rustyline::error::ReadlineError; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::Duration; +use tokio::sync::{mpsc, oneshot}; + +const CAPNP_PROTOCOL: StreamProtocol = StreamProtocol::new("/ww/0.1.0"); +const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(3); +const CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const RPC_TIMEOUT: Duration = Duration::from_secs(30); + +#[cfg(has_wasm_std_shell_bin_shell_wasm)] +const EMBEDDED_SHELL: &[u8] = include_bytes!("../../std/shell/bin/shell.wasm"); +#[cfg(not(has_wasm_std_shell_bin_shell_wasm))] +const EMBEDDED_SHELL: &[u8] = &[]; + +#[derive(Clone, Debug)] +struct Candidate { + peer_id: Option, + addrs: Vec, +} + +struct LocalSigner { + keypair: libp2p::identity::Keypair, +} + +impl LocalSigner { + fn from_signing_key(sk: &ed25519_dalek::SigningKey) -> Result { + let keypair = ww::keys::to_libp2p(sk)?; + Ok(Self { keypair }) + } +} + +#[allow(refining_impl_trait)] +impl ww::stem_capnp::signer::Server for LocalSigner { + fn sign( + self: capnp::capability::Rc, + params: ww::stem_capnp::signer::SignParams, + mut results: ww::stem_capnp::signer::SignResults, + ) -> capnp::capability::Promise<(), capnp::Error> { + let p = pry!(params.get()); + let nonce = p.get_nonce(); + let epoch_seq = p.get_epoch_seq(); + let domain = SigningDomain::terminal_membrane(); + + let mut payload = Vec::with_capacity(16); + payload.extend_from_slice(&nonce.to_be_bytes()); + payload.extend_from_slice(&epoch_seq.to_be_bytes()); + + let envelope = pry!(SignedEnvelope::new( + &self.keypair, + domain.as_str().to_string(), + domain.payload_type().to_vec(), + payload, + ) + .map_err(|e| capnp::Error::failed(format!("signing failed: {e}")))); + + results.get().set_sig(&envelope.into_protobuf_encoding()); + capnp::capability::Promise::ok(()) + } +} /// Run the interactive shell client. /// -/// `addr` and `discover` are the forward-stable CLI surface for remote -/// shell access (libp2p multiaddr / mDNS LAN browse). -pub async fn run_shell(addr: Option, discover: bool) -> Result<()> { - let hint = if addr.is_some() || discover { - "remote shell is not implemented yet" +/// - `ww shell ` dials explicit multiaddr. +/// - `ww shell` discovers local mDNS candidates and connects when unambiguous. +pub async fn run_shell(addr: Option) -> Result<()> { + let local = tokio::task::LocalSet::new(); + local + .run_until(async move { run_shell_local(addr).await }) + .await +} + +async fn run_shell_local(addr: Option) -> Result<()> { + let (signing_key, preferred_peer_id) = load_shell_identity()?; + + let target = if let Some(addr) = addr { + let peer = peer_id_from_addr(&addr); + let addrs = vec![addr]; + candidate_from_parts(peer, addrs)? } else { - "local shell is temporarily unavailable while transport/auth is being reworked" + let candidates = discover_mdns_candidates(&signing_key).await?; + choose_candidate(candidates, Some(preferred_peer_id))? }; - bail!("ww shell: NOT IMPLEMENTED ({hint})") + + let shell = dial_shell(&target, &signing_key).await?; + run_repl(&shell).await +} + +async fn dial_shell( + target: &Candidate, + signing_key: &ed25519_dalek::SigningKey, +) -> Result { + let keypair = ww::keys::to_libp2p(signing_key)?; + let mut client = ww::host::ClientSwarm::new(keypair)?; + let mut stream_control = client.stream_control(); + + let (connected_tx, connected_rx) = oneshot::channel(); + + if let Some(peer_id) = target.peer_id { + // Seed known addresses and initiate dial. + for addr in &target.addrs { + client.add_peer_addr(peer_id, addr.clone()); + } + } else if let Some(addr) = target.addrs.first() { + client + .dial(addr.clone()) + .map_err(|e| anyhow::anyhow!("failed to dial {addr}: {e}"))?; + } else { + bail!("no dial addresses provided"); + } + + tokio::task::spawn_local(client.run(Some(connected_tx), None)); + + let connected_peer = tokio::time::timeout(CONNECT_TIMEOUT, connected_rx) + .await + .context("timed out waiting for libp2p connection")? + .context("connection notification channel dropped")?; + + let remote_peer = target.peer_id.unwrap_or(connected_peer); + + let stream = tokio::time::timeout( + CONNECT_TIMEOUT, + stream_control.open_stream(remote_peer, CAPNP_PROTOCOL), + ) + .await + .context("timed out opening shell stream")? + .map_err(|e| anyhow::anyhow!("failed to open shell stream: {e}"))?; + + let ww::rpc::vat_dial::VatDial { + bootstrap: terminal, + driver: _driver, + } = ww::rpc::vat_dial::connect::< + _, + ww::stem_capnp::terminal::Client, + >(stream); + + let signer_client: ww::stem_capnp::signer::Client = + new_client(LocalSigner::from_signing_key(signing_key)?); + + let mut login_req = terminal.login_request(); + login_req.get().set_signer(signer_client); + let login_resp = tokio::time::timeout(RPC_TIMEOUT, login_req.send().promise) + .await + .context("terminal login timed out")??; + + let membrane = login_resp + .get()? + .get_session() + .context("terminal login returned no session")?; + + let graft_resp = tokio::time::timeout(RPC_TIMEOUT, membrane.graft_request().send().promise) + .await + .context("graft request timed out")??; + let caps = graft_resp.get()?.get_caps()?; + let runtime: ww::system_capnp::runtime::Client = get_graft_cap(&caps, "runtime")?; + + let shell_wasm = load_shell_wasm()?; + + let mut load_req = runtime.load_request(); + load_req.get().set_wasm(&shell_wasm); + let load_resp = tokio::time::timeout(RPC_TIMEOUT, load_req.send().promise) + .await + .context("runtime.load timed out")??; + let executor = load_resp.get()?.get_executor()?; + + let spawn_resp = tokio::time::timeout(RPC_TIMEOUT, executor.spawn_request().send().promise) + .await + .context("executor.spawn timed out")??; + let process = spawn_resp.get()?.get_process()?; + + let bootstrap_resp = + tokio::time::timeout(RPC_TIMEOUT, process.bootstrap_request().send().promise) + .await + .context("process.bootstrap timed out")??; + let bootstrap = bootstrap_resp.get()?; + let shell: ww::shell_capnp::shell::Client = bootstrap.get_cap().get_as_capability()?; + + wait_shell_ready(&shell).await?; + Ok(shell) +} + +async fn run_repl(shell: &ww::shell_capnp::shell::Client) -> Result<()> { + let mut rl = rustyline::DefaultEditor::new().context("failed to initialize line editor")?; + + loop { + match rl.readline("ww> ") { + Ok(line) => { + let input = line.trim(); + if input.is_empty() { + continue; + } + if input == ":q" || input == ":quit" || input == ":exit" { + break; + } + let _ = rl.add_history_entry(input); + + let (result, is_error) = shell_eval(shell, input).await?; + if is_error { + eprintln!("{result}"); + } else { + println!("{result}"); + } + } + Err(ReadlineError::Interrupted) => { + eprintln!("^C"); + continue; + } + Err(ReadlineError::Eof) => break, + Err(e) => return Err(anyhow::anyhow!("readline error: {e}")), + } + } + + Ok(()) +} + +async fn shell_eval(shell: &ww::shell_capnp::shell::Client, text: &str) -> Result<(String, bool)> { + let mut req = shell.eval_request(); + req.get().set_text(text); + let resp = tokio::time::timeout(RPC_TIMEOUT, req.send().promise) + .await + .context("shell eval timed out")??; + let result = resp.get()?; + let text = result + .get_result()? + .to_str() + .unwrap_or("(invalid UTF-8)") + .to_string(); + Ok((text, result.get_is_error())) +} + +async fn wait_shell_ready(shell: &ww::shell_capnp::shell::Client) -> Result<()> { + for _ in 0..60 { + let (result, is_error) = shell_eval(shell, "nil").await?; + if !is_error || !result.contains("not ready") { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + bail!("shell did not become ready") +} + +fn get_graft_cap( + caps: &capnp::struct_list::Reader<'_, ww::stem_capnp::export::Owned>, + name: &str, +) -> Result { + for i in 0..caps.len() { + let entry = caps.get(i); + let n = entry + .get_name()? + .to_str() + .map_err(|e| capnp::Error::failed(e.to_string()))?; + if n == name { + return entry.get_cap().get_as_capability(); + } + } + + Err(capnp::Error::failed(format!( + "capability '{name}' not found in graft response" + ))) +} + +fn load_shell_wasm() -> Result> { + if !EMBEDDED_SHELL.is_empty() { + return Ok(EMBEDDED_SHELL.to_vec()); + } + + let path = Path::new("std/shell/bin/shell.wasm"); + if path.exists() { + return std::fs::read(path).context("failed to read std/shell/bin/shell.wasm"); + } + + bail!( + "shell WASM not found (embedded shell unavailable). Build it with `make shell` or install a release with embedded shell." + ) +} + +fn shell_identity_path() -> Result { + if let Some(path) = std::env::var_os("WW_IDENTITY") { + return Ok(PathBuf::from(path)); + } + + let home = dirs::home_dir().context("cannot determine home directory")?; + Ok(home.join(".ww/identity")) +} + +fn load_shell_identity() -> Result<(ed25519_dalek::SigningKey, PeerId)> { + let path = shell_identity_path()?; + if !path.exists() { + bail!( + "Identity file not found: {}\n\ + `ww shell` requires a persistent identity to authenticate.\n\ + Create one with: ww keygen > ~/.ww/identity", + path.display() + ); + } + + let sk = ww::keys::load(path.to_str().context("identity path is non-UTF-8")?)?; + let peer_id = ww::keys::to_libp2p(&sk)?.public().to_peer_id(); + Ok((sk, peer_id)) +} + +async fn discover_mdns_candidates( + signing_key: &ed25519_dalek::SigningKey, +) -> Result> { + let keypair = ww::keys::to_libp2p(signing_key)?; + let client = ww::host::ClientSwarm::new(keypair)?; + + let (_connected_tx, _connected_rx) = oneshot::channel::(); + let (discovered_tx, mut discovered_rx) = mpsc::unbounded_channel::<(PeerId, Multiaddr)>(); + + tokio::task::spawn_local(client.run(None, Some(discovered_tx))); + + let deadline = tokio::time::Instant::now() + DISCOVERY_TIMEOUT; + let mut candidates: HashMap> = HashMap::new(); + + loop { + tokio::select! { + _ = tokio::time::sleep_until(deadline) => break, + maybe = discovered_rx.recv() => { + match maybe { + Some((peer_id, addr)) => { + let entry = candidates.entry(peer_id).or_default(); + if !entry.iter().any(|a| a == &addr) { + entry.push(addr); + } + } + None => break, + } + } + } + } + + Ok(candidates + .into_iter() + .map(|(peer_id, addrs)| Candidate { + peer_id: Some(peer_id), + addrs, + }) + .collect()) +} + +fn choose_candidate(candidates: Vec, preferred: Option) -> Result { + if candidates.is_empty() { + bail!( + "No wetware hosts discovered via mDNS.\n\ + Try `ww shell ` to connect explicitly." + ); + } + + if candidates.len() == 1 { + return ensure_candidate_addr(candidates.into_iter().next().unwrap()); + } + + if let Some(preferred_peer) = preferred { + let mut matches: Vec = candidates + .iter() + .filter(|c| c.peer_id == Some(preferred_peer)) + .cloned() + .collect(); + + if matches.len() == 1 { + return ensure_candidate_addr(matches.remove(0)); + } + } + + let mut listing = String::new(); + for c in &candidates { + let addrs = c + .addrs + .iter() + .map(ToString::to_string) + .collect::>() + .join(", "); + if let Some(peer_id) = c.peer_id { + listing.push_str(&format!("\n - {} [{}]", peer_id, addrs)); + } else { + listing.push_str(&format!("\n - [{}]", addrs)); + } + } + + bail!( + "Multiple wetware hosts discovered via mDNS; refusing to guess.\n\ + Use an explicit multiaddr: `ww shell `\n\ + Discovered hosts:{listing}\n\ + TODO tracked in https://github.com/wetware/ww/issues/479" + ) +} + +fn ensure_candidate_addr(candidate: Candidate) -> Result { + if candidate.addrs.is_empty() { + if let Some(peer_id) = candidate.peer_id { + bail!("discovered peer {} has no dialable addresses", peer_id); + } + bail!("candidate has no dialable addresses"); + } + Ok(candidate) +} + +fn peer_id_from_addr(addr: &Multiaddr) -> Option { + for protocol in addr.iter() { + if let Protocol::P2p(peer_id) = protocol { + return Some(peer_id); + } + } + None +} + +fn candidate_from_parts(peer: Option, addrs: Vec) -> Result { + if addrs.is_empty() { + bail!("no dial addresses provided") + } + Ok(Candidate { + peer_id: peer, + addrs, + }) } #[cfg(test)] mod tests { use super::*; - #[tokio::test] - async fn shell_without_args_reports_local_unavailable() { - let err = run_shell(None, false).await.unwrap_err(); - let msg = err.to_string(); - assert!( - msg.contains("local shell is temporarily unavailable"), - "unexpected error: {msg}" - ); + fn maddr(s: &str) -> Multiaddr { + s.parse().unwrap() } - #[tokio::test] - async fn shell_with_addr_reports_remote_unimplemented() { - let addr: Multiaddr = "/ip4/127.0.0.1/tcp/2025".parse().unwrap(); - let err = run_shell(Some(addr), false).await.unwrap_err(); - let msg = err.to_string(); + #[test] + fn choose_prefers_matching_identity_when_multiple() { + let p1: PeerId = "12D3KooWJ3qM19qUUj8JdT9kPEg6VZLoes6eexfUYd6Xn7SPrf8n" + .parse() + .unwrap(); + let p2: PeerId = "12D3KooWQdQnZYK7hX8Q2Yb8qXWQYvdr4jRWk6TUhSxvVmF5vU3P" + .parse() + .unwrap(); + + let chosen = choose_candidate( + vec![ + Candidate { + peer_id: Some(p1), + addrs: vec![maddr("/ip4/10.0.0.1/tcp/2025")], + }, + Candidate { + peer_id: Some(p2), + addrs: vec![maddr("/ip4/10.0.0.2/tcp/2025")], + }, + ], + Some(p2), + ) + .unwrap(); + + assert_eq!(chosen.peer_id, Some(p2)); + } + + #[test] + fn choose_errors_on_multiple_without_preference_match() { + let p1: PeerId = "12D3KooWJ3qM19qUUj8JdT9kPEg6VZLoes6eexfUYd6Xn7SPrf8n" + .parse() + .unwrap(); + let p2: PeerId = "12D3KooWQdQnZYK7hX8Q2Yb8qXWQYvdr4jRWk6TUhSxvVmF5vU3P" + .parse() + .unwrap(); + let p3: PeerId = "12D3KooWJfUGS8thH9bC4x6hFQ3mFAH3RT6N8gW2H8RyV8Xxwy9A" + .parse() + .unwrap(); + + let err = choose_candidate( + vec![ + Candidate { + peer_id: Some(p1), + addrs: vec![maddr("/ip4/10.0.0.1/tcp/2025")], + }, + Candidate { + peer_id: Some(p2), + addrs: vec![maddr("/ip4/10.0.0.2/tcp/2025")], + }, + ], + Some(p3), + ) + .unwrap_err() + .to_string(); + + assert!(err.contains("Multiple wetware hosts discovered"), "{err}"); + } + + #[test] + fn candidate_from_parts_allows_addr_without_peer_id() { + let c = candidate_from_parts(None, vec![maddr("/ip4/127.0.0.1/tcp/2025")]).unwrap(); + assert_eq!(c.peer_id, None); + assert_eq!(c.addrs.len(), 1); + } + + #[test] + fn choose_candidate_errors_when_empty() { + let err = choose_candidate(vec![], None).unwrap_err().to_string(); assert!( - msg.contains("remote shell is not implemented yet"), - "unexpected error: {msg}" + err.contains("No wetware hosts discovered via mDNS"), + "{err}" ); } + + #[test] + fn peer_id_from_addr_extracts_when_present() { + let peer_id: PeerId = "12D3KooWJ3qM19qUUj8JdT9kPEg6VZLoes6eexfUYd6Xn7SPrf8n" + .parse() + .unwrap(); + let addr = maddr(&format!("/ip4/127.0.0.1/tcp/2025/p2p/{peer_id}")); + assert_eq!(peer_id_from_addr(&addr), Some(peer_id)); + } + + #[test] + fn peer_id_from_addr_returns_none_without_p2p() { + let addr = maddr("/ip4/127.0.0.1/tcp/2025"); + assert_eq!(peer_id_from_addr(&addr), None); + } } diff --git a/src/host.rs b/src/host.rs index f0b93163..81edd794 100644 --- a/src/host.rs +++ b/src/host.rs @@ -177,6 +177,7 @@ pub use rpc::SwarmCommand; pub struct WetwareBehaviour { pub identify: libp2p::identify::Behaviour, pub stream: libp2p_stream::Behaviour, + pub mdns: libp2p::mdns::tokio::Behaviour, /// WAN Kademlia DHT client (Amino protocol `/ipfs/kad/1.0.0`). /// Runs in client mode initially. Promoted to server when AutoNAT confirms /// public reachability. @@ -333,6 +334,10 @@ impl Libp2pHost { .with_quic() .with_relay_client(libp2p::noise::Config::new, libp2p::yamux::Config::default)? .with_behaviour(|_keypair, relay_client| { + let mdns = libp2p::mdns::tokio::Behaviour::new( + libp2p::mdns::Config::default(), + local_peer_id, + )?; let conn_limits = libp2p::connection_limits::ConnectionLimits::default() .with_max_pending_incoming(Some(16)) .with_max_pending_outgoing(Some(16)) @@ -341,6 +346,7 @@ impl Libp2pHost { Ok(WetwareBehaviour { identify: libp2p::identify::Behaviour::new(identify_config), stream: stream_behaviour, + mdns, kad: kad_wan, kad_lan, autonat: libp2p::autonat::v1::Behaviour::new( @@ -608,6 +614,21 @@ impl Libp2pHost { ); } SwarmEvent::Behaviour(WetwareBehaviourEvent::Identify(_)) => {} + SwarmEvent::Behaviour(WetwareBehaviourEvent::Mdns( + libp2p::mdns::Event::Discovered(discovered), + )) => { + for (peer_id, addr) in discovered { + peer_addr_book.entry(peer_id).or_default().push(addr.clone()); + if is_lan_addr(&addr) { + self.swarm.behaviour_mut().kad_lan.add_address(&peer_id, addr); + } else { + self.swarm.behaviour_mut().kad.add_address(&peer_id, addr); + } + } + } + SwarmEvent::Behaviour(WetwareBehaviourEvent::Mdns( + libp2p::mdns::Event::Expired(_), + )) => {} // --- AutoNAT v1: authoritative NAT status --- SwarmEvent::Behaviour(WetwareBehaviourEvent::Autonat( libp2p::autonat::v1::Event::StatusChanged { old, new }, @@ -1548,12 +1569,14 @@ mod tests { /// Minimal network behaviour for client-only operation. /// Identify for peer info exchange + libp2p_stream for VatClient dialing. +/// mDNS for local discovery. /// Relay client for connecting to NATted nodes via relayed addresses. -/// No Kademlia, no mDNS, no listeners. +/// No Kademlia and no listeners. #[derive(libp2p::swarm::NetworkBehaviour)] pub struct ClientBehaviour { identify: libp2p::identify::Behaviour, stream: libp2p_stream::Behaviour, + mdns: libp2p::mdns::tokio::Behaviour, relay_client: libp2p::relay::client::Behaviour, } @@ -1589,9 +1612,12 @@ impl ClientSwarm { .with_dns()? .with_relay_client(libp2p::noise::Config::new, libp2p::yamux::Config::default)? .with_behaviour(|_keypair, relay_client| { + let mdns = + libp2p::mdns::tokio::Behaviour::new(libp2p::mdns::Config::default(), peer_id)?; Ok(ClientBehaviour { identify: libp2p::identify::Behaviour::new(identify_config), stream: stream_behaviour, + mdns, relay_client, }) })? @@ -1640,9 +1666,14 @@ impl ClientSwarm { /// If `connected_tx` is provided, the peer ID of the first established /// connection is sent through it (useful for dnsaddr dialing where the /// peer ID is not known upfront). - pub async fn run(mut self, connected_tx: Option>) { + pub async fn run( + mut self, + connected_tx: Option>, + discovered_tx: Option>, + ) { use libp2p::swarm::SwarmEvent; let mut connected_tx = connected_tx; + let discovered_tx = discovered_tx; loop { match self.swarm.next().await { Some(SwarmEvent::ConnectionEstablished { peer_id, .. }) => { @@ -1651,6 +1682,18 @@ impl ClientSwarm { let _ = tx.send(peer_id); } } + Some(SwarmEvent::Behaviour(ClientBehaviourEvent::Mdns( + libp2p::mdns::Event::Discovered(discovered), + ))) => { + if let Some(tx) = &discovered_tx { + for (peer_id, addr) in discovered { + let _ = tx.send((peer_id, addr)); + } + } + } + Some(SwarmEvent::Behaviour(ClientBehaviourEvent::Mdns( + libp2p::mdns::Event::Expired(_), + ))) => {} Some(SwarmEvent::ConnectionClosed { peer_id, .. }) => { tracing::debug!(peer = %peer_id, "Client connection closed"); }