Protocol v2: trusted-peer push, truthful partials, split downloads

Three capabilities the iroh-blobs 0.103 line makes possible:

- Push (new API surface, protocol v2): `Push { hash, node_id }` hands
  fully-present content to a trusted peer, unprompted. Consent is
  mutual — the sender pushes only to peers it trusts, and the receiver
  (which now accepts connections unconditionally and gates per request)
  admits pushes only from peers *it* trusts, deferring with RateLimited
  while metered. An accepted push is pinned by the receiver (default
  policy, format inferred from the request ranges) once its transfer
  completes, and surfaces as a PushReceived event. Because QUIC writes
  are fire-and-forget, the sender confirms delivery by observing the
  receiver's bitfields (root + last hashseq child) before replying —
  Pushed { bytes } means verified received, not merely sent. New
  varde-ctl `push` command.

- Truthful partial presence: Status/List report bytes actually present
  and verified for partial blobs, from the store's bitfields via
  observe, instead of the old "0 until complete".

- Split downloads: multi-provider fetches stripe one request across
  providers (SplitStrategy::Split) instead of trying them serially.

Trusted peers may observe bitfields even when serving is disabled or
metered — bitfields are metadata, and push confirmation rides on them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 14:52:27 +02:00
parent ad69f7d884
commit 4033c0ffab
9 changed files with 474 additions and 33 deletions
+118 -2
View File
@@ -13,6 +13,7 @@ use iroh::address_lookup::memory::MemoryLookup;
use iroh::protocol::Router;
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey};
use iroh_blobs::api::downloader::{DownloadOptions, Downloader, Shuffled, SplitStrategy};
use iroh_blobs::protocol::GetRequest;
use iroh_blobs::{BlobFormat, BlobsProtocol, Hash, HashAndFormat};
use iroh_mdns_address_lookup::{DiscoveryEvent, MdnsAddressLookup};
use n0_future::StreamExt;
@@ -23,7 +24,7 @@ use varde_proto::{Direction, Event};
use crate::config::Config;
use crate::meta::Meta;
use crate::metered::MeteredState;
use crate::shaped::{Gate, ServeFilter, TokenBucket};
use crate::shaped::{Gate, ReceivedPush, ServeFilter, TokenBucket};
use crate::store::BlobStore;
/// The network side of the daemon: one iroh endpoint serving the blob
@@ -33,6 +34,7 @@ use crate::store::BlobStore;
pub struct Transfer {
endpoint: Endpoint,
router: Router,
store: iroh_blobs::api::Store,
downloader: Downloader,
/// Address book for provider addresses learned out-of-band (ticket
/// imports): the downloader dials by endpoint id and resolves
@@ -41,6 +43,7 @@ pub struct Transfer {
events: broadcast::Sender<Event>,
metered: MeteredState,
discovery_rx: std::sync::Mutex<Option<mpsc::Receiver<EndpointId>>>,
pushes_rx: std::sync::Mutex<Option<mpsc::Receiver<ReceivedPush>>>,
}
impl Transfer {
@@ -108,6 +111,7 @@ impl Transfer {
// Best-effort low-priority marking of our UDP traffic.
crate::dscp::mark_endpoint_sockets(&endpoint.bound_sockets());
let (pushes_tx, pushes_rx) = mpsc::channel(16);
let provider_events = crate::shaped::provider_events(Gate {
meta,
open_filter,
@@ -115,6 +119,7 @@ impl Transfer {
serving_enabled: config.max_upload_bytes_per_sec > 0,
metered: metered.clone(),
events: events.clone(),
pushes: pushes_tx,
});
let provider = BlobsProtocol::new(store.api(), Some(provider_events));
@@ -126,11 +131,13 @@ impl Transfer {
Ok(Transfer {
endpoint,
router,
store: store.api().clone(),
downloader,
known_addrs,
events,
metered,
discovery_rx: std::sync::Mutex::new(Some(discovery_rx)),
pushes_rx: std::sync::Mutex::new(Some(pushes_rx)),
})
}
@@ -151,6 +158,108 @@ impl Transfer {
self.discovery_rx.lock().expect("discovery lock").take()
}
/// Take the stream of completed inbound pushes. Callable once.
pub fn take_pushes(&self) -> Option<mpsc::Receiver<ReceivedPush>> {
self.pushes_rx.lock().expect("pushes lock").take()
}
/// Push fully-present `content` to `peer`, unprompted. The peer
/// accepts only if it trusts us. Returns payload bytes written.
pub async fn push_to(&self, peer: EndpointId, content: HashAndFormat) -> Result<u64> {
anyhow::ensure!(
!self.metered.is_metered(),
"metered connection: refusing to push"
);
let conn = self
.endpoint
.connect(peer, iroh_blobs::ALPN)
.await
.with_context(|| format!("connecting to {peer}"))?;
let request = match content.format {
BlobFormat::Raw => {
iroh_blobs::protocol::PushRequest::from(GetRequest::blob(content.hash))
}
BlobFormat::HashSeq => {
iroh_blobs::protocol::PushRequest::from(GetRequest::all(content.hash))
}
};
// Upstream returns zeroed Stats for pushes; the byte count rides
// the progress stream instead.
use iroh_blobs::api::remote::PushProgressItem;
let mut stream = self
.store
.remote()
.execute_push(conn.clone(), request)
.stream();
let mut bytes = 0u64;
let mut done = false;
while let Some(item) = stream.next().await {
match item {
PushProgressItem::Progress(sent) => {
bytes = sent;
let _ = self.events.send(Event::TransferProgress {
hash: content.hash.to_hex().to_string(),
direction: Direction::Upload,
bytes_done: sent,
bytes_total: None,
});
}
PushProgressItem::Done(_) => {
done = true;
break;
}
PushProgressItem::Error(e) => {
return Err(anyhow::anyhow!(e)).context("pushing content");
}
}
}
anyhow::ensure!(done, "push stream ended without a result");
// Writing is fire-and-forget at the QUIC level: closing the
// connection now could discard data the peer has not read yet,
// and says nothing about acceptance. Delivery is confirmed by
// observing the peer's bitfields until they report complete —
// the peer imports the root first, then children in order, so
// root + last child covers the whole request.
self.wait_remote_complete(&conn, content.hash).await?;
if content.format == BlobFormat::HashSeq {
let seq_bytes = self
.store
.blobs()
.get_bytes(content.hash)
.await
.context("reading pushed hashseq")?;
let seq = iroh_blobs::hashseq::HashSeq::try_from(seq_bytes)
.map_err(|e| anyhow::anyhow!("invalid hashseq: {e}"))?;
if let Some(last) = seq.iter().last() {
self.wait_remote_complete(&conn, last).await?;
}
}
info!(hash = %content.hash.to_hex(), peer = %peer, bytes, "push complete, verified by peer");
Ok(bytes)
}
/// Watch `hash` on the remote end of `conn` until its bitfield
/// reports the blob complete.
async fn wait_remote_complete(
&self,
conn: &iroh::endpoint::Connection,
hash: Hash,
) -> Result<()> {
let observe = self.store.remote().observe(
conn.clone(),
iroh_blobs::protocol::ObserveRequest::new(hash),
);
let mut observe = std::pin::pin!(observe);
while let Some(bitfield) = observe.next().await {
let bitfield = bitfield.context("observing pushed content on the peer")?;
if bitfield.is_complete() {
return Ok(());
}
}
anyhow::bail!("peer stopped reporting before the pushed content completed");
}
/// Produce a ticket for out-of-band sharing of `content`.
pub async fn export_ticket(&self, content: HashAndFormat) -> Result<String> {
let addr = self.endpoint.addr();
@@ -181,13 +290,20 @@ impl Transfer {
}
}
// With several providers one fetch stripes across all of them;
// a lone provider gets the whole request.
let strategy = if ids.len() > 1 {
SplitStrategy::Split
} else {
SplitStrategy::None
};
let downloader = self.downloader.clone();
let events = self.events.clone();
tokio::spawn(async move {
let progress = downloader.download_with_opts(DownloadOptions::new(
content,
Shuffled::new(ids),
SplitStrategy::None,
strategy,
));
let mut stream = match progress.stream().await {
Ok(stream) => stream,