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