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:
Generated
+35
@@ -240,6 +240,15 @@ dependencies = [
|
|||||||
"rustc_version",
|
"rustc_version",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "atomic-polyfill"
|
||||||
|
version = "1.0.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
|
||||||
|
dependencies = [
|
||||||
|
"critical-section",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atomic-waker"
|
name = "atomic-waker"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -1316,6 +1325,15 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hash32"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
|
||||||
|
dependencies = [
|
||||||
|
"byteorder",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.14.5"
|
version = "0.14.5"
|
||||||
@@ -1351,6 +1369,20 @@ dependencies = [
|
|||||||
"hashbrown 0.14.5",
|
"hashbrown 0.14.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heapless"
|
||||||
|
version = "0.7.17"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-polyfill",
|
||||||
|
"hash32",
|
||||||
|
"rustc_version",
|
||||||
|
"serde",
|
||||||
|
"spin 0.9.9",
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -2905,6 +2937,7 @@ dependencies = [
|
|||||||
"cobs",
|
"cobs",
|
||||||
"embedded-io 0.4.0",
|
"embedded-io 0.4.0",
|
||||||
"embedded-io 0.6.1",
|
"embedded-io 0.6.1",
|
||||||
|
"heapless",
|
||||||
"postcard-derive",
|
"postcard-derive",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
@@ -4683,11 +4716,13 @@ dependencies = [
|
|||||||
"anyhow",
|
"anyhow",
|
||||||
"async-channel",
|
"async-channel",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
"ed25519-dalek",
|
||||||
"futures-lite",
|
"futures-lite",
|
||||||
"iroh",
|
"iroh",
|
||||||
"iroh-blobs",
|
"iroh-blobs",
|
||||||
"iroh-io",
|
"iroh-io",
|
||||||
"n0-future",
|
"n0-future",
|
||||||
|
"postcard",
|
||||||
"proptest",
|
"proptest",
|
||||||
"rand 0.8.7",
|
"rand 0.8.7",
|
||||||
"reflink-copy",
|
"reflink-copy",
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# varde
|
||||||
|
|
||||||
|
A small, boring, distro-packageable Linux daemon that owns a
|
||||||
|
content-addressed blob store and mirrors content between consenting
|
||||||
|
peers, built on [iroh](https://iroh.computer) (iroh-blobs 0.35).
|
||||||
|
Applications talk to it over a unix socket: "add this path", "pin this
|
||||||
|
hash", "materialize hash X at path Y". Think: what Windows Delivery
|
||||||
|
Optimization is for updates — generalized, open, legible.
|
||||||
|
|
||||||
|
*varde* (Norwegian): a stone cairn used as a waypoint.
|
||||||
|
|
||||||
|
## Design ethos
|
||||||
|
|
||||||
|
- **Infrastructure, not product.** No GUI, no self-updater. A daemon, a
|
||||||
|
CLI, a JSON-lines socket protocol, man pages, systemd units.
|
||||||
|
- **Legible to the network.** Identifiable mDNS advertisement, DSCP CS1
|
||||||
|
marking, conservative rate limits (10 MiB/s up default), LAN-only with
|
||||||
|
zero WAN upload by default, all transfers stop on metered connections.
|
||||||
|
- **Consent-tiered.** Discovery is open; replication is explicit.
|
||||||
|
Content is served only to allowlisted peers, except what you
|
||||||
|
deliberately publish (`--open-lan` pins, exported tickets). All
|
||||||
|
fetched data is BLAKE3-verified regardless — trust gates
|
||||||
|
participation, not integrity.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
| crate | role |
|
||||||
|
|---|---|
|
||||||
|
| `varde-proto` | wire types for the socket API (serde, no I/O) |
|
||||||
|
| `varde-daemon` | the service: store, endpoint, policy, socket server |
|
||||||
|
| `varde-ctl` | CLI client and protocol reference implementation |
|
||||||
|
|
||||||
|
`dist/` holds systemd units (system + user, socket activation),
|
||||||
|
scdoc man pages, an example config, and an untested Arch PKGBUILD.
|
||||||
|
`docs/` has per-milestone decision notes and the redoal integration
|
||||||
|
sketch.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ varde-daemon --user &
|
||||||
|
$ varde-ctl add ~/dataset -r
|
||||||
|
added 5b1c…e0 (1234567 bytes)
|
||||||
|
$ varde-ctl pin 5b1c…e0
|
||||||
|
$ varde-ctl ticket export 5b1c…e0 # hand this to another machine
|
||||||
|
$ varde-ctl ticket import <ticket> # ...which runs this
|
||||||
|
$ varde-ctl materialize 5b1c…e0 /srv/dataset # reflinks when possible
|
||||||
|
```
|
||||||
|
|
||||||
|
Trusted peers on the same LAN sync overlapping pins automatically:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ varde-ctl status # shows this daemon's node id
|
||||||
|
$ varde-ctl peer trust <node-id-of-the-other-machine> # on both ends
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building and testing
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ cargo build --release
|
||||||
|
$ cargo test --workspace # spawns real daemons; no mocked iroh
|
||||||
|
$ cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
The `metered` feature (default on) needs D-Bus at runtime only; build
|
||||||
|
with `--no-default-features` for systems without it.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Milestone 6 — Discovery provider seam
|
||||||
|
|
||||||
|
## What landed
|
||||||
|
|
||||||
|
- `varde_daemon::discovery` with the spec's `DiscoveryProvider` trait,
|
||||||
|
`TopicKey`, and the signed `Announcement` type. Signatures are ed25519
|
||||||
|
(the same key type as iroh NodeIds) over a deterministic postcard
|
||||||
|
encoding that includes the topic, so announcements can't be replayed
|
||||||
|
onto other channels. Verification tests cover round trip, tampered
|
||||||
|
root, wrong topic, forged author, mismatched signing key, and
|
||||||
|
serde-then-verify.
|
||||||
|
- `LanDiscovery` implements the trait: mDNS sightings become
|
||||||
|
locally-authored, signed announcements ("peer N may hold pinned root
|
||||||
|
R"), one per pinned root. `announce()` is a no-op on the LAN — mDNS
|
||||||
|
already advertises presence, and varde never broadcasts content lists.
|
||||||
|
- The daemon's auto-sync was refactored *onto* the seam: sightings feed
|
||||||
|
presence tracking and the LanDiscovery channel; a single
|
||||||
|
`on_announcement` path verifies, indexes, and applies the consent
|
||||||
|
tiers (trusted author → pinned + incomplete → trusted providers →
|
||||||
|
fetch). The milestone-4 mdns integration test passes unchanged through
|
||||||
|
the new path, which is the proof the seam carries real traffic.
|
||||||
|
- Announcements surface on the event stream (`announcement` events) and
|
||||||
|
are indexed in memory per root.
|
||||||
|
- `docs/redoal-integration.md` sketches the gesture-topic gossip
|
||||||
|
provider against this contract, including the one extension it will
|
||||||
|
need (topic-scoped trust) and why nothing else changes.
|
||||||
|
|
||||||
|
## Deviations from the spec sketch
|
||||||
|
|
||||||
|
- The trait deals in `SignedAnnouncement` rather than bare
|
||||||
|
`Announcement`: the spec says announcements are signed, so the wire
|
||||||
|
type carries its signature and the subscriber can't forget to check.
|
||||||
|
- `Announcement.author` is an iroh `PublicKey` (ed25519, as specified);
|
||||||
|
provider addresses are excluded from the signed payload (they're
|
||||||
|
refreshable hints) while provider identities are covered.
|
||||||
|
- Signature bytes travel as a length-checked `Vec<u8>` because serde
|
||||||
|
cannot derive for `[u8; 64]`.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# redoal integration sketch
|
||||||
|
|
||||||
|
How a gesture-derived discovery provider plugs into varde without
|
||||||
|
touching the daemon. Nothing here is implemented; this documents the
|
||||||
|
contract the v1 seam already enforces.
|
||||||
|
|
||||||
|
## The seam
|
||||||
|
|
||||||
|
`varde_daemon::discovery` defines:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait DiscoveryProvider: Send + Sync {
|
||||||
|
fn subscribe(&self, topic: TopicKey) -> BoxStream<SignedAnnouncement>;
|
||||||
|
fn announce(&self, topic: TopicKey, ann: SignedAnnouncement) -> Result<()>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- `TopicKey` is an opaque 32-byte channel id.
|
||||||
|
- `Announcement { root, author, meta, providers }` is signed (ed25519,
|
||||||
|
the same key type as iroh NodeIds) over a deterministic postcard
|
||||||
|
encoding that *includes the topic*, so announcements can't be replayed
|
||||||
|
across channels. Provider socket addresses are excluded from the
|
||||||
|
signature (relays may refresh them); provider *identities* are covered.
|
||||||
|
|
||||||
|
The daemon consumes any provider identically (`Daemon::on_announcement`):
|
||||||
|
|
||||||
|
1. verify the signature — drop on failure;
|
||||||
|
2. index the announcement (all of them — discovery is the open tier);
|
||||||
|
3. auto-fetch only when **all** of these hold:
|
||||||
|
- the author key is trusted (or is the local daemon),
|
||||||
|
- the root is already pinned locally and incomplete,
|
||||||
|
- at least one announced provider is on the peer allowlist.
|
||||||
|
|
||||||
|
v1 ships one implementation, `LanDiscovery`: mDNS sightings are turned
|
||||||
|
into locally-authored announcements ("peer N may hold pinned root R").
|
||||||
|
It proves the seam because the daemon's entire auto-sync path runs
|
||||||
|
through `subscribe()` — swap the provider and nothing above changes.
|
||||||
|
|
||||||
|
## The redoal provider (future)
|
||||||
|
|
||||||
|
redoal turns a shared physical gesture (two phones shaken together, a
|
||||||
|
tap pattern, ...) into a high-entropy shared secret between the people
|
||||||
|
present. That secret derives a gossip topic:
|
||||||
|
|
||||||
|
```
|
||||||
|
TopicKey = BLAKE3-derive_key("redoal/v1/topic", gesture_secret)
|
||||||
|
```
|
||||||
|
|
||||||
|
`RedoalDiscovery` would:
|
||||||
|
|
||||||
|
- join the iroh-gossip swarm for that TopicId (iroh-gossip rides the
|
||||||
|
same iroh endpoint varde already has — no new transport);
|
||||||
|
- `subscribe(topic)`: yield gossip messages decoded as
|
||||||
|
`SignedAnnouncement`s. Verification and consent stay in the daemon —
|
||||||
|
the provider is a dumb pipe by design;
|
||||||
|
- `announce(topic, ann)`: broadcast to the swarm. Unlike the LAN
|
||||||
|
provider (where announce is a no-op because mDNS already advertises
|
||||||
|
presence), gossip announce actively publishes — the daemon must only
|
||||||
|
call it for content the user shared to that topic.
|
||||||
|
|
||||||
|
### Trust bootstrap
|
||||||
|
|
||||||
|
The gesture is the consent ceremony. Deriving from the same secret:
|
||||||
|
|
||||||
|
```
|
||||||
|
author trust: the gesture exchange includes both parties' public keys
|
||||||
|
(signed with the gesture key), so each side adds the
|
||||||
|
other to the *author* allowlist for that topic.
|
||||||
|
peer trust: announcements carry provider NodeIds; on a gestured
|
||||||
|
topic, providers named by a trusted author get scoped
|
||||||
|
peer trust (serve/fetch for that topic's roots only).
|
||||||
|
```
|
||||||
|
|
||||||
|
That last point needs one extension to the v1 model: today peer trust
|
||||||
|
is global (`meta.json` allowlist). Topic-scoped trust would add
|
||||||
|
`trusted_for: {topic → keys}`, checked in the same two places the
|
||||||
|
global list is checked now (provider accept gate, fetch candidate
|
||||||
|
filter). The seam anticipates this: both checks already live in the
|
||||||
|
daemon, not in the provider.
|
||||||
|
|
||||||
|
### What stays true regardless of provider
|
||||||
|
|
||||||
|
- Discovery open, replication explicit: indexing an announcement never
|
||||||
|
moves bytes; only the trust checks above do.
|
||||||
|
- All fetched data is BLAKE3-verified by iroh-blobs.
|
||||||
|
- Metered/rate-limit posture applies unchanged — providers sit above
|
||||||
|
the transport, the limiters below it.
|
||||||
|
|
||||||
|
## Sizing
|
||||||
|
|
||||||
|
`RedoalDiscovery` is roughly: iroh-gossip dependency, ~200 lines of
|
||||||
|
provider glue, the topic-scoped trust extension in `meta.json`, and a
|
||||||
|
`varde-ctl topic join <secret>` command. No daemon architecture changes.
|
||||||
@@ -24,6 +24,10 @@ futures-lite = "2"
|
|||||||
bytes = "1"
|
bytes = "1"
|
||||||
# Feature-gated D-Bus client for NetworkManager metered status.
|
# Feature-gated D-Bus client for NetworkManager metered status.
|
||||||
zbus = { version = "5", optional = true, default-features = false, features = ["tokio"] }
|
zbus = { version = "5", optional = true, default-features = false, features = ["tokio"] }
|
||||||
|
# Announcement signatures: same ed25519 implementation iroh keys use,
|
||||||
|
# postcard is the deterministic encoding signed over.
|
||||||
|
ed25519-dalek = "2"
|
||||||
|
postcard = { version = "1", features = ["use-std"] }
|
||||||
iroh-io = { workspace = true }
|
iroh-io = { workspace = true }
|
||||||
reflink-copy = { workspace = true }
|
reflink-copy = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
|||||||
+92
-10
@@ -15,6 +15,7 @@ use varde_proto::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::discovery::{DiscoveryProvider, LanDiscovery, SignedAnnouncement, TopicKey, LAN_TOPIC};
|
||||||
use crate::meta::{Meta, StoredFormat};
|
use crate::meta::{Meta, StoredFormat};
|
||||||
use crate::store::{BlobStore, OpError};
|
use crate::store::{BlobStore, OpError};
|
||||||
use crate::transfer::Transfer;
|
use crate::transfer::Transfer;
|
||||||
@@ -39,6 +40,9 @@ pub struct Daemon {
|
|||||||
open_set: Arc<RwLock<BTreeSet<Hash>>>,
|
open_set: Arc<RwLock<BTreeSet<Hash>>>,
|
||||||
/// LAN peers seen via discovery: node id -> last sighting.
|
/// LAN peers seen via discovery: node id -> last sighting.
|
||||||
lan_peers: Mutex<BTreeMap<String, Instant>>,
|
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 {
|
impl Daemon {
|
||||||
@@ -78,6 +82,7 @@ impl Daemon {
|
|||||||
events,
|
events,
|
||||||
open_set,
|
open_set,
|
||||||
lan_peers: Mutex::new(BTreeMap::new()),
|
lan_peers: Mutex::new(BTreeMap::new()),
|
||||||
|
announcements: Mutex::new(BTreeMap::new()),
|
||||||
});
|
});
|
||||||
daemon.recompute_open_set().await;
|
daemon.recompute_open_set().await;
|
||||||
|
|
||||||
@@ -99,14 +104,40 @@ impl Daemon {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Discovery loop: track LAN peer sightings, emit join events for
|
// Discovery: raw mdns sightings feed presence tracking, and are
|
||||||
// trusted peers and sync any incomplete pins from them.
|
// 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() {
|
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);
|
let weak = Arc::downgrade(&daemon);
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
while let Some(node_id) = discovered.recv().await {
|
while let Some(node_id) = discovered.recv().await {
|
||||||
let Some(daemon) = weak.upgrade() else { break };
|
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;
|
*self.open_set.write().expect("open set lock") = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a discovery sighting of `node_id`.
|
/// Track presence for a discovery sighting of `node_id`. Content
|
||||||
async fn on_peer_seen(&self, node_id: iroh::NodeId) {
|
/// sync happens via the announcement path, not here.
|
||||||
|
fn on_peer_seen(&self, node_id: iroh::NodeId) {
|
||||||
let id = node_id.to_string();
|
let id = node_id.to_string();
|
||||||
let rejoined = {
|
let rejoined = {
|
||||||
let mut peers = self.lan_peers.lock().expect("lan peers lock");
|
let mut peers = self.lan_peers.lock().expect("lan peers lock");
|
||||||
@@ -174,14 +206,64 @@ impl Daemon {
|
|||||||
peers.insert(id.clone(), now);
|
peers.insert(id.clone(), now);
|
||||||
fresh
|
fresh
|
||||||
};
|
};
|
||||||
if !self.meta.is_trusted(&id) {
|
if rejoined && self.meta.is_trusted(&id) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
if rejoined {
|
|
||||||
info!(peer = %id, "trusted peer present on LAN");
|
info!(peer = %id, "trusted peer present on LAN");
|
||||||
let _ = self.events.send(Event::PeerJoined { node_id: id });
|
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.
|
/// Queue fetches for every incomplete pin from a trusted LAN peer.
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
//! The content-discovery seam.
|
||||||
|
//!
|
||||||
|
//! A [`DiscoveryProvider`] is a source of signed content announcements
|
||||||
|
//! the daemon may subscribe to. v1 ships [`LanDiscovery`] (backed by
|
||||||
|
//! mDNS peer sightings) and the implicit "provider" that is ticket
|
||||||
|
//! import. A future provider maps gesture-derived gossip TopicIds
|
||||||
|
//! (redoal) to announcement streams over iroh-gossip — see
|
||||||
|
//! `docs/redoal-integration.md`.
|
||||||
|
//!
|
||||||
|
//! Consent tiering: the daemon indexes every announcement on subscribed
|
||||||
|
//! topics, but only auto-fetches content announced by trusted author
|
||||||
|
//! keys, and only from providers on the peer allowlist. Verification of
|
||||||
|
//! the fetched bytes is BLAKE3 as always — trust gates participation,
|
||||||
|
//! not integrity.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::pin::Pin;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use iroh::{NodeAddr, NodeId, PublicKey, SecretKey};
|
||||||
|
use iroh_blobs::Hash;
|
||||||
|
use n0_future::Stream;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
/// A stream of announcements, boxed for trait-object use.
|
||||||
|
pub type BoxStream<T> = Pin<Box<dyn Stream<Item = T> + Send + 'static>>;
|
||||||
|
|
||||||
|
/// Identifies an announcement channel. For the LAN provider there is a
|
||||||
|
/// single well-known topic; gossip providers derive topics from shared
|
||||||
|
/// secrets (e.g. redoal gesture keys).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
|
pub struct TopicKey(pub [u8; 32]);
|
||||||
|
|
||||||
|
/// The well-known topic for local-network presence announcements.
|
||||||
|
pub const LAN_TOPIC: TopicKey = TopicKey(*b"varde/v1/lan-presence\0\0\0\0\0\0\0\0\0\0\0");
|
||||||
|
|
||||||
|
/// A content announcement: "content `root` is available from
|
||||||
|
/// `providers`", vouched for by `author`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Announcement {
|
||||||
|
/// The announced content (HashSeq root or single blob hash).
|
||||||
|
pub root: Hash,
|
||||||
|
/// ed25519 key of whoever signs this announcement.
|
||||||
|
pub author: PublicKey,
|
||||||
|
/// Free-form metadata (name hints, sizes, provenance...).
|
||||||
|
pub meta: BTreeMap<String, String>,
|
||||||
|
/// Peers believed to hold the content.
|
||||||
|
pub providers: Vec<NodeAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An [`Announcement`] plus the author's signature over it and its topic.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct SignedAnnouncement {
|
||||||
|
pub announcement: Announcement,
|
||||||
|
/// 64-byte ed25519 signature (serde can't derive for [u8; 64]).
|
||||||
|
signature_bytes: Vec<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SignedAnnouncement {
|
||||||
|
/// Sign `announcement` for `topic`. The signature covers the topic
|
||||||
|
/// too, so an announcement can't be replayed onto another channel.
|
||||||
|
/// `secret` must be the key matching `announcement.author`.
|
||||||
|
pub fn sign(secret: &SecretKey, topic: TopicKey, announcement: Announcement) -> Result<Self> {
|
||||||
|
anyhow::ensure!(
|
||||||
|
secret.public() == announcement.author,
|
||||||
|
"signing key does not match announcement author"
|
||||||
|
);
|
||||||
|
let payload = signing_payload(topic, &announcement)?;
|
||||||
|
let signature = secret.sign(&payload);
|
||||||
|
Ok(SignedAnnouncement {
|
||||||
|
announcement,
|
||||||
|
signature_bytes: signature.to_bytes().to_vec(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the signature against the embedded author key and `topic`.
|
||||||
|
pub fn verify(&self, topic: TopicKey) -> Result<()> {
|
||||||
|
let payload = signing_payload(topic, &self.announcement)?;
|
||||||
|
let bytes: &[u8; 64] = self
|
||||||
|
.signature_bytes
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("signature must be 64 bytes"))?;
|
||||||
|
let signature = ed25519_dalek::Signature::from_bytes(bytes);
|
||||||
|
self.announcement
|
||||||
|
.author
|
||||||
|
.verify(&payload, &signature)
|
||||||
|
.map_err(|e| anyhow::anyhow!("announcement signature invalid: {e}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Deterministic bytes covered by the signature: postcard of
|
||||||
|
/// (topic, root, author, meta, providers' node ids). Provider *addresses*
|
||||||
|
/// are excluded — they are hints that relays may legitimately refresh —
|
||||||
|
/// but the provider identities are covered.
|
||||||
|
fn signing_payload(topic: TopicKey, announcement: &Announcement) -> Result<Vec<u8>> {
|
||||||
|
let provider_ids: Vec<NodeId> = announcement.providers.iter().map(|p| p.node_id).collect();
|
||||||
|
let payload = (
|
||||||
|
topic,
|
||||||
|
announcement.root,
|
||||||
|
announcement.author,
|
||||||
|
&announcement.meta,
|
||||||
|
provider_ids,
|
||||||
|
);
|
||||||
|
Ok(postcard::to_stdvec(&payload)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A source of content announcements the daemon may subscribe to.
|
||||||
|
pub trait DiscoveryProvider: Send + Sync {
|
||||||
|
/// Announcements arriving on `topic`. Invalid signatures are the
|
||||||
|
/// subscriber's problem to reject (call [`SignedAnnouncement::verify`]).
|
||||||
|
fn subscribe(&self, topic: TopicKey) -> BoxStream<SignedAnnouncement>;
|
||||||
|
|
||||||
|
/// Publish an announcement on `topic`.
|
||||||
|
fn announce(&self, topic: TopicKey, ann: SignedAnnouncement) -> Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The v1 LAN provider: turns mDNS peer sightings into announcements.
|
||||||
|
///
|
||||||
|
/// When a peer appears on the LAN, we synthesize one announcement per
|
||||||
|
/// locally pinned root, authored (and signed) by *this* daemon: "I saw
|
||||||
|
/// peer N; N may hold root R". The daemon's own trust policy then
|
||||||
|
/// decides whether to dial N. Outbound `announce` is a no-op: presence
|
||||||
|
/// on the LAN is already advertised by mDNS itself, and varde never
|
||||||
|
/// broadcasts its content list.
|
||||||
|
pub struct LanDiscovery {
|
||||||
|
secret: SecretKey,
|
||||||
|
sightings: std::sync::Mutex<Option<mpsc::Receiver<NodeId>>>,
|
||||||
|
/// Snapshot of locally pinned roots, queried per sighting.
|
||||||
|
pins: Arc<dyn Fn() -> Vec<Hash> + Send + Sync>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LanDiscovery {
|
||||||
|
pub fn new(
|
||||||
|
secret: SecretKey,
|
||||||
|
sightings: mpsc::Receiver<NodeId>,
|
||||||
|
pins: Arc<dyn Fn() -> Vec<Hash> + Send + Sync>,
|
||||||
|
) -> LanDiscovery {
|
||||||
|
LanDiscovery {
|
||||||
|
secret,
|
||||||
|
sightings: std::sync::Mutex::new(Some(sightings)),
|
||||||
|
pins,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscoveryProvider for LanDiscovery {
|
||||||
|
fn subscribe(&self, topic: TopicKey) -> BoxStream<SignedAnnouncement> {
|
||||||
|
if topic != LAN_TOPIC {
|
||||||
|
// The LAN has exactly one channel; other topics are empty.
|
||||||
|
return Box::pin(n0_future::stream::empty());
|
||||||
|
}
|
||||||
|
let Some(mut sightings) = self.sightings.lock().expect("sightings lock").take() else {
|
||||||
|
return Box::pin(n0_future::stream::empty());
|
||||||
|
};
|
||||||
|
let secret = self.secret.clone();
|
||||||
|
let pins = self.pins.clone();
|
||||||
|
let (tx, rx) = mpsc::channel(64);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
while let Some(node_id) = sightings.recv().await {
|
||||||
|
for root in pins() {
|
||||||
|
let announcement = Announcement {
|
||||||
|
root,
|
||||||
|
author: secret.public(),
|
||||||
|
meta: BTreeMap::from([("source".to_string(), "mdns".to_string())]),
|
||||||
|
providers: vec![NodeAddr::new(node_id)],
|
||||||
|
};
|
||||||
|
match SignedAnnouncement::sign(&secret, LAN_TOPIC, announcement) {
|
||||||
|
Ok(signed) => {
|
||||||
|
if tx.send(signed).await.is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "signing lan announcement"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Box::pin(tokio_stream_from(rx))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn announce(&self, _topic: TopicKey, _ann: SignedAnnouncement) -> Result<()> {
|
||||||
|
// Presence is already advertised by mDNS; varde does not
|
||||||
|
// broadcast content lists on the LAN.
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tokio_stream_from<T: Send + 'static>(mut rx: mpsc::Receiver<T>) -> impl Stream<Item = T> {
|
||||||
|
n0_future::stream::poll_fn(move |cx| rx.poll_recv(cx))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn keypair() -> SecretKey {
|
||||||
|
SecretKey::generate(rand::rngs::OsRng)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sample(author: PublicKey) -> Announcement {
|
||||||
|
Announcement {
|
||||||
|
root: Hash::new(b"content"),
|
||||||
|
author,
|
||||||
|
meta: BTreeMap::from([("name".to_string(), "demo".to_string())]),
|
||||||
|
providers: vec![NodeAddr::new(keypair().public())],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_verify_round_trip() {
|
||||||
|
let secret = keypair();
|
||||||
|
let signed = SignedAnnouncement::sign(&secret, LAN_TOPIC, sample(secret.public())).unwrap();
|
||||||
|
signed.verify(LAN_TOPIC).expect("valid signature verifies");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tampered_root_fails() {
|
||||||
|
let secret = keypair();
|
||||||
|
let mut signed =
|
||||||
|
SignedAnnouncement::sign(&secret, LAN_TOPIC, sample(secret.public())).unwrap();
|
||||||
|
signed.announcement.root = Hash::new(b"evil");
|
||||||
|
assert!(signed.verify(LAN_TOPIC).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wrong_topic_fails() {
|
||||||
|
let secret = keypair();
|
||||||
|
let signed = SignedAnnouncement::sign(&secret, LAN_TOPIC, sample(secret.public())).unwrap();
|
||||||
|
assert!(signed.verify(TopicKey([9u8; 32])).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forged_author_fails() {
|
||||||
|
let secret = keypair();
|
||||||
|
let mut signed =
|
||||||
|
SignedAnnouncement::sign(&secret, LAN_TOPIC, sample(secret.public())).unwrap();
|
||||||
|
// Claim a different author without their key.
|
||||||
|
signed.announcement.author = keypair().public();
|
||||||
|
assert!(signed.verify(LAN_TOPIC).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signing_with_mismatched_key_is_rejected() {
|
||||||
|
let secret = keypair();
|
||||||
|
let other = keypair();
|
||||||
|
assert!(SignedAnnouncement::sign(&secret, LAN_TOPIC, sample(other.public())).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn survives_serde_round_trip() {
|
||||||
|
let secret = keypair();
|
||||||
|
let signed = SignedAnnouncement::sign(&secret, LAN_TOPIC, sample(secret.public())).unwrap();
|
||||||
|
let json = serde_json::to_string(&signed).unwrap();
|
||||||
|
let back: SignedAnnouncement = serde_json::from_str(&json).unwrap();
|
||||||
|
back.verify(LAN_TOPIC).expect("signature survives serde");
|
||||||
|
assert_eq!(back, signed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
|
pub mod discovery;
|
||||||
pub mod dscp;
|
pub mod dscp;
|
||||||
pub mod meta;
|
pub mod meta;
|
||||||
pub mod metered;
|
pub mod metered;
|
||||||
|
|||||||
@@ -190,6 +190,11 @@ impl Transfer {
|
|||||||
self.endpoint.node_id().to_string()
|
self.endpoint.node_id().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The endpoint's identity key (also signs our announcements).
|
||||||
|
pub fn secret_key(&self) -> SecretKey {
|
||||||
|
self.endpoint.secret_key().clone()
|
||||||
|
}
|
||||||
|
|
||||||
/// Take the stream of locally discovered node ids. Yields each
|
/// Take the stream of locally discovered node ids. Yields each
|
||||||
/// sighting (mdns re-announces periodically); the daemon dedupes.
|
/// sighting (mdns re-announces periodically); the daemon dedupes.
|
||||||
/// Callable once; returns None afterwards or with discovery off.
|
/// Callable once; returns None afterwards or with discovery off.
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ use varde_proto::{PinPolicy, Request, ResponseData};
|
|||||||
|
|
||||||
extern "C" {
|
extern "C" {
|
||||||
fn dup2(oldfd: i32, newfd: i32) -> i32;
|
fn dup2(oldfd: i32, newfd: i32) -> i32;
|
||||||
|
fn fcntl(fd: i32, cmd: i32, arg: i32) -> i32;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const F_SETFD: i32 = 2;
|
||||||
|
|
||||||
/// Spawn the daemon with an activation socket on fd 3, the LISTEN_FDS
|
/// Spawn the daemon with an activation socket on fd 3, the LISTEN_FDS
|
||||||
/// protocol systemd uses.
|
/// protocol systemd uses.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -38,17 +41,28 @@ fn systemd_socket_activation() {
|
|||||||
.env("VARDE_DISCOVERY", "false")
|
.env("VARDE_DISCOVERY", "false")
|
||||||
.env("LISTEN_FDS", "1")
|
.env("LISTEN_FDS", "1")
|
||||||
.env_remove("LISTEN_PID");
|
.env_remove("LISTEN_PID");
|
||||||
// SAFETY: dup2 is async-signal-safe; we place the listener on fd 3
|
// SAFETY: dup2/fcntl are async-signal-safe; we place the listener on
|
||||||
// in the child, which also clears CLOEXEC on the duplicate.
|
// fd 3 in the child. dup2 clears CLOEXEC on the duplicate, but when
|
||||||
|
// the listener already *is* fd 3, dup2(3,3) is a no-op that leaves
|
||||||
|
// CLOEXEC set and exec would close the socket — clear it explicitly.
|
||||||
unsafe {
|
unsafe {
|
||||||
command.pre_exec(move || {
|
command.pre_exec(move || {
|
||||||
if dup2(fd, 3) < 0 {
|
let rc = if fd == 3 {
|
||||||
|
fcntl(3, F_SETFD, 0)
|
||||||
|
} else {
|
||||||
|
dup2(fd, 3)
|
||||||
|
};
|
||||||
|
if rc < 0 {
|
||||||
return Err(std::io::Error::last_os_error());
|
return Err(std::io::Error::last_os_error());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let mut child = command.spawn().expect("spawning daemon");
|
let mut child = command.spawn().expect("spawning daemon");
|
||||||
|
// Drop our copy of the listener: if the daemon dies, connects must
|
||||||
|
// fail (and this test fail cleanly) rather than queue in the backlog
|
||||||
|
// of a socket nobody accepts on.
|
||||||
|
drop(listener);
|
||||||
|
|
||||||
// The daemon must answer on the activation socket.
|
// The daemon must answer on the activation socket.
|
||||||
let deadline = Instant::now() + Duration::from_secs(10);
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
@@ -56,6 +70,7 @@ fn systemd_socket_activation() {
|
|||||||
while Instant::now() < deadline {
|
while Instant::now() < deadline {
|
||||||
if std::os::unix::net::UnixStream::connect(&socket_path).is_ok() {
|
if std::os::unix::net::UnixStream::connect(&socket_path).is_ok() {
|
||||||
let mut client = support::Client::connect(&socket_path);
|
let mut client = support::Client::connect(&socket_path);
|
||||||
|
client.set_read_timeout(Duration::from_secs(5));
|
||||||
let reply = client.request(&Request::Status { hash: None });
|
let reply = client.request(&Request::Status { hash: None });
|
||||||
if reply.ok {
|
if reply.ok {
|
||||||
answered = true;
|
answered = true;
|
||||||
|
|||||||
@@ -101,6 +101,11 @@ pub struct Client {
|
|||||||
impl Client {
|
impl Client {
|
||||||
pub fn connect(socket: &Path) -> Client {
|
pub fn connect(socket: &Path) -> Client {
|
||||||
let stream = UnixStream::connect(socket).expect("connecting to daemon");
|
let stream = UnixStream::connect(socket).expect("connecting to daemon");
|
||||||
|
// Hang guard: a daemon that accepts but never answers should fail
|
||||||
|
// the test, not wedge the whole suite.
|
||||||
|
stream
|
||||||
|
.set_read_timeout(Some(Duration::from_secs(120)))
|
||||||
|
.expect("setting read timeout");
|
||||||
let reader = BufReader::new(stream.try_clone().expect("cloning stream"));
|
let reader = BufReader::new(stream.try_clone().expect("cloning stream"));
|
||||||
Client {
|
Client {
|
||||||
reader,
|
reader,
|
||||||
|
|||||||
Reference in New Issue
Block a user