Milestone 4: LAN discovery, trust gate, auto-sync, rate limits, events
mDNS-style LAN discovery (iroh MdnsDiscovery, discovery flag, default on) feeds a presence tracker; trusted peers that appear trigger fetches of every incomplete pin, and Pin itself now fetches from present trusted peers. Serving is our own ProtocolHandler: trusted NodeIds get the full store, everyone else a filtered view limited to open_lan pins and ticket-exported hashes (plus hashseq children) that answers "not found" for the rest. Ticket export records standing serve-consent for that hash; trust changes take effect on new connections. ShapedStore implements the full iroh-blobs Store trait to charge provider reads to an upload token bucket and downloader writes to a download bucket; upload cap 0 closes incoming connections at accept. Subscribe now streams transfer_progress both ways, peer_joined, and pin_complete. Tests: forged-ticket trust gating (denied untrusted, served after trust), 256 KiB/s upload cap enforced by wall clock, event stream during a transfer, and real-mdns auto-sync between two daemons (skips where multicast is unavailable). Dependencies: n0-future, async-channel, futures-lite, bytes — all already in the tree via iroh; needed directly to name types in iroh-blobs trait signatures and channels. iroh feature discovery-local-network for MdnsDiscovery. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,287 @@
|
||||
//! Milestone 4: trust gating, rate limiting, event streaming, and
|
||||
//! mdns-based auto-sync between trusted daemons.
|
||||
|
||||
mod support;
|
||||
|
||||
use std::path::Path;
|
||||
use std::str::FromStr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use varde_proto::{MaterializeMode, PinPolicy, Request, ResponseData};
|
||||
|
||||
fn add_file(client: &mut support::Client, path: &Path) -> String {
|
||||
let reply = client.request(&Request::Add {
|
||||
path: path.display().to_string(),
|
||||
recursive: false,
|
||||
});
|
||||
assert!(reply.ok, "add failed: {:?}", reply.error);
|
||||
match reply.data {
|
||||
Some(ResponseData::Added { hash, .. }) => hash,
|
||||
other => panic!("unexpected add reply: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn pin(client: &mut support::Client, hash: &str, open_lan: bool) {
|
||||
let reply = client.request(&Request::Pin {
|
||||
hash: hash.to_string(),
|
||||
policy: PinPolicy { open_lan },
|
||||
});
|
||||
assert!(reply.ok, "pin failed: {:?}", reply.error);
|
||||
}
|
||||
|
||||
fn export_ticket(client: &mut support::Client, hash: &str) -> String {
|
||||
let reply = client.request(&Request::TicketExport {
|
||||
hash: hash.to_string(),
|
||||
});
|
||||
assert!(reply.ok, "ticket export failed: {:?}", reply.error);
|
||||
match reply.data {
|
||||
Some(ResponseData::Ticket { ticket }) => ticket,
|
||||
other => panic!("unexpected ticket reply: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn import_ticket(client: &mut support::Client, ticket: &str) {
|
||||
let reply = client.request(&Request::TicketImport {
|
||||
ticket: ticket.to_string(),
|
||||
pin_policy: PinPolicy::default(),
|
||||
});
|
||||
assert!(reply.ok, "ticket import failed: {:?}", reply.error);
|
||||
}
|
||||
|
||||
fn node_id(client: &mut support::Client) -> String {
|
||||
let reply = client.request(&Request::Status { hash: None });
|
||||
match reply.data {
|
||||
Some(ResponseData::Status(s)) => s.node_id.expect("daemon has a node id"),
|
||||
other => panic!("unexpected status reply: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_complete(socket: &Path, hash: &str) -> bool {
|
||||
let mut client = support::Client::connect(socket);
|
||||
let reply = client.request(&Request::Status {
|
||||
hash: Some(hash.to_string()),
|
||||
});
|
||||
matches!(
|
||||
reply.data,
|
||||
Some(ResponseData::HashStatus(s)) if s.complete
|
||||
)
|
||||
}
|
||||
|
||||
fn wait_complete(socket: &Path, hash: &str, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
while Instant::now() < deadline {
|
||||
if is_complete(socket, hash) {
|
||||
return true;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Re-target a ticket at another hash on the same provider: what an
|
||||
/// attacker who learned a hash out of band would hand to their daemon.
|
||||
fn forge_ticket(real_ticket: &str, target_hash: &str) -> String {
|
||||
let ticket = iroh_blobs::ticket::BlobTicket::from_str(real_ticket).unwrap();
|
||||
let hash = iroh_blobs::Hash::from_str(target_hash).unwrap();
|
||||
iroh_blobs::ticket::BlobTicket::new(
|
||||
ticket.node_addr().clone(),
|
||||
hash,
|
||||
iroh_blobs::BlobFormat::Raw,
|
||||
)
|
||||
.unwrap()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_gates_serving_per_hash() {
|
||||
let provider = support::spawn_daemon();
|
||||
let fetcher_dir = tempfile::tempdir().unwrap();
|
||||
let fetcher = support::spawn_daemon_at(fetcher_dir.path());
|
||||
let mut provider_client = support::Client::connect(&provider.socket);
|
||||
let mut fetcher_client = support::Client::connect(&fetcher.socket);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
// `shared` is deliberately published via ticket; `private` is pinned
|
||||
// but never shared.
|
||||
std::fs::write(dir.path().join("shared.bin"), vec![1u8; 300_000]).unwrap();
|
||||
std::fs::write(dir.path().join("private.bin"), vec![2u8; 300_000]).unwrap();
|
||||
let shared = add_file(&mut provider_client, &dir.path().join("shared.bin"));
|
||||
let private = add_file(&mut provider_client, &dir.path().join("private.bin"));
|
||||
pin(&mut provider_client, &shared, false);
|
||||
pin(&mut provider_client, &private, false);
|
||||
|
||||
let ticket = export_ticket(&mut provider_client, &shared);
|
||||
|
||||
// The exported content transfers to the untrusted fetcher.
|
||||
import_ticket(&mut fetcher_client, &ticket);
|
||||
assert!(
|
||||
wait_complete(&fetcher.socket, &shared, Duration::from_secs(60)),
|
||||
"exported content must be fetchable without trust"
|
||||
);
|
||||
|
||||
// A forged ticket for the never-shared hash must not: the provider
|
||||
// answers "not found" to untrusted peers.
|
||||
let forged = forge_ticket(&ticket, &private);
|
||||
import_ticket(&mut fetcher_client, &forged);
|
||||
std::thread::sleep(Duration::from_secs(5));
|
||||
assert!(
|
||||
!is_complete(&fetcher.socket, &private),
|
||||
"unshared content leaked to an untrusted peer"
|
||||
);
|
||||
|
||||
// Once the provider trusts the fetcher, the same request succeeds.
|
||||
// Trust is evaluated when a connection is accepted, so restart the
|
||||
// fetcher (same store, same identity) to shed the connection that
|
||||
// was established while untrusted.
|
||||
let fetcher_id = node_id(&mut fetcher_client);
|
||||
let reply = provider_client.request(&Request::PeerTrust {
|
||||
node_id: fetcher_id,
|
||||
});
|
||||
assert!(reply.ok, "peer trust failed: {:?}", reply.error);
|
||||
drop(fetcher);
|
||||
let fetcher = support::spawn_daemon_at(fetcher_dir.path());
|
||||
let mut fetcher_client = support::Client::connect(&fetcher.socket);
|
||||
import_ticket(&mut fetcher_client, &forged);
|
||||
assert!(
|
||||
wait_complete(&fetcher.socket, &private, Duration::from_secs(60)),
|
||||
"trusted peer should be served everything"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_rate_limit_is_enforced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 256 KiB/s upload cap on the provider.
|
||||
let provider = support::spawn_daemon_with_env(dir.path(), &[("VARDE_MAX_UPLOAD", "262144")]);
|
||||
let fetcher = support::spawn_daemon();
|
||||
let mut provider_client = support::Client::connect(&provider.socket);
|
||||
let mut fetcher_client = support::Client::connect(&fetcher.socket);
|
||||
|
||||
let payload = vec![7u8; 1_000_000];
|
||||
let src = dir.path().join("capped.bin");
|
||||
std::fs::write(&src, &payload).unwrap();
|
||||
let hash = add_file(&mut provider_client, &src);
|
||||
pin(&mut provider_client, &hash, false);
|
||||
let ticket = export_ticket(&mut provider_client, &hash);
|
||||
|
||||
let start = Instant::now();
|
||||
import_ticket(&mut fetcher_client, &ticket);
|
||||
assert!(
|
||||
wait_complete(&fetcher.socket, &hash, Duration::from_secs(120)),
|
||||
"capped fetch never completed"
|
||||
);
|
||||
let elapsed = start.elapsed();
|
||||
// 1 MB at 256 KiB/s is ~4 s minus one 256 KiB burst: ~3 s. Anything
|
||||
// under 1.5 s means the limiter did nothing (uncapped localhost
|
||||
// moves this in well under 200 ms).
|
||||
assert!(
|
||||
elapsed >= Duration::from_millis(1500),
|
||||
"1 MB fetch finished in {elapsed:?} despite 256 KiB/s upload cap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_streams_progress_and_completion() {
|
||||
let provider = support::spawn_daemon();
|
||||
let fetcher = support::spawn_daemon();
|
||||
let mut provider_client = support::Client::connect(&provider.socket);
|
||||
let mut fetcher_client = support::Client::connect(&fetcher.socket);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
let src = dir.path().join("watched.bin");
|
||||
std::fs::write(&src, vec![5u8; 2_000_000]).unwrap();
|
||||
let hash = add_file(&mut provider_client, &src);
|
||||
pin(&mut provider_client, &hash, false);
|
||||
let ticket = export_ticket(&mut provider_client, &hash);
|
||||
|
||||
// Subscribe on the fetcher before starting the transfer.
|
||||
let mut events = support::Client::connect(&fetcher.socket);
|
||||
let ack = events.request(&Request::Subscribe {});
|
||||
assert!(ack.ok);
|
||||
events.set_read_timeout(Duration::from_secs(60));
|
||||
|
||||
import_ticket(&mut fetcher_client, &ticket);
|
||||
|
||||
let mut saw_progress = false;
|
||||
let mut saw_complete = false;
|
||||
while let Some(line) = events.read_event_line() {
|
||||
let event: serde_json::Value = serde_json::from_str(&line).expect("event is JSON");
|
||||
match event["event"].as_str() {
|
||||
Some("transfer_progress") => {
|
||||
assert_eq!(event["hash"].as_str(), Some(hash.as_str()));
|
||||
assert_eq!(event["direction"].as_str(), Some("download"));
|
||||
saw_progress = true;
|
||||
}
|
||||
Some("pin_complete") => {
|
||||
assert_eq!(event["hash"].as_str(), Some(hash.as_str()));
|
||||
saw_complete = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
assert!(saw_progress, "no transfer_progress event seen");
|
||||
assert!(saw_complete, "no pin_complete event seen");
|
||||
}
|
||||
|
||||
/// Two trusted daemons with LAN discovery on: a pin that exists on one
|
||||
/// side auto-syncs to the other with no ticket exchange. Requires
|
||||
/// working multicast; skips (with a note) where mdns can't see the
|
||||
/// sibling process.
|
||||
#[test]
|
||||
fn overlapping_pins_auto_sync_via_lan_discovery() {
|
||||
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);
|
||||
|
||||
// Mutual trust: A serves B, B auto-fetches from A.
|
||||
let id_a = node_id(&mut client_a);
|
||||
let id_b = node_id(&mut client_b);
|
||||
assert!(client_a.request(&Request::PeerTrust { node_id: id_b }).ok);
|
||||
assert!(client_b.request(&Request::PeerTrust { node_id: id_a }).ok);
|
||||
|
||||
// Content pinned on A only.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let src = dir.path().join("synced.bin");
|
||||
let payload = vec![9u8; 500_000];
|
||||
std::fs::write(&src, &payload).unwrap();
|
||||
let hash = add_file(&mut client_a, &src);
|
||||
pin(&mut client_a, &hash, false);
|
||||
|
||||
// B pins the same hash without having the bytes.
|
||||
pin(&mut client_b, &hash, false);
|
||||
|
||||
// Wait for B to see A via discovery at all.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let mut discovered = false;
|
||||
while Instant::now() < deadline {
|
||||
let reply = client_b.request(&Request::PeerList {});
|
||||
if let Some(ResponseData::Peers { peers }) = reply.data {
|
||||
if peers.iter().any(|p| p.connected) {
|
||||
discovered = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
if !discovered {
|
||||
eprintln!("SKIP: mdns discovery saw no peers (multicast unavailable in this environment)");
|
||||
return;
|
||||
}
|
||||
|
||||
assert!(
|
||||
wait_complete(&b.socket, &hash, Duration::from_secs(60)),
|
||||
"pinned content did not auto-sync from trusted LAN peer"
|
||||
);
|
||||
let dest = dir.path().join("synced-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);
|
||||
}
|
||||
Reference in New Issue
Block a user