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:
+239
-28
@@ -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:?}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user