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:
2026-07-15 09:51:57 +02:00
parent 871280553e
commit 258fa072aa
11 changed files with 617 additions and 13 deletions
+92 -10
View File
@@ -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.