Milestone 3: iroh endpoint, tickets, pin-triggered fetch

The daemon binds an iroh endpoint (ed25519 identity at secret.key,
0600), serves its store via Blobs/Router on the standard ALPN, and
fetches with the iroh-blobs Downloader rather than the rpc client to
keep quic-rpc out of the tree. Relay is disabled unless wan_upload is
set: LAN-only, zero WAN upload by default. TicketImport pins first
(the pin is the GC root protecting in-flight data), registers the
ticket's NodeAddr with the endpoint (the downloader dials by NodeId
alone), then fetches in the background; completion emits pin_complete.

Tests: two-daemon localhost transfers (blob + directory collection,
byte-identical), and kill -9 mid-transfer followed by restart on the
same store resuming to completion.

Dependencies: iroh 0.35 (endpoint/router, pairs with iroh-blobs 0.35),
rand 0.8 (secret key generation, same version iroh uses).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 21:18:25 +02:00
parent e256f73439
commit 61171a5ea8
10 changed files with 532 additions and 9 deletions
+56 -7
View File
@@ -13,6 +13,7 @@ use varde_proto::{
use crate::config::Config;
use crate::meta::{Meta, StoredFormat};
use crate::store::{BlobStore, OpError};
use crate::transfer::Transfer;
/// Shared daemon state. Handlers grow with the milestones; anything not
/// yet wired returns [`ErrorCode::Unimplemented`] rather than pretending.
@@ -20,11 +21,13 @@ pub struct Daemon {
config: Config,
store: BlobStore,
meta: Meta,
transfer: Transfer,
events: broadcast::Sender<Event>,
}
impl Daemon {
/// Open the blob store and metadata under the configured store dir.
/// Open the blob store and metadata under the configured store dir,
/// and bring up the iroh endpoint.
pub async fn open(config: Config) -> anyhow::Result<Daemon> {
std::fs::create_dir_all(&config.store_dir)
.with_context(|| format!("creating store directory {}", config.store_dir.display()))?;
@@ -33,14 +36,23 @@ impl Daemon {
// Capacity bounds memory if a subscriber stalls; laggards get a
// Lagged error, not unbounded buffering.
let (events, _) = broadcast::channel(1024);
let transfer =
Transfer::start(&config, &store, store.pool_handle(), events.clone()).await?;
Ok(Daemon {
config,
store,
meta,
transfer,
events,
})
}
/// Flush the store and close the endpoint.
pub async fn shutdown(&self) {
self.transfer.shutdown().await;
self.store.shutdown().await;
}
pub fn config(&self) -> &Config {
&self.config
}
@@ -106,7 +118,7 @@ impl Daemon {
Request::Status { hash: None } => Ok(ResponseData::Status(GlobalStatus {
version: env!("CARGO_PKG_VERSION").to_string(),
protocol: varde_proto::PROTOCOL_VERSION,
node_id: None,
node_id: Some(self.transfer.node_id()),
store_path: self.config.store_dir.display().to_string(),
pins: self.meta.pins().len() as u64,
connected_peers: 0,
@@ -143,6 +155,40 @@ impl Daemon {
let blobs_removed = self.store.gc(roots).await?;
Ok(ResponseData::GcDone { blobs_removed })
}
Request::TicketExport { hash } => {
let parsed = parse_hash(&hash)?;
let (_have, _total, complete) = self.store.presence(parsed).await?;
if !complete {
return Err(OpError::NotFound(format!(
"{} is not fully present; cannot export a ticket",
parsed.to_hex()
)));
}
let content = HashAndFormat {
hash: parsed,
format: blob_format(self.meta.format_of(&parsed.to_hex())),
};
let ticket = self.transfer.export_ticket(content).await?;
Ok(ResponseData::Ticket { ticket })
}
Request::TicketImport { ticket, pin_policy } => {
let (hash, format, provider) =
crate::transfer::parse_ticket(&ticket).map_err(OpError::InvalidArgument)?;
let stored = match format {
BlobFormat::Raw => StoredFormat::Raw,
BlobFormat::HashSeq => StoredFormat::HashSeq,
};
self.meta.record_format(&hash.to_hex(), stored)?;
// Pin first: the pin is the GC root that keeps the fetch's
// data alive, and it is the standing intent even if the
// provider is unreachable right now.
self.meta.pin(&hash.to_hex(), pin_policy)?;
self.transfer
.spawn_fetch(HashAndFormat { hash, format }, vec![provider]);
Ok(ResponseData::Pinned {
hash: hash.to_hex().to_string(),
})
}
Request::Subscribe {} => Ok(ResponseData::Done {}),
other => Err(OpError::Unimplemented(format!(
"not implemented yet: {other:?}"
@@ -155,19 +201,22 @@ impl Daemon {
let mut roots = Vec::new();
for (hash, _record) in self.meta.pins() {
let parsed = parse_hash(&hash)?;
let format = match self.meta.format_of(&hash) {
StoredFormat::Raw => BlobFormat::Raw,
StoredFormat::HashSeq => BlobFormat::HashSeq,
};
roots.push(HashAndFormat {
hash: parsed,
format,
format: blob_format(self.meta.format_of(&hash)),
});
}
Ok(roots)
}
}
fn blob_format(format: StoredFormat) -> BlobFormat {
match format {
StoredFormat::Raw => BlobFormat::Raw,
StoredFormat::HashSeq => BlobFormat::HashSeq,
}
}
fn parse_hash(s: &str) -> Result<Hash, OpError> {
Hash::from_str(s).map_err(|e| OpError::InvalidArgument(format!("invalid hash {s:?}: {e}")))
}
+1
View File
@@ -8,6 +8,7 @@ pub mod daemon;
pub mod meta;
pub mod server;
pub mod store;
pub mod transfer;
use std::sync::Arc;
+2 -2
View File
@@ -90,8 +90,8 @@ async fn run(config: Config) -> Result<()> {
r = shutdown_signal() => r.map(|signal| info!(signal, "shutting down")),
};
// Flush the store and remove the socket so the next start is clean.
daemon.store().shutdown().await;
// Close the endpoint, flush the store, remove the socket.
daemon.shutdown().await;
let _ = std::fs::remove_file(&socket_path);
result
}
+5
View File
@@ -87,6 +87,11 @@ impl BlobStore {
&self.store
}
/// The local pool the transfer layer shares for non-`Send` blob work.
pub fn pool_handle(&self) -> LocalPoolHandle {
self.pool.clone()
}
/// Import a file or directory. Returns the root hash, total imported
/// bytes, and the resulting format (`Raw` for files, `HashSeq` for
/// directories).
+188
View File
@@ -0,0 +1,188 @@
//! iroh endpoint, blob provider and downloader.
//!
//! This module is also the seam where transport-level traffic shaping
//! lives: the rate limiters (milestone 4) and, later, scavenger
//! congestion control would slot in here without touching the daemon
//! logic above.
use std::path::Path;
use anyhow::{Context, Result};
use iroh::protocol::Router;
use iroh::{Endpoint, NodeAddr, RelayMode, SecretKey};
use iroh_blobs::downloader::{DownloadRequest, Downloader};
use iroh_blobs::net_protocol::Blobs;
use iroh_blobs::ticket::BlobTicket;
use iroh_blobs::util::local_pool::LocalPoolHandle;
use iroh_blobs::{BlobFormat, Hash, HashAndFormat};
use tokio::sync::broadcast;
use tracing::{debug, info, warn};
use varde_proto::Event;
use crate::config::Config;
use crate::store::BlobStore;
/// The network side of the daemon: one iroh endpoint serving the blob
/// store, plus a downloader for fetching pinned content from providers.
#[derive(Debug)]
pub struct Transfer {
endpoint: Endpoint,
router: Router,
downloader: Downloader,
events: broadcast::Sender<Event>,
}
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,
pool: LocalPoolHandle,
events: broadcast::Sender<Event>,
) -> Result<Transfer> {
let secret = load_or_create_secret(&config.store_dir.join("secret.key"))?;
let mut builder = Endpoint::builder().secret_key(secret);
if !config.wan_upload {
builder = builder.relay_mode(RelayMode::Disabled);
}
let endpoint = builder.bind().await.context("binding iroh endpoint")?;
info!(node_id = %endpoint.node_id(), "endpoint up");
let blobs = Blobs::builder(store.inner().clone())
.local_pool(pool)
.build(&endpoint);
let downloader = blobs.downloader().clone();
let router = Router::builder(endpoint.clone())
.accept(iroh_blobs::ALPN, blobs)
.spawn();
Ok(Transfer {
endpoint,
router,
downloader,
events,
})
}
/// Our stable node id.
pub fn node_id(&self) -> String {
self.endpoint.node_id().to_string()
}
/// 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")?;
let ticket = BlobTicket::new(addr, content.hash, content.format)?;
Ok(ticket.to_string())
}
/// Queue a background fetch of `content` from `providers`. Emits
/// [`Event::PinComplete`] when the content is fully verified locally.
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
// the provider's direct addresses first or the dial fails
// with "no addressing information".
for provider in &providers {
debug!(node = %provider.node_id, addrs = ?provider.direct_addresses, "provider");
if let Err(e) = endpoint.add_node_addr(provider.clone()) {
warn!(node = %provider.node_id, error = %e, "adding provider address");
}
}
let request = DownloadRequest::new(content, providers);
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;
}
}
/// Parse a ticket string into (root, format, provider address).
pub fn parse_ticket(ticket: &str) -> Result<(Hash, BlobFormat, NodeAddr), String> {
let 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<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");
}
}