Milestone 6: DiscoveryProvider seam, signed announcements, redoal sketch
The discovery module defines the seam: DiscoveryProvider (subscribe/ announce over 32-byte TopicKeys) and a signed Announcement carrying root hash, ed25519 author, metadata and provider addresses. Signatures cover a deterministic postcard encoding including the topic (no cross- channel replay) and the provider identities (addresses stay refreshable hints). LanDiscovery conforms to the trait — mdns sightings become locally-authored announcements, one per pinned root — and the daemon's auto-sync now runs entirely through it: verify, index unconditionally, fetch only for trusted authors from allowlisted providers on already- pinned incomplete roots. The milestone-4 real-mdns sync test passes unchanged through the new path. Verification unit tests cover round trip, tampered root, wrong topic, forged author, mismatched signing key, and serde survival. docs/redoal-integration.md sketches the gesture-topic gossip provider against this contract. Also: fix a flaky hang in the socket-activation test (dup2(3,3) leaves CLOEXEC set when the listener already sits on fd 3; parent's listener copy masked daemon death), and give the test client a read-timeout hang guard. Add a top-level README. Dependencies: ed25519-dalek (Signature type; same implementation iroh keys use), postcard (deterministic signed encoding, iroh's canonical compact codec). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+92
-10
@@ -15,6 +15,7 @@ use varde_proto::{
|
||||
};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::discovery::{DiscoveryProvider, LanDiscovery, SignedAnnouncement, TopicKey, LAN_TOPIC};
|
||||
use crate::meta::{Meta, StoredFormat};
|
||||
use crate::store::{BlobStore, OpError};
|
||||
use crate::transfer::Transfer;
|
||||
@@ -39,6 +40,9 @@ pub struct Daemon {
|
||||
open_set: Arc<RwLock<BTreeSet<Hash>>>,
|
||||
/// LAN peers seen via discovery: node id -> last sighting.
|
||||
lan_peers: Mutex<BTreeMap<String, Instant>>,
|
||||
/// Everything announced on subscribed discovery topics, newest per
|
||||
/// root. Indexing is unconditional; *acting* requires trust.
|
||||
announcements: Mutex<BTreeMap<Hash, SignedAnnouncement>>,
|
||||
}
|
||||
|
||||
impl Daemon {
|
||||
@@ -78,6 +82,7 @@ impl Daemon {
|
||||
events,
|
||||
open_set,
|
||||
lan_peers: Mutex::new(BTreeMap::new()),
|
||||
announcements: Mutex::new(BTreeMap::new()),
|
||||
});
|
||||
daemon.recompute_open_set().await;
|
||||
|
||||
@@ -99,14 +104,40 @@ impl Daemon {
|
||||
}
|
||||
});
|
||||
|
||||
// Discovery loop: track LAN peer sightings, emit join events for
|
||||
// trusted peers and sync any incomplete pins from them.
|
||||
// Discovery: raw mdns sightings feed presence tracking, and are
|
||||
// re-emitted as signed announcements through the LanDiscovery
|
||||
// provider — the same seam a future gossip provider plugs into.
|
||||
if let Some(mut discovered) = daemon.transfer.take_discovery() {
|
||||
let (lan_tx, lan_rx) = tokio::sync::mpsc::channel(64);
|
||||
let pins_snapshot: Arc<dyn Fn() -> Vec<Hash> + Send + Sync> = {
|
||||
let meta = daemon.meta.clone();
|
||||
Arc::new(move || {
|
||||
meta.pins()
|
||||
.into_iter()
|
||||
.filter_map(|(hex, _)| Hash::from_str(&hex).ok())
|
||||
.collect()
|
||||
})
|
||||
};
|
||||
let lan = LanDiscovery::new(daemon.transfer.secret_key(), lan_rx, pins_snapshot);
|
||||
let mut announcements = lan.subscribe(LAN_TOPIC);
|
||||
|
||||
let weak = Arc::downgrade(&daemon);
|
||||
tokio::spawn(async move {
|
||||
while let Some(node_id) = discovered.recv().await {
|
||||
let Some(daemon) = weak.upgrade() else { break };
|
||||
daemon.on_peer_seen(node_id).await;
|
||||
daemon.on_peer_seen(node_id);
|
||||
if lan_tx.send(node_id).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let weak = Arc::downgrade(&daemon);
|
||||
tokio::spawn(async move {
|
||||
use n0_future::StreamExt;
|
||||
while let Some(signed) = announcements.next().await {
|
||||
let Some(daemon) = weak.upgrade() else { break };
|
||||
daemon.on_announcement(LAN_TOPIC, signed).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -161,8 +192,9 @@ impl Daemon {
|
||||
*self.open_set.write().expect("open set lock") = open;
|
||||
}
|
||||
|
||||
/// Handle a discovery sighting of `node_id`.
|
||||
async fn on_peer_seen(&self, node_id: iroh::NodeId) {
|
||||
/// Track presence for a discovery sighting of `node_id`. Content
|
||||
/// sync happens via the announcement path, not here.
|
||||
fn on_peer_seen(&self, node_id: iroh::NodeId) {
|
||||
let id = node_id.to_string();
|
||||
let rejoined = {
|
||||
let mut peers = self.lan_peers.lock().expect("lan peers lock");
|
||||
@@ -174,14 +206,64 @@ impl Daemon {
|
||||
peers.insert(id.clone(), now);
|
||||
fresh
|
||||
};
|
||||
if !self.meta.is_trusted(&id) {
|
||||
return;
|
||||
}
|
||||
if rejoined {
|
||||
if rejoined && self.meta.is_trusted(&id) {
|
||||
info!(peer = %id, "trusted peer present on LAN");
|
||||
let _ = self.events.send(Event::PeerJoined { node_id: id });
|
||||
}
|
||||
self.sync_pins_from(node_id).await;
|
||||
}
|
||||
|
||||
/// Handle one announcement from a discovery provider: index it
|
||||
/// unconditionally, act on it only within the trust policy.
|
||||
async fn on_announcement(&self, topic: TopicKey, signed: SignedAnnouncement) {
|
||||
if let Err(e) = signed.verify(topic) {
|
||||
warn!(error = %e, "dropping announcement with bad signature");
|
||||
return;
|
||||
}
|
||||
let ann = &signed.announcement;
|
||||
let root = ann.root;
|
||||
self.announcements
|
||||
.lock()
|
||||
.expect("announcements lock")
|
||||
.insert(root, signed.clone());
|
||||
let _ = self.events.send(Event::Announcement {
|
||||
root: root.to_hex().to_string(),
|
||||
author: ann.author.to_string(),
|
||||
meta: ann.meta.clone(),
|
||||
});
|
||||
|
||||
// Consent tier 1: only act on announcements from trusted authors
|
||||
// (our own key vouches for locally observed LAN sightings).
|
||||
let author_trusted = ann.author == self.transfer.secret_key().public()
|
||||
|| self.meta.is_trusted(&ann.author.to_string());
|
||||
if !author_trusted {
|
||||
return;
|
||||
}
|
||||
// Consent tier 2: only content we already pinned, and only from
|
||||
// providers on the peer allowlist.
|
||||
let hex = root.to_hex().to_string();
|
||||
if !self.meta.is_pinned(&hex) {
|
||||
return;
|
||||
}
|
||||
if let Ok((_, _, true)) = self.store.presence(root).await {
|
||||
return; // already complete
|
||||
}
|
||||
let providers: Vec<iroh::NodeAddr> = ann
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| self.meta.is_trusted(&p.node_id.to_string()))
|
||||
.cloned()
|
||||
.collect();
|
||||
if providers.is_empty() {
|
||||
return;
|
||||
}
|
||||
debug!(root = %hex, providers = providers.len(), "fetching announced content");
|
||||
self.transfer.spawn_fetch(
|
||||
HashAndFormat {
|
||||
hash: root,
|
||||
format: blob_format(self.meta.format_of(&hex)),
|
||||
},
|
||||
providers,
|
||||
);
|
||||
}
|
||||
|
||||
/// Queue fetches for every incomplete pin from a trusted LAN peer.
|
||||
|
||||
Reference in New Issue
Block a user