2026-07-14 21:18:25 +02:00
|
|
|
//! iroh endpoint, blob provider and downloader.
|
|
|
|
|
//!
|
2026-07-14 22:27:58 +02:00
|
|
|
//! 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.
|
2026-07-14 21:18:25 +02:00
|
|
|
|
|
|
|
|
use std::path::Path;
|
2026-07-14 22:27:58 +02:00
|
|
|
use std::sync::Arc;
|
2026-07-14 21:18:25 +02:00
|
|
|
|
|
|
|
|
use anyhow::{Context, Result};
|
2026-07-14 22:27:58 +02:00
|
|
|
use iroh::discovery::mdns::MdnsDiscovery;
|
|
|
|
|
use iroh::endpoint::Connection;
|
|
|
|
|
use iroh::protocol::{ProtocolHandler, Router};
|
|
|
|
|
use iroh::{Endpoint, NodeAddr, NodeId, RelayMode, SecretKey};
|
2026-07-14 21:18:25 +02:00
|
|
|
use iroh_blobs::downloader::{DownloadRequest, Downloader};
|
2026-07-14 22:27:58 +02:00
|
|
|
use iroh_blobs::get::db::DownloadProgress;
|
|
|
|
|
use iroh_blobs::provider::{handle_connection, CustomEventSender, EventSender};
|
2026-07-14 21:18:25 +02:00
|
|
|
use iroh_blobs::util::local_pool::LocalPoolHandle;
|
2026-07-14 22:27:58 +02:00
|
|
|
use iroh_blobs::util::progress::AsyncChannelProgressSender;
|
2026-07-14 21:18:25 +02:00
|
|
|
use iroh_blobs::{BlobFormat, Hash, HashAndFormat};
|
2026-07-14 22:27:58 +02:00
|
|
|
use n0_future::boxed::BoxFuture;
|
|
|
|
|
use n0_future::StreamExt;
|
|
|
|
|
use tokio::sync::{broadcast, mpsc};
|
2026-07-14 21:18:25 +02:00
|
|
|
use tracing::{debug, info, warn};
|
2026-07-14 22:27:58 +02:00
|
|
|
use varde_proto::{Direction, Event};
|
2026-07-14 21:18:25 +02:00
|
|
|
|
|
|
|
|
use crate::config::Config;
|
2026-07-14 22:27:58 +02:00
|
|
|
use crate::meta::Meta;
|
|
|
|
|
use crate::shaped::{ServeFilter, ShapedStore, TokenBucket};
|
2026-07-14 21:18:25 +02:00
|
|
|
use crate::store::BlobStore;
|
|
|
|
|
|
|
|
|
|
/// The network side of the daemon: one iroh endpoint serving the blob
|
2026-07-14 22:27:58 +02:00
|
|
|
/// store (trust-gated, rate-limited), plus a downloader for fetching
|
|
|
|
|
/// pinned content from providers.
|
2026-07-14 21:18:25 +02:00
|
|
|
#[derive(Debug)]
|
|
|
|
|
pub struct Transfer {
|
|
|
|
|
endpoint: Endpoint,
|
|
|
|
|
router: Router,
|
|
|
|
|
downloader: Downloader,
|
|
|
|
|
events: broadcast::Sender<Event>,
|
2026-07-14 22:27:58 +02:00
|
|
|
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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-14 21:18:25 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Transfer {
|
|
|
|
|
/// Bind the endpoint and start serving the store.
|
|
|
|
|
///
|
|
|
|
|
/// With `wan_upload` off (the default) the relay is disabled: the
|
|
|
|
|
/// endpoint is only reachable via direct (LAN/localhost) paths and
|
|
|
|
|
/// never uploads through third-party infrastructure.
|
|
|
|
|
pub async fn start(
|
|
|
|
|
config: &Config,
|
|
|
|
|
store: &BlobStore,
|
2026-07-14 22:27:58 +02:00
|
|
|
meta: Arc<Meta>,
|
|
|
|
|
open_filter: ServeFilter,
|
2026-07-14 21:18:25 +02:00
|
|
|
pool: LocalPoolHandle,
|
|
|
|
|
events: broadcast::Sender<Event>,
|
|
|
|
|
) -> Result<Transfer> {
|
|
|
|
|
let secret = load_or_create_secret(&config.store_dir.join("secret.key"))?;
|
2026-07-14 22:27:58 +02:00
|
|
|
let mut builder = Endpoint::builder().secret_key(secret.clone());
|
2026-07-14 21:18:25 +02:00
|
|
|
if !config.wan_upload {
|
|
|
|
|
builder = builder.relay_mode(RelayMode::Disabled);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 22:27:58 +02:00
|
|
|
// 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);
|
2026-07-14 21:18:25 +02:00
|
|
|
let router = Router::builder(endpoint.clone())
|
2026-07-14 22:27:58 +02:00
|
|
|
.accept(iroh_blobs::ALPN, provider)
|
2026-07-14 21:18:25 +02:00
|
|
|
.spawn();
|
|
|
|
|
|
|
|
|
|
Ok(Transfer {
|
|
|
|
|
endpoint,
|
|
|
|
|
router,
|
|
|
|
|
downloader,
|
|
|
|
|
events,
|
2026-07-14 22:27:58 +02:00
|
|
|
discovery_rx: std::sync::Mutex::new(Some(discovery_rx)),
|
2026-07-14 21:18:25 +02:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Our stable node id.
|
|
|
|
|
pub fn node_id(&self) -> String {
|
|
|
|
|
self.endpoint.node_id().to_string()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 22:27:58 +02:00
|
|
|
/// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 21:18:25 +02:00
|
|
|
/// Produce a ticket for out-of-band sharing of `content`.
|
|
|
|
|
pub async fn export_ticket(&self, content: HashAndFormat) -> Result<String> {
|
|
|
|
|
let addr = self
|
|
|
|
|
.endpoint
|
|
|
|
|
.node_addr()
|
|
|
|
|
.await
|
|
|
|
|
.context("waiting for endpoint address")?;
|
2026-07-14 22:27:58 +02:00
|
|
|
let ticket = iroh_blobs::ticket::BlobTicket::new(addr, content.hash, content.format)?;
|
2026-07-14 21:18:25 +02:00
|
|
|
Ok(ticket.to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Queue a background fetch of `content` from `providers`. Emits
|
2026-07-14 22:27:58 +02:00
|
|
|
/// progress events and [`Event::PinComplete`] when fully verified.
|
2026-07-14 21:18:25 +02:00
|
|
|
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
|
2026-07-14 22:27:58 +02:00
|
|
|
// any direct addresses we know (ticket-embedded ones), or the
|
|
|
|
|
// dial fails with "no addressing information". Id-only
|
|
|
|
|
// provider entries resolve via discovery instead.
|
2026-07-14 21:18:25 +02:00
|
|
|
for provider in &providers {
|
2026-07-14 22:27:58 +02:00
|
|
|
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");
|
|
|
|
|
}
|
2026-07-14 21:18:25 +02:00
|
|
|
}
|
|
|
|
|
}
|
2026-07-14 22:27:58 +02:00
|
|
|
|
|
|
|
|
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));
|
2026-07-14 21:18:25 +02:00
|
|
|
let handle = downloader.queue(request).await;
|
|
|
|
|
match handle.await {
|
|
|
|
|
Ok(_stats) => {
|
|
|
|
|
info!(hash = %content.hash.to_hex(), "fetch complete");
|
|
|
|
|
let _ = events.send(Event::PinComplete {
|
|
|
|
|
hash: content.hash.to_hex().to_string(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
Err(e) => {
|
|
|
|
|
warn!(hash = %content.hash.to_hex(), error = %e, "fetch failed");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Gracefully close the endpoint.
|
|
|
|
|
pub async fn shutdown(&self) {
|
|
|
|
|
if let Err(e) = self.router.shutdown().await {
|
|
|
|
|
warn!(error = %e, "router shutdown");
|
|
|
|
|
}
|
|
|
|
|
self.endpoint.close().await;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 22:27:58 +02:00
|
|
|
/// 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,
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 21:18:25 +02:00
|
|
|
/// Parse a ticket string into (root, format, provider address).
|
|
|
|
|
pub fn parse_ticket(ticket: &str) -> Result<(Hash, BlobFormat, NodeAddr), String> {
|
2026-07-14 22:27:58 +02:00
|
|
|
let ticket: iroh_blobs::ticket::BlobTicket =
|
|
|
|
|
ticket.parse().map_err(|e| format!("invalid ticket: {e}"))?;
|
2026-07-14 21:18:25 +02:00
|
|
|
Ok((ticket.hash(), ticket.format(), ticket.node_addr().clone()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Load the endpoint secret key from `path`, creating it (mode 0600) on
|
|
|
|
|
/// first start.
|
|
|
|
|
fn load_or_create_secret(path: &Path) -> Result<SecretKey> {
|
|
|
|
|
match std::fs::read_to_string(path) {
|
|
|
|
|
Ok(hex) => parse_secret(hex.trim())
|
|
|
|
|
.with_context(|| format!("parsing secret key {}", path.display())),
|
|
|
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
|
|
|
let secret = SecretKey::generate(rand::rngs::OsRng);
|
|
|
|
|
let hex = hex_encode(&secret.to_bytes());
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
|
|
|
let mut file = std::fs::OpenOptions::new()
|
|
|
|
|
.write(true)
|
|
|
|
|
.create_new(true)
|
|
|
|
|
.mode(0o600)
|
|
|
|
|
.open(path)
|
|
|
|
|
.with_context(|| format!("creating secret key {}", path.display()))?;
|
|
|
|
|
file.write_all(hex.as_bytes())?;
|
|
|
|
|
Ok(secret)
|
|
|
|
|
}
|
|
|
|
|
Err(e) => Err(e).with_context(|| format!("reading secret key {}", path.display())),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn parse_secret(hex: &str) -> Result<SecretKey> {
|
|
|
|
|
anyhow::ensure!(hex.len() == 64, "expected 64 hex chars, got {}", hex.len());
|
|
|
|
|
let mut bytes = [0u8; 32];
|
|
|
|
|
for (i, byte) in bytes.iter_mut().enumerate() {
|
|
|
|
|
*byte = u8::from_str_radix(&hex[2 * i..2 * i + 2], 16)
|
|
|
|
|
.map_err(|e| anyhow::anyhow!("bad hex at {}: {e}", 2 * i))?;
|
|
|
|
|
}
|
|
|
|
|
Ok(SecretKey::from_bytes(&bytes))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn hex_encode(bytes: &[u8]) -> String {
|
|
|
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn secret_key_round_trips_through_file() {
|
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
|
|
|
let path = dir.path().join("secret.key");
|
|
|
|
|
let first = load_or_create_secret(&path).unwrap();
|
|
|
|
|
let second = load_or_create_secret(&path).unwrap();
|
|
|
|
|
assert_eq!(first.to_bytes(), second.to_bytes());
|
|
|
|
|
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
|
|
|
|
|
assert_eq!(mode & 0o777, 0o600, "secret key must be 0600");
|
|
|
|
|
}
|
|
|
|
|
}
|