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
+179 -28
View File
@@ -1,35 +1,106 @@
//! iroh endpoint, blob provider and downloader.
//!
//! This module is also the seam where transport-level traffic shaping
//! lives: the rate limiters (milestone 4) and, later, scavenger
//! congestion control would slot in here without touching the daemon
//! logic above.
//! This module is the transport seam: rate limiting lives in
//! [`crate::shaped::ShapedStore`] wired up here, and a scavenger
//! congestion controller would later slot in at the same place without
//! touching the daemon logic above.
use std::path::Path;
use std::sync::Arc;
use anyhow::{Context, Result};
use iroh::protocol::Router;
use iroh::{Endpoint, NodeAddr, RelayMode, SecretKey};
use iroh::discovery::mdns::MdnsDiscovery;
use iroh::endpoint::Connection;
use iroh::protocol::{ProtocolHandler, Router};
use iroh::{Endpoint, NodeAddr, NodeId, RelayMode, SecretKey};
use iroh_blobs::downloader::{DownloadRequest, Downloader};
use iroh_blobs::net_protocol::Blobs;
use iroh_blobs::ticket::BlobTicket;
use iroh_blobs::get::db::DownloadProgress;
use iroh_blobs::provider::{handle_connection, CustomEventSender, EventSender};
use iroh_blobs::util::local_pool::LocalPoolHandle;
use iroh_blobs::util::progress::AsyncChannelProgressSender;
use iroh_blobs::{BlobFormat, Hash, HashAndFormat};
use tokio::sync::broadcast;
use n0_future::boxed::BoxFuture;
use n0_future::StreamExt;
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, info, warn};
use varde_proto::Event;
use varde_proto::{Direction, Event};
use crate::config::Config;
use crate::meta::Meta;
use crate::shaped::{ServeFilter, ShapedStore, TokenBucket};
use crate::store::BlobStore;
/// The network side of the daemon: one iroh endpoint serving the blob
/// store, plus a downloader for fetching pinned content from providers.
/// store (trust-gated, rate-limited), plus a downloader for fetching
/// pinned content from providers.
#[derive(Debug)]
pub struct Transfer {
endpoint: Endpoint,
router: Router,
downloader: Downloader,
events: broadcast::Sender<Event>,
discovery_rx: std::sync::Mutex<Option<mpsc::Receiver<NodeId>>>,
}
/// Serves blob requests with trust gating: trusted peers see the whole
/// store, untrusted peers see only the openly-served subset (and get
/// "not found" for the rest). With serving disabled (upload cap 0) every
/// incoming connection is closed immediately.
#[derive(Debug, Clone)]
struct GatedProvider {
full_view: ShapedStore,
open_view: ShapedStore,
meta: Arc<Meta>,
events: EventSender,
pool: LocalPoolHandle,
serving_enabled: bool,
}
impl ProtocolHandler for GatedProvider {
fn accept(&self, connection: Connection) -> BoxFuture<Result<()>> {
let this = self.clone();
Box::pin(async move {
let remote = connection.remote_node_id()?;
if !this.serving_enabled {
debug!(%remote, "serving disabled, closing connection");
connection.close(0u32.into(), b"serving disabled");
return Ok(());
}
let trusted = this.meta.is_trusted(&remote.to_string());
debug!(%remote, trusted, "incoming blobs connection");
if trusted {
handle_connection(connection, this.full_view, this.events, this.pool).await;
} else {
handle_connection(connection, this.open_view, this.events, this.pool).await;
}
Ok(())
})
}
}
/// Forwards provider transfer events into the daemon event stream.
#[derive(Debug)]
struct UploadEvents(broadcast::Sender<Event>);
impl CustomEventSender for UploadEvents {
fn send(&self, event: iroh_blobs::provider::Event) -> BoxFuture<()> {
self.try_send(event);
Box::pin(async {})
}
fn try_send(&self, event: iroh_blobs::provider::Event) {
if let iroh_blobs::provider::Event::TransferProgress {
hash, end_offset, ..
} = event
{
let _ = self.0.send(Event::TransferProgress {
hash: hash.to_hex().to_string(),
direction: Direction::Upload,
bytes_done: end_offset,
bytes_total: None,
});
}
}
}
impl Transfer {
@@ -41,23 +112,57 @@ impl Transfer {
pub async fn start(
config: &Config,
store: &BlobStore,
meta: Arc<Meta>,
open_filter: ServeFilter,
pool: LocalPoolHandle,
events: broadcast::Sender<Event>,
) -> Result<Transfer> {
let secret = load_or_create_secret(&config.store_dir.join("secret.key"))?;
let mut builder = Endpoint::builder().secret_key(secret);
let mut builder = Endpoint::builder().secret_key(secret.clone());
if !config.wan_upload {
builder = builder.relay_mode(RelayMode::Disabled);
}
let endpoint = builder.bind().await.context("binding iroh endpoint")?;
info!(node_id = %endpoint.node_id(), "endpoint up");
let blobs = Blobs::builder(store.inner().clone())
.local_pool(pool)
.build(&endpoint);
let downloader = blobs.downloader().clone();
// LAN discovery: advertise under iroh's mDNS-style local
// discovery service (identifiable `_iroh` mdns records, not
// anonymous noise). Discovered node ids stream to the daemon,
// which decides what to do based on trust.
let (discovery_tx, discovery_rx) = mpsc::channel(64);
if config.discovery {
let mdns =
MdnsDiscovery::new(secret.public()).context("starting mdns local discovery")?;
if let Some(mut stream) = iroh::discovery::Discovery::subscribe(&mdns) {
tokio::spawn(async move {
while let Some(item) = stream.next().await {
if discovery_tx.send(item.node_id()).await.is_err() {
break;
}
}
});
}
builder = builder.discovery(Box::new(mdns));
}
let endpoint = builder.bind().await.context("binding iroh endpoint")?;
info!(node_id = %endpoint.node_id(), discovery = config.discovery, "endpoint up");
let upload_bucket = TokenBucket::new(config.max_upload_bytes_per_sec);
let download_bucket = TokenBucket::new(config.max_download_bytes_per_sec);
let shaped = ShapedStore::new(store.inner().clone(), upload_bucket, download_bucket);
let provider_events = EventSender::new(Some(Arc::new(UploadEvents(events.clone()))));
let provider = GatedProvider {
full_view: shaped.clone(),
open_view: shaped.filtered(open_filter),
meta,
events: provider_events,
pool: pool.clone(),
serving_enabled: config.max_upload_bytes_per_sec > 0,
};
let downloader = Downloader::new(shaped, endpoint.clone(), pool);
let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs)
.accept(iroh_blobs::ALPN, provider)
.spawn();
Ok(Transfer {
@@ -65,6 +170,7 @@ impl Transfer {
router,
downloader,
events,
discovery_rx: std::sync::Mutex::new(Some(discovery_rx)),
})
}
@@ -73,6 +179,13 @@ impl Transfer {
self.endpoint.node_id().to_string()
}
/// Take the stream of locally discovered node ids. Yields each
/// sighting (mdns re-announces periodically); the daemon dedupes.
/// Callable once; returns None afterwards or with discovery off.
pub fn take_discovery(&self) -> Option<mpsc::Receiver<NodeId>> {
self.discovery_rx.lock().expect("discovery lock").take()
}
/// Produce a ticket for out-of-band sharing of `content`.
pub async fn export_ticket(&self, content: HashAndFormat) -> Result<String> {
let addr = self
@@ -80,27 +193,38 @@ impl Transfer {
.node_addr()
.await
.context("waiting for endpoint address")?;
let ticket = BlobTicket::new(addr, content.hash, content.format)?;
let ticket = iroh_blobs::ticket::BlobTicket::new(addr, content.hash, content.format)?;
Ok(ticket.to_string())
}
/// Queue a background fetch of `content` from `providers`. Emits
/// [`Event::PinComplete`] when the content is fully verified locally.
/// progress events and [`Event::PinComplete`] when fully verified.
pub fn spawn_fetch(&self, content: HashAndFormat, providers: Vec<NodeAddr>) {
let downloader = self.downloader.clone();
let events = self.events.clone();
let endpoint = self.endpoint.clone();
tokio::spawn(async move {
// The downloader dials by NodeId; the endpoint must be taught
// the provider's direct addresses first or the dial fails
// with "no addressing information".
// any direct addresses we know (ticket-embedded ones), or the
// dial fails with "no addressing information". Id-only
// provider entries resolve via discovery instead.
for provider in &providers {
debug!(node = %provider.node_id, addrs = ?provider.direct_addresses, "provider");
if let Err(e) = endpoint.add_node_addr(provider.clone()) {
warn!(node = %provider.node_id, error = %e, "adding provider address");
if !provider.direct_addresses.is_empty() || provider.relay_url.is_some() {
if let Err(e) = endpoint.add_node_addr(provider.clone()) {
debug!(node = %provider.node_id, error = %e, "adding provider address");
}
}
}
let request = DownloadRequest::new(content, providers);
let (progress_tx, progress_rx) = async_channel::bounded(64);
tokio::spawn(forward_download_progress(
content.hash,
progress_rx,
events.clone(),
));
let request = DownloadRequest::new(content, providers)
.progress_sender(AsyncChannelProgressSender::new(progress_tx));
let handle = downloader.queue(request).await;
match handle.await {
Ok(_stats) => {
@@ -125,9 +249,36 @@ impl Transfer {
}
}
/// Translate iroh-blobs download progress into daemon transfer events.
async fn forward_download_progress(
root: Hash,
rx: async_channel::Receiver<DownloadProgress>,
events: broadcast::Sender<Event>,
) {
let mut current_total = None;
while let Ok(progress) = rx.recv().await {
match progress {
DownloadProgress::Found { size, .. } => {
current_total = Some(size);
}
DownloadProgress::Progress { offset, .. } => {
let _ = events.send(Event::TransferProgress {
hash: root.to_hex().to_string(),
direction: Direction::Download,
bytes_done: offset,
bytes_total: current_total,
});
}
DownloadProgress::AllDone(_) | DownloadProgress::Abort(_) => break,
_ => {}
}
}
}
/// Parse a ticket string into (root, format, provider address).
pub fn parse_ticket(ticket: &str) -> Result<(Hash, BlobFormat, NodeAddr), String> {
let ticket: BlobTicket = ticket.parse().map_err(|e| format!("invalid ticket: {e}"))?;
let ticket: iroh_blobs::ticket::BlobTicket =
ticket.parse().map_err(|e| format!("invalid ticket: {e}"))?;
Ok((ticket.hash(), ticket.format(), ticket.node_addr().clone()))
}