288 lines
11 KiB
Rust
288 lines
11 KiB
Rust
|
|
//! 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);
|
||
|
|
}
|