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
Generated
+35
View File
@@ -240,6 +240,15 @@ dependencies = [
"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]]
name = "atomic-waker"
version = "1.1.2"
@@ -1316,6 +1325,15 @@ dependencies = [
"tracing",
]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]]
name = "hashbrown"
version = "0.14.5"
@@ -1351,6 +1369,20 @@ dependencies = [
"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]]
name = "heck"
version = "0.5.0"
@@ -2905,6 +2937,7 @@ dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"postcard-derive",
"serde",
]
@@ -4683,11 +4716,13 @@ dependencies = [
"anyhow",
"async-channel",
"bytes",
"ed25519-dalek",
"futures-lite",
"iroh",
"iroh-blobs",
"iroh-io",
"n0-future",
"postcard",
"proptest",
"rand 0.8.7",
"reflink-copy",
+66
View File
@@ -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.
+37
View File
@@ -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]`.
+93
View File
@@ -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.
+4
View File
@@ -24,6 +24,10 @@ futures-lite = "2"
bytes = "1"
# Feature-gated D-Bus client for NetworkManager metered status.
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 }
reflink-copy = { workspace = true }
anyhow = { workspace = true }
+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.
+261
View File
@@ -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);
}
}
+1
View File
@@ -5,6 +5,7 @@
pub mod config;
pub mod daemon;
pub mod discovery;
pub mod dscp;
pub mod meta;
pub mod metered;
+5
View File
@@ -190,6 +190,11 @@ impl Transfer {
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
/// sighting (mdns re-announces periodically); the daemon dedupes.
/// Callable once; returns None afterwards or with discovery off.
+18 -3
View File
@@ -13,8 +13,11 @@ use varde_proto::{PinPolicy, Request, ResponseData};
extern "C" {
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
/// protocol systemd uses.
#[test]
@@ -38,17 +41,28 @@ fn systemd_socket_activation() {
.env("VARDE_DISCOVERY", "false")
.env("LISTEN_FDS", "1")
.env_remove("LISTEN_PID");
// SAFETY: dup2 is async-signal-safe; we place the listener on fd 3
// in the child, which also clears CLOEXEC on the duplicate.
// SAFETY: dup2/fcntl are async-signal-safe; we place the listener on
// 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 {
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());
}
Ok(())
});
}
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.
let deadline = Instant::now() + Duration::from_secs(10);
@@ -56,6 +70,7 @@ fn systemd_socket_activation() {
while Instant::now() < deadline {
if std::os::unix::net::UnixStream::connect(&socket_path).is_ok() {
let mut client = support::Client::connect(&socket_path);
client.set_read_timeout(Duration::from_secs(5));
let reply = client.request(&Request::Status { hash: None });
if reply.ok {
answered = true;
+5
View File
@@ -101,6 +101,11 @@ pub struct Client {
impl Client {
pub fn connect(socket: &Path) -> Client {
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"));
Client {
reader,