Milestone 4: LAN discovery, trust gate, auto-sync, rate limits, events

mDNS-style LAN discovery (iroh MdnsDiscovery, discovery flag, default
on) feeds a presence tracker; trusted peers that appear trigger fetches
of every incomplete pin, and Pin itself now fetches from present
trusted peers. Serving is our own ProtocolHandler: trusted NodeIds get
the full store, everyone else a filtered view limited to open_lan pins
and ticket-exported hashes (plus hashseq children) that answers "not
found" for the rest. Ticket export records standing serve-consent for
that hash; trust changes take effect on new connections. ShapedStore
implements the full iroh-blobs Store trait to charge provider reads to
an upload token bucket and downloader writes to a download bucket;
upload cap 0 closes incoming connections at accept. Subscribe now
streams transfer_progress both ways, peer_joined, and pin_complete.

Tests: forged-ticket trust gating (denied untrusted, served after
trust), 256 KiB/s upload cap enforced by wall clock, event stream
during a transfer, and real-mdns auto-sync between two daemons
(skips where multicast is unavailable).

Dependencies: n0-future, async-channel, futures-lite, bytes — all
already in the tree via iroh; needed directly to name types in
iroh-blobs trait signatures and channels. iroh feature
discovery-local-network for MdnsDiscovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 22:27:58 +02:00
parent 61171a5ea8
commit 20bdf56668
12 changed files with 1308 additions and 66 deletions
+239 -28
View File
@@ -1,13 +1,17 @@
//! Request dispatch and daemon state.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::{Arc, Mutex, RwLock};
use std::time::{Duration, Instant};
use anyhow::Context;
use iroh_blobs::{BlobFormat, Hash, HashAndFormat};
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
use varde_proto::{
Envelope, ErrorCode, Event, GlobalStatus, HashStatus, PinInfo, Request, ResponseData,
Envelope, ErrorCode, Event, GlobalStatus, HashStatus, PeerInfo, PinInfo, Request, ResponseData,
};
use crate::config::Config;
@@ -15,36 +19,97 @@ use crate::meta::{Meta, StoredFormat};
use crate::store::{BlobStore, OpError};
use crate::transfer::Transfer;
/// Shared daemon state. Handlers grow with the milestones; anything not
/// yet wired returns [`ErrorCode::Unimplemented`] rather than pretending.
/// A LAN peer is considered present if discovery has seen it within this
/// window (mdns re-announces well below this).
const PEER_PRESENCE_WINDOW: Duration = Duration::from_secs(120);
/// Suppress duplicate PeerJoined events within this window.
const PEER_REJOIN_WINDOW: Duration = Duration::from_secs(60);
/// Shared daemon state.
pub struct Daemon {
config: Config,
store: BlobStore,
meta: Meta,
meta: Arc<Meta>,
transfer: Transfer,
events: broadcast::Sender<Event>,
/// Hashes servable to untrusted peers (open_lan pins, exported
/// tickets, and their hashseq children). Read by the provider filter
/// on every untrusted request.
open_set: Arc<RwLock<BTreeSet<Hash>>>,
/// LAN peers seen via discovery: node id -> last sighting.
lan_peers: Mutex<BTreeMap<String, Instant>>,
}
impl Daemon {
/// Open the blob store and metadata under the configured store dir,
/// and bring up the iroh endpoint.
pub async fn open(config: Config) -> anyhow::Result<Daemon> {
/// bring up the iroh endpoint, and start the discovery/sync loops.
pub async fn open(config: Config) -> anyhow::Result<Arc<Daemon>> {
std::fs::create_dir_all(&config.store_dir)
.with_context(|| format!("creating store directory {}", config.store_dir.display()))?;
let store = BlobStore::open(&config.store_dir).await?;
let meta = Meta::load(&config.store_dir)?;
let meta = Arc::new(Meta::load(&config.store_dir)?);
// Capacity bounds memory if a subscriber stalls; laggards get a
// Lagged error, not unbounded buffering.
let (events, _) = broadcast::channel(1024);
let transfer =
Transfer::start(&config, &store, store.pool_handle(), events.clone()).await?;
Ok(Daemon {
let open_set: Arc<RwLock<BTreeSet<Hash>>> = Arc::default();
let filter_set = open_set.clone();
let open_filter: crate::shaped::ServeFilter =
Arc::new(move |hash| filter_set.read().expect("open set lock").contains(hash));
let transfer = Transfer::start(
&config,
&store,
meta.clone(),
open_filter,
store.pool_handle(),
events.clone(),
)
.await?;
let daemon = Arc::new(Daemon {
config,
store,
meta,
transfer,
events,
})
open_set,
lan_peers: Mutex::new(BTreeMap::new()),
});
daemon.recompute_open_set().await;
// Completed fetches can turn hashseq roots expandable; refresh
// the open set whenever a pin finishes.
let weak = Arc::downgrade(&daemon);
let mut pin_events = daemon.events.subscribe();
tokio::spawn(async move {
loop {
match pin_events.recv().await {
Ok(Event::PinComplete { .. }) => {
let Some(daemon) = weak.upgrade() else { break };
daemon.recompute_open_set().await;
}
Ok(_) => {}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
// Discovery loop: track LAN peer sightings, emit join events for
// trusted peers and sync any incomplete pins from them.
if let Some(mut discovered) = daemon.transfer.take_discovery() {
let weak = Arc::downgrade(&daemon);
tokio::spawn(async move {
while let Some(node_id) = discovered.recv().await {
let Some(daemon) = weak.upgrade() else { break };
daemon.on_peer_seen(node_id).await;
}
});
}
Ok(daemon)
}
/// Flush the store and close the endpoint.
@@ -61,15 +126,103 @@ impl Daemon {
&self.store
}
pub fn meta(&self) -> &Meta {
&self.meta
}
/// New receiver for the daemon event stream.
pub fn subscribe_events(&self) -> broadcast::Receiver<Event> {
self.events.subscribe()
}
/// Rebuild the set of hashes servable to untrusted peers.
async fn recompute_open_set(&self) {
let mut open = BTreeSet::new();
let mut roots: Vec<String> = self
.meta
.pins()
.into_iter()
.filter(|(_, record)| record.policy.open_lan)
.map(|(hash, _)| hash)
.collect();
roots.extend(self.meta.exported());
for hex in roots {
let Ok(hash) = Hash::from_str(&hex) else {
continue;
};
open.insert(hash);
if self.meta.format_of(&hex) == StoredFormat::HashSeq {
match self.store.hashseq_children(hash).await {
Ok(children) => open.extend(children),
Err(e) => warn!(hash = %hex, error = %e, "expanding open hashseq"),
}
}
}
debug!(hashes = open.len(), "open set recomputed");
*self.open_set.write().expect("open set lock") = open;
}
/// Handle a discovery sighting of `node_id`.
async fn on_peer_seen(&self, node_id: iroh::NodeId) {
let id = node_id.to_string();
let rejoined = {
let mut peers = self.lan_peers.lock().expect("lan peers lock");
let now = Instant::now();
let fresh = match peers.get(&id) {
Some(last) => now.duration_since(*last) > PEER_REJOIN_WINDOW,
None => true,
};
peers.insert(id.clone(), now);
fresh
};
if !self.meta.is_trusted(&id) {
return;
}
if rejoined {
info!(peer = %id, "trusted peer present on LAN");
let _ = self.events.send(Event::PeerJoined { node_id: id });
}
self.sync_pins_from(node_id).await;
}
/// Queue fetches for every incomplete pin from a trusted LAN peer.
/// The downloader dedupes by content, so repeated sightings are
/// cheap; discovery resolves the peer's addresses.
async fn sync_pins_from(&self, node_id: iroh::NodeId) {
for (hex, _record) in self.meta.pins() {
let Ok(hash) = Hash::from_str(&hex) else {
continue;
};
match self.store.presence(hash).await {
Ok((_, _, true)) => {} // complete, nothing to do
Ok(_) => {
debug!(hash = %hex, peer = %node_id, "syncing incomplete pin");
self.transfer.spawn_fetch(
HashAndFormat {
hash,
format: blob_format(self.meta.format_of(&hex)),
},
vec![iroh::NodeAddr::new(node_id)],
);
}
Err(e) => warn!(hash = %hex, error = %e, "checking pin presence"),
}
}
}
/// Trusted peers seen on the LAN recently.
fn present_trusted_peers(&self) -> Vec<PeerInfo> {
let peers = self.lan_peers.lock().expect("lan peers lock");
let now = Instant::now();
self.meta
.trusted_peers()
.into_iter()
.map(|node_id| {
let connected = peers
.get(&node_id)
.is_some_and(|last| now.duration_since(*last) < PEER_PRESENCE_WINDOW);
PeerInfo { node_id, connected }
})
.collect()
}
/// Handle one API request. Infallible at this level: all failures are
/// mapped to structured error envelopes.
pub async fn handle(&self, request: Request) -> Envelope {
@@ -93,8 +246,26 @@ impl Daemon {
Request::Pin { hash, policy } => {
let parsed = parse_hash(&hash)?;
self.meta.pin(&parsed.to_hex(), policy)?;
// Fetching absent pinned content arrives with the
// transfer milestone.
self.recompute_open_set().await;
// Fetch if absent: try every trusted peer currently
// present on the LAN.
if let Ok((_, _, false)) = self.store.presence(parsed).await {
let present: Vec<iroh::NodeAddr> = self
.present_trusted_peers()
.into_iter()
.filter(|p| p.connected)
.filter_map(|p| p.node_id.parse().ok().map(iroh::NodeAddr::new))
.collect();
if !present.is_empty() {
self.transfer.spawn_fetch(
HashAndFormat {
hash: parsed,
format: blob_format(self.meta.format_of(&parsed.to_hex())),
},
present,
);
}
}
Ok(ResponseData::Pinned {
hash: parsed.to_hex().to_string(),
})
@@ -104,6 +275,7 @@ impl Daemon {
if !self.meta.unpin(&parsed.to_hex())? {
return Err(OpError::NotFound(format!("no pin for {}", parsed.to_hex())));
}
self.recompute_open_set().await;
Ok(ResponseData::Done {})
}
Request::Materialize { hash, dest, mode } => {
@@ -115,14 +287,21 @@ impl Daemon {
.await?;
Ok(ResponseData::Materialized { bytes, reflinked })
}
Request::Status { hash: None } => Ok(ResponseData::Status(GlobalStatus {
version: env!("CARGO_PKG_VERSION").to_string(),
protocol: varde_proto::PROTOCOL_VERSION,
node_id: Some(self.transfer.node_id()),
store_path: self.config.store_dir.display().to_string(),
pins: self.meta.pins().len() as u64,
connected_peers: 0,
})),
Request::Status { hash: None } => {
let connected = self
.present_trusted_peers()
.iter()
.filter(|p| p.connected)
.count() as u64;
Ok(ResponseData::Status(GlobalStatus {
version: env!("CARGO_PKG_VERSION").to_string(),
protocol: varde_proto::PROTOCOL_VERSION,
node_id: Some(self.transfer.node_id()),
store_path: self.config.store_dir.display().to_string(),
pins: self.meta.pins().len() as u64,
connected_peers: connected,
}))
}
Request::Status { hash: Some(hash) } => {
let parsed = parse_hash(&hash)?;
let (have_bytes, total_bytes, complete) = self.store.presence(parsed).await?;
@@ -169,6 +348,10 @@ impl Daemon {
format: blob_format(self.meta.format_of(&parsed.to_hex())),
};
let ticket = self.transfer.export_ticket(content).await?;
// Exporting a ticket is deliberate publication: the
// holder must be able to fetch without being trusted.
self.meta.record_exported(&parsed.to_hex())?;
self.recompute_open_set().await;
Ok(ResponseData::Ticket { ticket })
}
Request::TicketImport { ticket, pin_policy } => {
@@ -183,16 +366,44 @@ impl Daemon {
// data alive, and it is the standing intent even if the
// provider is unreachable right now.
self.meta.pin(&hash.to_hex(), pin_policy)?;
self.recompute_open_set().await;
self.transfer
.spawn_fetch(HashAndFormat { hash, format }, vec![provider]);
Ok(ResponseData::Pinned {
hash: hash.to_hex().to_string(),
})
}
Request::PeerTrust { node_id } => {
let parsed: iroh::NodeId = node_id
.parse()
.map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?;
self.meta.trust_peer(&parsed.to_string())?;
info!(peer = %parsed, "peer trusted");
// If the peer is already visible on the LAN, sync now.
let present = {
let peers = self.lan_peers.lock().expect("lan peers lock");
peers.get(&parsed.to_string()).is_some_and(|last| {
Instant::now().duration_since(*last) < PEER_PRESENCE_WINDOW
})
};
if present {
self.sync_pins_from(parsed).await;
}
Ok(ResponseData::Done {})
}
Request::PeerUntrust { node_id } => {
let parsed: iroh::NodeId = node_id
.parse()
.map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?;
if !self.meta.untrust_peer(&parsed.to_string())? {
return Err(OpError::NotFound(format!("{parsed} was not trusted")));
}
Ok(ResponseData::Done {})
}
Request::PeerList {} => Ok(ResponseData::Peers {
peers: self.present_trusted_peers(),
}),
Request::Subscribe {} => Ok(ResponseData::Done {}),
other => Err(OpError::Unimplemented(format!(
"not implemented yet: {other:?}"
))),
}
}