Proof-of-concept mesh agent library over iroh
Working library with real iroh connections, not an interface sketch: - persistent device identity in state.sqlite, stable across restarts - deterministic network space derived from name + secret via HKDF-SHA256, with frozen labels and unambiguous length-prefixed encoding - replaceable discovery returning unverified candidates only; static bootstrap, in-memory test backend and a composite - real iroh connections plus an explicit mutual membership proof: HMAC-SHA256 over a role-separated transcript bound to the TLS exporter, the network id and both endpoint identities - small versioned control protocol: handshake, announcement, ping/pong - multiple networks per agent with enforced isolation - automatic reconnect with bounded backoff and jitter - mandatory state vs disposable cache, with a real directory ownership lock - status snapshots, event stream and honest diagnostics 47 integration and unit tests cover the required scenarios offline on loopback. Snapshots, revocations and WireGuard are designed for and documented, not implemented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
//! Finding *candidates*, and nothing more.
|
||||
//!
|
||||
//! Discovery answers one question: "which iroh endpoints might currently be
|
||||
//! participating in the network behind this [`DiscoveryKey`], and at which
|
||||
//! addresses?". Its answers are **unverified candidates**. Membership is decided
|
||||
//! later, by the control protocol handshake in [`crate::proto::handshake`].
|
||||
//!
|
||||
//! A discovery backend must not carry control messages between agents, must not
|
||||
//! confirm authentication and must not mutate agent state directly.
|
||||
//!
|
||||
//! Two concerns are kept apart:
|
||||
//!
|
||||
//! * *Finding members of a network* — [`NetworkDiscovery::resolve`], keyed by
|
||||
//! the secret-derived [`DiscoveryKey`].
|
||||
//! * *Resolving the address of one iroh endpoint* — an
|
||||
//! [`iroh::EndpointAddr`] either already carries addresses, or iroh's own
|
||||
//! address lookup service must be enabled. Dialling a bare [`EndpointId`]
|
||||
//! with neither is expected to fail.
|
||||
//!
|
||||
//! No empty result ever proves a network is empty. It only means "nobody found
|
||||
//! yet".
|
||||
//!
|
||||
//! Mainline DHT discovery is future work and is not implemented here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::identity::DiscoveryKey;
|
||||
|
||||
/// A boxed future, so that [`NetworkDiscovery`] stays object safe.
|
||||
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
||||
|
||||
/// Where a candidate came from. Purely informational.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CandidateSource {
|
||||
/// A statically configured bootstrap entry.
|
||||
Bootstrap,
|
||||
/// A discovery backend lookup.
|
||||
Discovery,
|
||||
/// An address hint restored from the disposable cache.
|
||||
Cache,
|
||||
}
|
||||
|
||||
/// An unverified candidate peer.
|
||||
///
|
||||
/// Holding one grants nothing: the peer still has to pass the handshake.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
/// iroh address of the candidate, including whatever addressing info exists.
|
||||
pub addr: EndpointAddr,
|
||||
/// Where this candidate came from.
|
||||
pub source: CandidateSource,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
/// Creates a candidate.
|
||||
pub fn new(addr: EndpointAddr, source: CandidateSource) -> Self {
|
||||
Self { addr, source }
|
||||
}
|
||||
|
||||
/// The candidate's endpoint id.
|
||||
pub fn endpoint_id(&self) -> EndpointId {
|
||||
self.addr.id
|
||||
}
|
||||
}
|
||||
|
||||
/// A replaceable source of candidates.
|
||||
///
|
||||
/// Implementations must be cheap to clone behind an [`Arc`] and must never
|
||||
/// block the async executor.
|
||||
pub trait NetworkDiscovery: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Publishes this agent's address under `key`.
|
||||
///
|
||||
/// Backends that cannot publish (static bootstrap lists) return `Ok(())`.
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>>;
|
||||
|
||||
/// Withdraws a previously published address.
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>>;
|
||||
|
||||
/// Returns the candidates currently known for `key`.
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>>;
|
||||
}
|
||||
|
||||
/// A statically configured list of bootstrap candidates.
|
||||
///
|
||||
/// Each entry must carry enough addressing information to be dialled, i.e. an
|
||||
/// iroh endpoint id plus direct addresses or a relay URL, unless iroh's own
|
||||
/// address lookup is enabled in [`crate::config::TransportPolicy`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StaticBootstrap {
|
||||
entries: Vec<EndpointAddr>,
|
||||
}
|
||||
|
||||
impl StaticBootstrap {
|
||||
/// Creates a bootstrap list.
|
||||
pub fn new(entries: impl IntoIterator<Item = EndpointAddr>) -> Self {
|
||||
Self {
|
||||
entries: entries.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkDiscovery for StaticBootstrap {
|
||||
fn name(&self) -> &str {
|
||||
"static-bootstrap"
|
||||
}
|
||||
|
||||
fn publish<'a>(&'a self, _key: DiscoveryKey, _addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
_key: DiscoveryKey,
|
||||
_endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn resolve<'a>(&'a self, _key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
let candidates: Vec<Candidate> = self
|
||||
.entries
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|addr| Candidate::new(addr, CandidateSource::Bootstrap))
|
||||
.collect();
|
||||
Box::pin(async move { Ok(candidates) })
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-process discovery backend used by tests and examples.
|
||||
///
|
||||
/// It stores a mapping from [`DiscoveryKey`] to endpoint addresses and nothing
|
||||
/// else. It carries no messages, performs no authentication and cannot touch an
|
||||
/// agent's state. Clone it to hand the same rendezvous table to several agents;
|
||||
/// create a new one per test so that tests stay independent — there is no global
|
||||
/// mutable state here.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SharedMemoryDiscovery {
|
||||
inner: Arc<Mutex<HashMap<DiscoveryKey, HashMap<EndpointId, EndpointAddr>>>>,
|
||||
}
|
||||
|
||||
impl SharedMemoryDiscovery {
|
||||
/// Creates an empty rendezvous table.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Number of entries published under `key`. Useful in tests.
|
||||
pub fn len(&self, key: &DiscoveryKey) -> usize {
|
||||
self.with_inner(|map| map.get(key).map_or(0, HashMap::len))
|
||||
}
|
||||
|
||||
/// Whether nothing is published under `key`.
|
||||
pub fn is_empty(&self, key: &DiscoveryKey) -> bool {
|
||||
self.len(key) == 0
|
||||
}
|
||||
|
||||
/// Removes every entry under `key`, simulating a discovery outage.
|
||||
pub fn clear(&self, key: &DiscoveryKey) {
|
||||
self.with_inner(|map| {
|
||||
map.remove(key);
|
||||
});
|
||||
}
|
||||
|
||||
/// Replaces an entry with a deliberately wrong address, simulating a stale
|
||||
/// or poisoned record.
|
||||
pub fn insert_raw(&self, key: DiscoveryKey, addr: EndpointAddr) {
|
||||
self.with_inner(|map| {
|
||||
map.entry(key).or_default().insert(addr.id, addr);
|
||||
});
|
||||
}
|
||||
|
||||
fn with_inner<T>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut HashMap<DiscoveryKey, HashMap<EndpointId, EndpointAddr>>) -> T,
|
||||
) -> T {
|
||||
let mut guard = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
f(&mut guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkDiscovery for SharedMemoryDiscovery {
|
||||
fn name(&self) -> &str {
|
||||
"shared-memory"
|
||||
}
|
||||
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
self.with_inner(|map| {
|
||||
map.entry(key).or_default().insert(addr.id, addr);
|
||||
});
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
self.with_inner(|map| {
|
||||
if let Some(entries) = map.get_mut(&key) {
|
||||
entries.remove(&endpoint);
|
||||
if entries.is_empty() {
|
||||
map.remove(&key);
|
||||
}
|
||||
}
|
||||
});
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
let candidates: Vec<Candidate> = self.with_inner(|map| {
|
||||
map.get(&key)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.values()
|
||||
.cloned()
|
||||
.map(|addr| Candidate::new(addr, CandidateSource::Discovery))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
Box::pin(async move { Ok(candidates) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Combines several backends, concatenating their candidates.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompositeDiscovery {
|
||||
backends: Vec<Arc<dyn NetworkDiscovery>>,
|
||||
}
|
||||
|
||||
impl CompositeDiscovery {
|
||||
/// Creates a composite over the given backends.
|
||||
pub fn new(backends: impl IntoIterator<Item = Arc<dyn NetworkDiscovery>>) -> Self {
|
||||
Self {
|
||||
backends: backends.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkDiscovery for CompositeDiscovery {
|
||||
fn name(&self) -> &str {
|
||||
"composite"
|
||||
}
|
||||
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
for backend in &self.backends {
|
||||
// One failing backend must not stop the others.
|
||||
if let Err(err) = backend.publish(key, addr.clone()).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "publish failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
for backend in &self.backends {
|
||||
if let Err(err) = backend.unpublish(key, endpoint).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "unpublish failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
Box::pin(async move {
|
||||
let mut out = Vec::new();
|
||||
for backend in &self.backends {
|
||||
match backend.resolve(key).await {
|
||||
Ok(mut found) => out.append(&mut found),
|
||||
Err(err) => {
|
||||
tracing::debug!(backend = backend.name(), %err, "resolve failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user