diff --git a/Cargo.lock b/Cargo.lock index 3ace047..b0ae10a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/README.md b/README.md new file mode 100644 index 0000000..838805c --- /dev/null +++ b/README.md @@ -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 # ...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 # 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. diff --git a/docs/milestone-6.md b/docs/milestone-6.md new file mode 100644 index 0000000..70a9b74 --- /dev/null +++ b/docs/milestone-6.md @@ -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` because serde + cannot derive for `[u8; 64]`. diff --git a/docs/redoal-integration.md b/docs/redoal-integration.md new file mode 100644 index 0000000..253fdf3 --- /dev/null +++ b/docs/redoal-integration.md @@ -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; + 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 ` command. No daemon architecture changes. diff --git a/varde-daemon/Cargo.toml b/varde-daemon/Cargo.toml index 5be0667..32dbcb2 100644 --- a/varde-daemon/Cargo.toml +++ b/varde-daemon/Cargo.toml @@ -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 } diff --git a/varde-daemon/src/daemon.rs b/varde-daemon/src/daemon.rs index fb5e9b8..68f3114 100644 --- a/varde-daemon/src/daemon.rs +++ b/varde-daemon/src/daemon.rs @@ -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>>, /// LAN peers seen via discovery: node id -> last sighting. lan_peers: Mutex>, + /// Everything announced on subscribed discovery topics, newest per + /// root. Indexing is unconditional; *acting* requires trust. + announcements: Mutex>, } 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 Vec + 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 = 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. diff --git a/varde-daemon/src/discovery.rs b/varde-daemon/src/discovery.rs new file mode 100644 index 0000000..d7ac51f --- /dev/null +++ b/varde-daemon/src/discovery.rs @@ -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 = Pin + 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, + /// Peers believed to hold the content. + pub providers: Vec, +} + +/// 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, +} + +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 { + 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> { + let provider_ids: Vec = 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; + + /// 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>>, + /// Snapshot of locally pinned roots, queried per sighting. + pins: Arc Vec + Send + Sync>, +} + +impl LanDiscovery { + pub fn new( + secret: SecretKey, + sightings: mpsc::Receiver, + pins: Arc Vec + Send + Sync>, + ) -> LanDiscovery { + LanDiscovery { + secret, + sightings: std::sync::Mutex::new(Some(sightings)), + pins, + } + } +} + +impl DiscoveryProvider for LanDiscovery { + fn subscribe(&self, topic: TopicKey) -> BoxStream { + 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(mut rx: mpsc::Receiver) -> impl Stream { + 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); + } +} diff --git a/varde-daemon/src/lib.rs b/varde-daemon/src/lib.rs index a8ab36b..07a2c0f 100644 --- a/varde-daemon/src/lib.rs +++ b/varde-daemon/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod daemon; +pub mod discovery; pub mod dscp; pub mod meta; pub mod metered; diff --git a/varde-daemon/src/transfer.rs b/varde-daemon/src/transfer.rs index 3cd0507..a0dba49 100644 --- a/varde-daemon/src/transfer.rs +++ b/varde-daemon/src/transfer.rs @@ -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. diff --git a/varde-daemon/tests/citizenship.rs b/varde-daemon/tests/citizenship.rs index 5b5d0cd..a3c0129 100644 --- a/varde-daemon/tests/citizenship.rs +++ b/varde-daemon/tests/citizenship.rs @@ -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; diff --git a/varde-daemon/tests/support/mod.rs b/varde-daemon/tests/support/mod.rs index 4cffdf0..63d56cf 100644 --- a/varde-daemon/tests/support/mod.rs +++ b/varde-daemon/tests/support/mod.rs @@ -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,