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:
tsunagiandClaude Opus 5 committed 2026-09-21 00:10:07 +01:00
commit 7cea9afa37
44 files changed
+12916

No files matched your search

+310
View File
@@ -0,0 +1,310 @@
//! Persistence, split into mandatory state and a disposable cache.
//!
//! | store | contents | on damage |
//! |----------------|--------------------------------------------------------|-----------|
//! | `state.sqlite` | device identity, network configuration, hostname | hard error |
//! | `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
//!
//! Both files are created with owner-only permissions where the platform
//! supports it. The state directory additionally carries an ownership lock, see
//! [`DirectoryLock`].
//!
//! SQLite is synchronous. Every call that touches a database therefore runs on
//! a blocking pool via [`tokio::task::spawn_blocking`], and no database lock is
//! ever held across a network `await`.
mod cache;
mod lock;
mod state;
pub use cache::{AddressHint, CacheOutcome, CacheStore};
pub use lock::DirectoryLock;
pub use state::{SCHEMA_VERSION, StateStore, StoredNetwork};
use std::path::Path;
use std::sync::{Arc, Mutex, MutexGuard};
use crate::config::StoragePaths;
use crate::error::{Error, Result};
use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
/// Applies the pragmas both stores share.
fn apply_common_pragmas(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
conn.busy_timeout(std::time::Duration::from_secs(5))?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "synchronous", "NORMAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
Ok(())
}
/// Restricts a file to the current user where the platform supports it.
#[cfg(unix)]
fn restrict_permissions(file: &std::fs::File, path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
file.set_permissions(perms).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})
}
/// On Windows, files inherit the parent directory's ACL, which for the
/// per-user application data directory is already restricted to that user.
#[cfg(not(unix))]
fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn restrict_path_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
Error::Io {
path: path.to_path_buf(),
source,
}
})
}
#[cfg(not(unix))]
fn restrict_path_permissions(_path: &Path) -> Result<()> {
Ok(())
}
#[cfg(unix)]
fn restrict_dir_permissions(path: &Path) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
Error::Io {
path: path.to_path_buf(),
source,
}
})
}
#[cfg(not(unix))]
fn restrict_dir_permissions(_path: &Path) -> Result<()> {
Ok(())
}
fn create_dir(path: &Path) -> Result<()> {
std::fs::create_dir_all(path).map_err(|source| Error::Io {
path: path.to_path_buf(),
source,
})?;
restrict_dir_permissions(path)
}
/// Async facade over both stores, holding the directory ownership lock.
///
/// Cloning shares the same underlying connections and the same lock.
#[derive(Debug, Clone)]
pub struct Storage {
inner: Arc<Inner>,
}
#[derive(Debug)]
struct Inner {
state: Mutex<StateStore>,
cache: Mutex<Option<CacheStore>>,
cache_outcome: CacheOutcome,
paths: StoragePaths,
lock: Mutex<Option<DirectoryLock>>,
}
impl Storage {
/// Opens both stores and takes the ownership lock on the state directory.
///
/// Fails with [`Error::StateLocked`] if another live agent owns the state
/// directory, and with [`Error::StateCorrupted`] if the mandatory state is
/// unusable. A broken cache is silently discarded and reported through
/// [`Storage::cache_outcome`].
pub fn open(paths: &StoragePaths) -> Result<Self> {
create_dir(&paths.state_dir)?;
create_dir(&paths.cache_dir)?;
let lock = DirectoryLock::acquire(paths.lock_file())?;
let state = StateStore::open(paths.state_db())?;
let (cache, cache_outcome) = CacheStore::open_or_reset(paths.cache_db())?;
Ok(Self {
inner: Arc::new(Inner {
state: Mutex::new(state),
cache: Mutex::new(Some(cache)),
cache_outcome,
paths: paths.clone(),
lock: Mutex::new(Some(lock)),
}),
})
}
/// What happened to the cache when the agent started.
pub fn cache_outcome(&self) -> &CacheOutcome {
&self.inner.cache_outcome
}
/// The configured paths.
pub fn paths(&self) -> &StoragePaths {
&self.inner.paths
}
fn lock_state(&self) -> MutexGuard<'_, StateStore> {
match self.inner.state.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn lock_cache(&self) -> MutexGuard<'_, Option<CacheStore>> {
match self.inner.cache.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// Runs a closure against the mandatory state store on the blocking pool.
async fn with_state<T, F>(&self, f: F) -> Result<T>
where
T: Send + 'static,
F: FnOnce(&StateStore) -> Result<T> + Send + 'static,
{
let inner = Arc::clone(&self.inner);
tokio::task::spawn_blocking(move || {
let guard = match inner.state.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
f(&guard)
})
.await
.map_err(|err| Error::Storage(format!("state task failed: {err}")))?
}
/// Runs a closure against the cache, tolerating an unavailable cache.
///
/// If the cache has been disabled because it misbehaved, the closure is
/// skipped and `default` is returned.
async fn with_cache<T, F>(&self, default: T, f: F) -> T
where
T: Send + 'static,
F: FnOnce(&CacheStore) -> Result<T> + Send + 'static,
{
let inner = Arc::clone(&self.inner);
let joined = tokio::task::spawn_blocking(move || {
let guard = match inner.cache.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
match guard.as_ref() {
Some(cache) => f(cache),
None => Err(Error::Storage("cache is unavailable".into())),
}
})
.await;
match joined {
Ok(Ok(value)) => value,
Ok(Err(err)) => {
tracing::debug!(%err, "cache operation failed; continuing without it");
default
}
Err(err) => {
tracing::debug!(%err, "cache task failed; continuing without it");
default
}
}
}
/// Loads or creates the persistent device identity.
pub async fn device_identity(&self) -> Result<DeviceIdentity> {
self.with_state(|state| state.load_or_create_device_identity())
.await
}
/// Lists configured networks.
pub async fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
self.with_state(|state| state.list_networks()).await
}
/// Stores or updates a network configuration.
pub async fn upsert_network(
&self,
network_id: NetworkId,
name: NetworkName,
secret: NetworkSecret,
auto_start: bool,
) -> Result<()> {
self.with_state(move |state| state.upsert_network(network_id, &name, &secret, auto_start))
.await
}
/// Updates the auto-start flag of a network.
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
.await
}
/// Removes a network configuration and its cached hints.
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
self.with_state(move |state| state.remove_network(network_id))
.await?;
self.with_cache((), move |cache| cache.forget_network(network_id))
.await;
Ok(())
}
/// Reads the stored hostname.
pub async fn hostname(&self) -> Result<Option<String>> {
self.with_state(|state| state.hostname()).await
}
/// Writes the stored hostname.
pub async fn set_hostname(&self, hostname: String) -> Result<()> {
self.with_state(move |state| state.set_hostname(&hostname))
.await
}
/// Records an address hint. Failures are non-fatal.
pub async fn record_hint(
&self,
network_id: NetworkId,
endpoint_id: [u8; 32],
addr: String,
max_per_peer: usize,
) {
self.with_cache((), move |cache| {
cache.record_hint(network_id, &endpoint_id, &addr, max_per_peer)
})
.await;
}
/// Reads cached address hints. Returns an empty list if the cache is gone.
pub async fn hints_for_network(&self, network_id: NetworkId) -> Vec<AddressHint> {
self.with_cache(Vec::new(), move |cache| cache.hints_for_network(network_id))
.await
}
/// Synchronously reads the hostname. Used only during startup.
pub(crate) fn hostname_blocking(&self) -> Result<Option<String>> {
self.lock_state().hostname()
}
/// Whether the cache is currently usable.
pub fn cache_healthy(&self) -> bool {
self.lock_cache().is_some()
}
/// Releases the state directory ownership lock.
///
/// Called by [`crate::Agent::shutdown`] so that a cleanly stopped agent
/// leaves its directory immediately claimable by another instance. The
/// databases stay open and readable, but this handle no longer owns the
/// directory and must not be used to write after this point.
pub fn release_ownership_lock(&self) {
let mut guard = match self.inner.lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard.take();
}
}