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
+7
View File
@@ -54,6 +54,13 @@ $ varde-ctl status # shows this daemon's node id
$ varde-ctl peer trust <node-id-of-the-other-machine> # on both ends $ varde-ctl peer trust <node-id-of-the-other-machine> # on both ends
``` ```
Trusted peers can also hand content to each other directly, no ticket
round-trip — the receiver pins what it accepted:
```console
$ varde-ctl push 5b1c…e0 <node-id-of-the-other-machine>
```
## Building and testing ## Building and testing
```console ```console
+3 -2
View File
@@ -53,8 +53,9 @@ Requests (define these as enums in `varde-proto`):
| `Status { hash? }` | Global or per-hash: have/missing bytes, peers, transfer rates. | | `Status { hash? }` | Global or per-hash: have/missing bytes, peers, transfer rates. |
| `List {}` | All pins with policies and completeness. | | `List {}` | All pins with policies and completeness. |
| `TicketExport { hash }` / `TicketImport { ticket, pin_policy }` | iroh blob tickets — the v1 out-of-band sharing mechanism. | | `TicketExport { hash }` / `TicketImport { ticket, pin_policy }` | iroh blob tickets — the v1 out-of-band sharing mechanism. |
| `Gc {}` | Drop unpinned blobs. | | `Push { hash, node_id }` | Hand fully-present content to a trusted peer, unprompted. Mutual consent: sender pushes only to peers it trusts, receiver accepts only from peers it trusts, and pins what it accepted. Delivery is verified via the peer's bitfields before the reply. |
| `Subscribe {}` | Switch connection to event stream (transfer progress, peer joined, pin complete). | | `Gc {}` | Answers unimplemented since iroh-blobs 0.103: gc runs continuously inside the store (`gc_interval_secs`, default 300), protecting pins via a roots snapshot. |
| `Subscribe {}` | Switch connection to event stream (transfer progress, peer joined, pin complete, push received). |
Every response carries `{"ok": bool, ...}`. Errors are structured (`code`, `message`), never bare strings. Every response carries `{"ok": bool, ...}`. Errors are structured (`code`, `message`), never bare strings.
+12
View File
@@ -73,6 +73,14 @@ enum Command {
/// Peer trust operations /// Peer trust operations
#[command(subcommand)] #[command(subcommand)]
Peer(PeerCommand), Peer(PeerCommand),
/// Push fully-present content to a trusted peer
Push {
/// BLAKE3 hash (hex)
hash: String,
/// The receiving peer's iroh NodeId (z-base-32); must be trusted
/// on both sides
node_id: String,
},
/// Drop all unpinned blobs /// Drop all unpinned blobs
Gc, Gc,
/// Stream daemon events to stdout (one JSON object per line) /// Stream daemon events to stdout (one JSON object per line)
@@ -180,6 +188,7 @@ fn to_request(command: Command) -> Result<Request> {
Command::Peer(PeerCommand::Trust { node_id }) => Request::PeerTrust { node_id }, Command::Peer(PeerCommand::Trust { node_id }) => Request::PeerTrust { node_id },
Command::Peer(PeerCommand::Untrust { node_id }) => Request::PeerUntrust { node_id }, Command::Peer(PeerCommand::Untrust { node_id }) => Request::PeerUntrust { node_id },
Command::Peer(PeerCommand::List) => Request::PeerList {}, Command::Peer(PeerCommand::List) => Request::PeerList {},
Command::Push { hash, node_id } => Request::Push { hash, node_id },
Command::Gc => Request::Gc {}, Command::Gc => Request::Gc {},
Command::Subscribe => Request::Subscribe {}, Command::Subscribe => Request::Subscribe {},
}) })
@@ -291,5 +300,8 @@ fn print_human(data: Option<&ResponseData>) {
ResponseData::GcDone { blobs_removed } => { ResponseData::GcDone { blobs_removed } => {
println!("gc: removed {blobs_removed} blobs"); println!("gc: removed {blobs_removed} blobs");
} }
ResponseData::Pushed { hash, bytes } => {
println!("pushed {hash} ({bytes} bytes)");
}
} }
} }
+52
View File
@@ -125,6 +125,29 @@ impl Daemon {
} }
}); });
// Inbound pushes: content a trusted peer handed us, fully
// verified. Pin it (default policy) so it survives gc, and tell
// subscribers where it came from.
if let Some(mut pushes) = daemon.transfer.take_pushes() {
let weak = Arc::downgrade(&daemon);
tokio::spawn(async move {
while let Some(push) = pushes.recv().await {
let Some(daemon) = weak.upgrade() else { break };
let hex = push.hash.to_hex().to_string();
let from = push.from.clone().unwrap_or_default();
info!(hash = %hex, from = %from, "push received, pinning");
if let Err(e) = daemon.meta.record_format(&hex, push.format) {
warn!(hash = %hex, error = %e, "recording pushed format");
}
if let Err(e) = daemon.meta.pin(&hex, varde_proto::PinPolicy::default()) {
warn!(hash = %hex, error = %e, "pinning pushed content");
}
daemon.recompute_open_set().await;
let _ = daemon.events.send(Event::PushReceived { hash: hex, from });
}
});
}
// Discovery: raw mdns sightings feed presence tracking, and are // Discovery: raw mdns sightings feed presence tracking, and are
// re-emitted as signed announcements through the LanDiscovery // re-emitted as signed announcements through the LanDiscovery
// provider — the same seam a future gossip provider plugs into. // provider — the same seam a future gossip provider plugs into.
@@ -513,6 +536,35 @@ impl Daemon {
Request::PeerList {} => Ok(ResponseData::Peers { Request::PeerList {} => Ok(ResponseData::Peers {
peers: self.present_trusted_peers(), peers: self.present_trusted_peers(),
}), }),
Request::Push { hash, node_id } => {
let parsed = parse_hash(&hash)?;
let peer: iroh::EndpointId = node_id
.parse()
.map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?;
// Consent, sending side: we only push to peers we trust.
// (The receiver independently requires that it trusts us.)
if !self.meta.is_trusted(&peer.to_string()) {
return Err(OpError::InvalidArgument(format!(
"{peer} is not a trusted peer; trust it before pushing"
)));
}
let (_, _, complete) = self.store.presence(parsed).await?;
if !complete {
return Err(OpError::NotFound(format!(
"{} is not fully present; cannot push",
parsed.to_hex()
)));
}
let content = HashAndFormat {
hash: parsed,
format: blob_format(self.meta.format_of(&parsed.to_hex())),
};
let bytes = self.transfer.push_to(peer, content).await?;
Ok(ResponseData::Pushed {
hash: parsed.to_hex().to_string(),
bytes,
})
}
Request::Subscribe {} => Ok(ResponseData::Done {}), Request::Subscribe {} => Ok(ResponseData::Done {}),
} }
} }
+111 -23
View File
@@ -24,7 +24,7 @@ use tokio::sync::broadcast;
use tracing::debug; use tracing::debug;
use varde_proto::{Direction, Event}; use varde_proto::{Direction, Event};
use crate::meta::Meta; use crate::meta::{Meta, StoredFormat};
use crate::metered::MeteredState; use crate::metered::MeteredState;
/// An async token bucket. Rate 0 means unlimited. /// An async token bucket. Rate 0 means unlimited.
@@ -86,6 +86,15 @@ impl TokenBucket {
/// Predicate deciding whether a hash may be served to untrusted peers. /// Predicate deciding whether a hash may be served to untrusted peers.
pub type ServeFilter = Arc<dyn Fn(&Hash) -> bool + Send + Sync>; pub type ServeFilter = Arc<dyn Fn(&Hash) -> bool + Send + Sync>;
/// A completed inbound push, reported to the daemon for pinning.
#[derive(Debug)]
pub struct ReceivedPush {
pub hash: Hash,
pub format: StoredFormat,
/// NodeId of the pushing peer, when the connection exposed one.
pub from: Option<String>,
}
/// Provider gate configuration. /// Provider gate configuration.
pub struct Gate { pub struct Gate {
pub meta: Arc<Meta>, pub meta: Arc<Meta>,
@@ -94,6 +103,15 @@ pub struct Gate {
pub serving_enabled: bool, pub serving_enabled: bool,
pub metered: MeteredState, pub metered: MeteredState,
pub events: broadcast::Sender<Event>, pub events: broadcast::Sender<Event>,
/// Completed pushes flow to the daemon here.
pub pushes: tokio::sync::mpsc::Sender<ReceivedPush>,
}
/// What the gate knows about one live connection.
#[derive(Debug, Clone)]
struct ConnInfo {
trusted: bool,
endpoint_id: Option<String>,
} }
/// Spawn the provider event handler and return the [`EventSender`] to /// Spawn the provider event handler and return the [`EventSender`] to
@@ -103,18 +121,22 @@ pub fn provider_events(gate: Gate) -> EventSender {
connected: ConnectMode::Intercept, connected: ConnectMode::Intercept,
get: RequestMode::InterceptLog, get: RequestMode::InterceptLog,
get_many: RequestMode::InterceptLog, get_many: RequestMode::InterceptLog,
push: RequestMode::Disabled, // Push writes to the local store: intercepted and allowed for
// trusted peers only, with completion tracked for pinning.
push: RequestMode::InterceptLog,
observe: ObserveMode::Intercept, observe: ObserveMode::Intercept,
throttle: ThrottleMode::Intercept, throttle: ThrottleMode::Intercept,
}; };
let (sender, mut rx) = EventSender::channel(32, mask); let (sender, mut rx) = EventSender::channel(32, mask);
tokio::spawn(async move { tokio::spawn(async move {
// Trust decided once per connection, consulted per request. // Trust decided once per connection, consulted per request.
let mut trusted_conns: HashMap<u64, bool> = HashMap::new(); let mut conns: HashMap<u64, ConnInfo> = HashMap::new();
while let Some(msg) = rx.recv().await { while let Some(msg) = rx.recv().await {
match msg { match msg {
ProviderMessage::ClientConnected(m) => { ProviderMessage::ClientConnected(m) => {
let allowed = gate.serving_enabled && !gate.metered.is_metered(); // Connections are always accepted; serving and
// metered state gate individual requests, so a
// non-serving daemon can still receive pushes.
let trusted = m let trusted = m
.inner .inner
.endpoint_id .endpoint_id
@@ -124,22 +146,21 @@ pub fn provider_events(gate: Gate) -> EventSender {
connection_id, connection_id,
endpoint_id, endpoint_id,
} = m.inner; } = m.inner;
debug!(?endpoint_id, trusted, allowed, "incoming blobs connection"); debug!(?endpoint_id, trusted, "incoming blobs connection");
if allowed { conns.insert(
trusted_conns.insert(connection_id, trusted); connection_id,
} ConnInfo {
let verdict = if allowed { trusted,
Ok(()) endpoint_id: endpoint_id.map(|id| id.to_string()),
} else { },
Err(AbortReason::Permission) );
}; m.tx.send(Ok(())).await.ok();
m.tx.send(verdict).await.ok();
} }
ProviderMessage::ConnectionClosed(m) => { ProviderMessage::ConnectionClosed(m) => {
trusted_conns.remove(&m.inner.connection_id); conns.remove(&m.inner.connection_id);
} }
ProviderMessage::GetRequestReceived(m) => { ProviderMessage::GetRequestReceived(m) => {
let allowed = allow(&gate, &trusted_conns, m.inner.connection_id, [ let allowed = allow(&gate, &conns, m.inner.connection_id, [
m.inner.request.hash, m.inner.request.hash,
]); ]);
let verdict = if allowed { let verdict = if allowed {
@@ -155,7 +176,7 @@ pub fn provider_events(gate: Gate) -> EventSender {
ProviderMessage::GetManyRequestReceived(m) => { ProviderMessage::GetManyRequestReceived(m) => {
let allowed = allow( let allowed = allow(
&gate, &gate,
&trusted_conns, &conns,
m.inner.connection_id, m.inner.connection_id,
m.inner.request.hashes.iter().copied(), m.inner.request.hashes.iter().copied(),
); );
@@ -171,7 +192,15 @@ pub fn provider_events(gate: Gate) -> EventSender {
} }
ProviderMessage::ObserveRequestReceived(m) => { ProviderMessage::ObserveRequestReceived(m) => {
// Presence is information too: gate it like data. // Presence is information too: gate it like data.
let allowed = allow(&gate, &trusted_conns, m.inner.connection_id, [ // Bitfields are metadata, not payload, so trusted
// peers may observe even while serving is off or the
// connection is metered (push confirmation needs it).
let trusted = conns
.get(&m.inner.connection_id)
.map(|i| i.trusted)
.unwrap_or(false);
let allowed = trusted
|| allow(&gate, &conns, m.inner.connection_id, [
m.inner.request.hash, m.inner.request.hash,
]); ]);
let verdict = if allowed { let verdict = if allowed {
@@ -181,6 +210,34 @@ pub fn provider_events(gate: Gate) -> EventSender {
}; };
m.tx.send(verdict).await.ok(); m.tx.send(verdict).await.ok();
} }
ProviderMessage::PushRequestReceived(m) => {
// Inbound writes: trusted peers only, deferred while
// metered (RateLimited invites a retry later).
let info = conns.get(&m.inner.connection_id);
let trusted = info.map(|i| i.trusted).unwrap_or(false);
debug!(hash = %m.inner.request.hash.to_hex(), trusted, "push request received");
let verdict = if !trusted {
Err(AbortReason::Permission)
} else if gate.metered.is_metered() {
Err(AbortReason::RateLimited)
} else {
Ok(())
};
let allowed = verdict.is_ok();
m.tx.send(verdict).await.ok();
if allowed {
let received = ReceivedPush {
hash: m.inner.request.hash,
format: if m.inner.request.ranges.is_blob() {
StoredFormat::Raw
} else {
StoredFormat::HashSeq
},
from: info.and_then(|i| i.endpoint_id.clone()),
};
watch_push(m.rx, received, gate.pushes.clone());
}
}
ProviderMessage::Throttle(m) => { ProviderMessage::Throttle(m) => {
// Pay for the chunk from the shared upload bucket off // Pay for the chunk from the shared upload bucket off
// the handler loop, so a deep debt doesn't stall // the handler loop, so a deep debt doesn't stall
@@ -191,8 +248,8 @@ pub fn provider_events(gate: Gate) -> EventSender {
m.tx.send(Ok(())).await.ok(); m.tx.send(Ok(())).await.ok();
}); });
} }
// Push is disabled in the mask; notify variants are not // Notify variants are not subscribed. Ignore anything
// subscribed. Ignore anything else. // else.
_ => {} _ => {}
} }
} }
@@ -200,20 +257,51 @@ pub fn provider_events(gate: Gate) -> EventSender {
sender sender
} }
/// A request is allowed if the connection is trusted, or every requested /// A get/observe request is allowed if serving is on, the connection is
/// not metered, and either the connection is trusted or every requested
/// hash is openly served. /// hash is openly served.
fn allow( fn allow(
gate: &Gate, gate: &Gate,
trusted_conns: &HashMap<u64, bool>, conns: &HashMap<u64, ConnInfo>,
connection_id: u64, connection_id: u64,
hashes: impl IntoIterator<Item = Hash>, hashes: impl IntoIterator<Item = Hash>,
) -> bool { ) -> bool {
if trusted_conns.get(&connection_id).copied().unwrap_or(false) { if !gate.serving_enabled || gate.metered.is_metered() {
return false;
}
if conns
.get(&connection_id)
.map(|i| i.trusted)
.unwrap_or(false)
{
return true; return true;
} }
hashes.into_iter().all(|hash| (gate.open_filter)(&hash)) hashes.into_iter().all(|hash| (gate.open_filter)(&hash))
} }
/// Watch a push request's update stream; report it to the daemon only if
/// the transfer runs to completion (failed pushes stay unpinned and are
/// swept by gc).
fn watch_push(
mut rx: irpc::channel::mpsc::Receiver<iroh_blobs::provider::events::RequestUpdate>,
received: ReceivedPush,
pushes: tokio::sync::mpsc::Sender<ReceivedPush>,
) {
use iroh_blobs::provider::events::RequestUpdate;
tokio::spawn(async move {
while let Ok(Some(update)) = rx.recv().await {
match update {
RequestUpdate::Completed(_) => {
pushes.send(received).await.ok();
return;
}
RequestUpdate::Aborted(_) => return,
_ => {}
}
}
});
}
/// Forward per-request transfer updates into the daemon event stream. /// Forward per-request transfer updates into the daemon event stream.
fn forward_progress( fn forward_progress(
mut rx: irpc::channel::mpsc::Receiver<iroh_blobs::provider::events::RequestUpdate>, mut rx: irpc::channel::mpsc::Receiver<iroh_blobs::provider::events::RequestUpdate>,
+7 -3
View File
@@ -330,9 +330,13 @@ impl BlobStore {
pub async fn presence(&self, hash: Hash) -> Result<(u64, Option<u64>, bool), OpError> { pub async fn presence(&self, hash: Hash) -> Result<(u64, Option<u64>, bool), OpError> {
match self.store.blobs().status(hash).await.map_err(internal)? { match self.store.blobs().status(hash).await.map_err(internal)? {
BlobStatus::NotFound => Ok((0, None, false)), BlobStatus::NotFound => Ok((0, None, false)),
// Valid-range accounting for partials arrives with the BlobStatus::Partial { size } => {
// transfer milestone; absence of data is the safe report. // Chunk-accurate accounting from the store's bitfield:
BlobStatus::Partial { size } => Ok((0, size, false)), // report the bytes actually present and verified.
let bitfield = self.store.blobs().observe(hash).await.map_err(internal)?;
let total = bitfield.validated_size().or(size);
Ok((bitfield.total_bytes().min(total.unwrap_or(u64::MAX)), total, false))
}
BlobStatus::Complete { size } => Ok((size, Some(size), true)), BlobStatus::Complete { size } => Ok((size, Some(size), true)),
} }
} }
+118 -2
View File
@@ -13,6 +13,7 @@ use iroh::address_lookup::memory::MemoryLookup;
use iroh::protocol::Router; use iroh::protocol::Router;
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey}; use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode, SecretKey};
use iroh_blobs::api::downloader::{DownloadOptions, Downloader, Shuffled, SplitStrategy}; use iroh_blobs::api::downloader::{DownloadOptions, Downloader, Shuffled, SplitStrategy};
use iroh_blobs::protocol::GetRequest;
use iroh_blobs::{BlobFormat, BlobsProtocol, Hash, HashAndFormat}; use iroh_blobs::{BlobFormat, BlobsProtocol, Hash, HashAndFormat};
use iroh_mdns_address_lookup::{DiscoveryEvent, MdnsAddressLookup}; use iroh_mdns_address_lookup::{DiscoveryEvent, MdnsAddressLookup};
use n0_future::StreamExt; use n0_future::StreamExt;
@@ -23,7 +24,7 @@ use varde_proto::{Direction, Event};
use crate::config::Config; use crate::config::Config;
use crate::meta::Meta; use crate::meta::Meta;
use crate::metered::MeteredState; use crate::metered::MeteredState;
use crate::shaped::{Gate, ServeFilter, TokenBucket}; use crate::shaped::{Gate, ReceivedPush, ServeFilter, TokenBucket};
use crate::store::BlobStore; use crate::store::BlobStore;
/// The network side of the daemon: one iroh endpoint serving the blob /// The network side of the daemon: one iroh endpoint serving the blob
@@ -33,6 +34,7 @@ use crate::store::BlobStore;
pub struct Transfer { pub struct Transfer {
endpoint: Endpoint, endpoint: Endpoint,
router: Router, router: Router,
store: iroh_blobs::api::Store,
downloader: Downloader, downloader: Downloader,
/// Address book for provider addresses learned out-of-band (ticket /// Address book for provider addresses learned out-of-band (ticket
/// imports): the downloader dials by endpoint id and resolves /// imports): the downloader dials by endpoint id and resolves
@@ -41,6 +43,7 @@ pub struct Transfer {
events: broadcast::Sender<Event>, events: broadcast::Sender<Event>,
metered: MeteredState, metered: MeteredState,
discovery_rx: std::sync::Mutex<Option<mpsc::Receiver<EndpointId>>>, discovery_rx: std::sync::Mutex<Option<mpsc::Receiver<EndpointId>>>,
pushes_rx: std::sync::Mutex<Option<mpsc::Receiver<ReceivedPush>>>,
} }
impl Transfer { impl Transfer {
@@ -108,6 +111,7 @@ impl Transfer {
// Best-effort low-priority marking of our UDP traffic. // Best-effort low-priority marking of our UDP traffic.
crate::dscp::mark_endpoint_sockets(&endpoint.bound_sockets()); crate::dscp::mark_endpoint_sockets(&endpoint.bound_sockets());
let (pushes_tx, pushes_rx) = mpsc::channel(16);
let provider_events = crate::shaped::provider_events(Gate { let provider_events = crate::shaped::provider_events(Gate {
meta, meta,
open_filter, open_filter,
@@ -115,6 +119,7 @@ impl Transfer {
serving_enabled: config.max_upload_bytes_per_sec > 0, serving_enabled: config.max_upload_bytes_per_sec > 0,
metered: metered.clone(), metered: metered.clone(),
events: events.clone(), events: events.clone(),
pushes: pushes_tx,
}); });
let provider = BlobsProtocol::new(store.api(), Some(provider_events)); let provider = BlobsProtocol::new(store.api(), Some(provider_events));
@@ -126,11 +131,13 @@ impl Transfer {
Ok(Transfer { Ok(Transfer {
endpoint, endpoint,
router, router,
store: store.api().clone(),
downloader, downloader,
known_addrs, known_addrs,
events, events,
metered, metered,
discovery_rx: std::sync::Mutex::new(Some(discovery_rx)), 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() 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`. /// Produce a ticket for out-of-band sharing of `content`.
pub async fn export_ticket(&self, content: HashAndFormat) -> Result<String> { pub async fn export_ticket(&self, content: HashAndFormat) -> Result<String> {
let addr = self.endpoint.addr(); 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 downloader = self.downloader.clone();
let events = self.events.clone(); let events = self.events.clone();
tokio::spawn(async move { tokio::spawn(async move {
let progress = downloader.download_with_opts(DownloadOptions::new( let progress = downloader.download_with_opts(DownloadOptions::new(
content, content,
Shuffled::new(ids), Shuffled::new(ids),
SplitStrategy::None, strategy,
)); ));
let mut stream = match progress.stream().await { let mut stream = match progress.stream().await {
Ok(stream) => stream, Ok(stream) => stream,
+134
View File
@@ -280,3 +280,137 @@ fn overlapping_pins_auto_sync_via_lan_discovery() {
assert!(reply.ok, "materialize failed: {:?}", reply.error); assert!(reply.ok, "materialize failed: {:?}", reply.error);
assert_eq!(std::fs::read(&dest).unwrap(), payload); assert_eq!(std::fs::read(&dest).unwrap(), payload);
} }
/// Wait until `client` sees at least one connected trusted peer; false
/// if the deadline passes (multicast unavailable).
fn wait_peer_connected(client: &mut support::Client, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
let reply = client.request(&Request::PeerList {});
if let Some(ResponseData::Peers { peers }) = reply.data {
if peers.iter().any(|p| p.connected) {
return true;
}
}
std::thread::sleep(Duration::from_millis(250));
}
false
}
/// Push: A hands pinned content to B unprompted. Requires mutual trust
/// and mdns (skips without multicast). The receiver pins what it
/// accepted, so the content survives gc and lists as a pin.
#[test]
fn push_hands_content_to_trusted_peer() {
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
let a = support::spawn_daemon_with_env(dir_a.path(), &[("VARDE_DISCOVERY", "true")]);
let b = support::spawn_daemon_with_env(dir_b.path(), &[("VARDE_DISCOVERY", "true")]);
let mut client_a = support::Client::connect(&a.socket);
let mut client_b = support::Client::connect(&b.socket);
let id_a = node_id(&mut client_a);
let id_b = node_id(&mut client_b);
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("gift.bin");
let payload = vec![3u8; 300_000];
std::fs::write(&src, &payload).unwrap();
let hash = add_file(&mut client_a, &src);
pin(&mut client_a, &hash, false);
// Sender-side consent: pushing to a peer we don't trust is refused
// locally, before any connection is made.
let reply = client_a.request(&Request::Push {
hash: hash.clone(),
node_id: id_b.clone(),
});
assert!(!reply.ok, "push to untrusted peer must be refused");
// Mutual trust, established before any blob connection exists.
assert!(client_a.request(&Request::PeerTrust { node_id: id_b }).ok);
assert!(client_b.request(&Request::PeerTrust { node_id: id_a }).ok);
if !wait_peer_connected(&mut client_a, Duration::from_secs(30)) {
eprintln!("SKIP: mdns discovery saw no peers (multicast unavailable in this environment)");
return;
}
let reply = client_a.request(&Request::Push {
hash: hash.clone(),
node_id: node_id(&mut client_b),
});
assert!(reply.ok, "push failed: {:?}", reply.error);
match reply.data {
Some(ResponseData::Pushed { bytes, .. }) => {
assert_eq!(bytes, payload.len() as u64, "push moved the payload")
}
other => panic!("unexpected push reply: {other:?}"),
}
// The receiver has the bytes, and pinned them.
if !wait_complete(&b.socket, &hash, Duration::from_secs(30)) {
let reply = client_b.request(&Request::Status {
hash: Some(hash.clone()),
});
panic!("pushed content not complete on receiver; status: {reply:?}");
}
let deadline = Instant::now() + Duration::from_secs(10);
loop {
let reply = client_b.request(&Request::List {});
if let Some(ResponseData::Pins { pins }) = &reply.data {
if pins.iter().any(|p| p.hash == hash && p.complete) {
break;
}
}
assert!(
Instant::now() < deadline,
"receiver did not pin pushed content"
);
std::thread::sleep(Duration::from_millis(250));
}
let dest = dir.path().join("gift-out.bin");
let reply = client_b.request(&Request::Materialize {
hash,
dest: dest.display().to_string(),
mode: MaterializeMode::Copy,
});
assert!(reply.ok, "materialize failed: {:?}", reply.error);
assert_eq!(std::fs::read(&dest).unwrap(), payload);
}
/// Consent is mutual: a receiver that does not trust the sender rejects
/// the push, even though the sender trusts the receiver.
#[test]
fn push_rejected_without_receiver_trust() {
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
let a = support::spawn_daemon_with_env(dir_a.path(), &[("VARDE_DISCOVERY", "true")]);
let b = support::spawn_daemon_with_env(dir_b.path(), &[("VARDE_DISCOVERY", "true")]);
let mut client_a = support::Client::connect(&a.socket);
let mut client_b = support::Client::connect(&b.socket);
let id_b = node_id(&mut client_b);
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("unwanted.bin");
std::fs::write(&src, vec![5u8; 100_000]).unwrap();
let hash = add_file(&mut client_a, &src);
pin(&mut client_a, &hash, false);
// One-way trust only: A trusts B, B has never heard of A.
assert!(client_a.request(&Request::PeerTrust { node_id: id_b }).ok);
if !wait_peer_connected(&mut client_a, Duration::from_secs(30)) {
eprintln!("SKIP: mdns discovery saw no peers (multicast unavailable in this environment)");
return;
}
let reply = client_a.request(&Request::Push {
hash: hash.clone(),
node_id: node_id(&mut client_b),
});
assert!(!reply.ok, "receiver without trust must reject the push");
// And the receiver holds none of it.
assert!(!is_complete(&b.socket, &hash));
}
+28 -1
View File
@@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize};
/// Version of the socket protocol described by this crate. /// Version of the socket protocol described by this crate.
/// ///
/// Bumped on incompatible changes; reported in [`GlobalStatus`]. /// Bumped on incompatible changes; reported in [`GlobalStatus`].
pub const PROTOCOL_VERSION: u32 = 1; pub const PROTOCOL_VERSION: u32 = 2;
/// A request from a client to the daemon. One JSON object per line. /// A request from a client to the daemon. One JSON object per line.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -96,6 +96,18 @@ pub enum Request {
}, },
/// List trusted peers and their current connectivity. /// List trusted peers and their current connectivity.
PeerList {}, PeerList {},
/// Push fully-present content to a trusted peer, unprompted.
///
/// Consent is mutual: this daemon only pushes to peers it trusts, and
/// the receiver only accepts pushes from peers *it* trusts. On the
/// receiving side an accepted push is recorded as a pin (default
/// policy), so the content survives gc and shows up in `List`.
Push {
/// Hash (hex) of the fully-present blob or HashSeq root to push.
hash: String,
/// The receiving peer's iroh NodeId (z-base-32).
node_id: String,
},
/// Drop all blobs not reachable from a pin. Explicit, never automatic. /// Drop all blobs not reachable from a pin. Explicit, never automatic.
Gc {}, Gc {},
/// Switch this connection to an event stream. The daemon acknowledges /// Switch this connection to an event stream. The daemon acknowledges
@@ -240,6 +252,13 @@ pub enum ResponseData {
/// Number of blobs removed. /// Number of blobs removed.
blobs_removed: u64, blobs_removed: u64,
}, },
/// Content was pushed to a peer. Reply to [`Request::Push`].
Pushed {
/// The pushed root hash (hex).
hash: String,
/// Payload bytes written to the peer.
bytes: u64,
},
} }
/// Global daemon status. /// Global daemon status.
@@ -334,6 +353,14 @@ pub enum Event {
/// The pinned root hash (hex). /// The pinned root hash (hex).
hash: String, hash: String,
}, },
/// A trusted peer pushed content to this daemon; it has been pinned
/// with the default policy.
PushReceived {
/// The pushed root hash (hex).
hash: String,
/// NodeId (z-base-32) of the pushing peer.
from: String,
},
/// A signed announcement was received on a subscribed discovery topic. /// A signed announcement was received on a subscribed discovery topic.
Announcement { Announcement {
/// The announced HashSeq root (hex). /// The announced HashSeq root (hex).