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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+113
-25
@@ -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,9 +192,17 @@ 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
|
||||||
m.inner.request.hash,
|
// 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,
|
||||||
|
]);
|
||||||
let verdict = if allowed {
|
let verdict = if allowed {
|
||||||
Ok(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user