2 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Opus 5.5 4d9985c9cb release v0.1.5
ci / quality (push) Failing after 5s
release / build (aarch64-unknown-linux-gnu) (push) Successful in 2m26s
release / build (x86_64-unknown-linux-gnu) (push) Successful in 2m18s
release / publish (push) Successful in 47s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:24:51 +02:00
Bendik Aagaard LynghaugandClaude Opus 5.5 2063d11890 PeerAllowPush: consent to exchange pushes, for a while, and nothing else
PeerTrust syncs a peer's pins and serves it everything - too much to
grant a stranger. An app that wants two peers sharing something (two
phones at the same place) to hand each other new content allows pushes
for ttl_secs (at most a day): both sides may push, the receiver pins,
and the allowance lapses on its own. Asking again extends it;
PeerUntrust ends it. varde-ctl peer allow-push.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 13:21:50 +02:00
8 changed files with 171 additions and 15 deletions
Generated
+3 -3
View File
@@ -4491,7 +4491,7 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "varde-ctl"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"clap",
@@ -4504,7 +4504,7 @@ dependencies = [
[[package]]
name = "varde-daemon"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"anyhow",
"base64",
@@ -4530,7 +4530,7 @@ dependencies = [
[[package]]
name = "varde-proto"
version = "0.1.4"
version = "0.1.5"
dependencies = [
"serde",
"serde_json",
+1 -1
View File
@@ -3,7 +3,7 @@ resolver = "2"
members = ["varde-proto", "varde-daemon", "varde-ctl"]
[workspace.package]
version = "0.1.4"
version = "0.1.5"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://project.uhhm.no/uhhm/varde"
+13 -1
View File
@@ -73,7 +73,8 @@ enum Command {
/// Peer trust operations
#[command(subcommand)]
Peer(PeerCommand),
/// Push fully-present content to a trusted peer
/// Push fully-present content to a trusted peer, or one allowed to
/// exchange pushes
Push {
/// BLAKE3 hash (hex)
hash: String,
@@ -116,6 +117,14 @@ enum PeerCommand {
/// iroh NodeId (z-base-32)
node_id: String,
},
/// Allow pushes with a peer for a while, without trusting it
AllowPush {
/// iroh NodeId (z-base-32)
node_id: String,
/// How long, in seconds (at most a day)
#[arg(long, default_value_t = 1800)]
ttl: u64,
},
/// List trusted peers
List,
}
@@ -187,6 +196,9 @@ fn to_request(command: Command) -> Result<Request> {
},
Command::Peer(PeerCommand::Trust { node_id }) => Request::PeerTrust { node_id },
Command::Peer(PeerCommand::Untrust { node_id }) => Request::PeerUntrust { node_id },
Command::Peer(PeerCommand::AllowPush { node_id, ttl }) => {
Request::PeerAllowPush { node_id, ttl_secs: ttl }
}
Command::Peer(PeerCommand::List) => Request::PeerList {},
Command::Push { hash, node_id } => Request::Push { hash, node_id },
Command::Gc => Request::Gc {},
+18 -5
View File
@@ -566,11 +566,23 @@ impl Daemon {
let parsed: iroh::EndpointId = node_id
.parse()
.map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?;
if !self.meta.untrust_peer(&parsed.to_string())? {
let was_trusted = self.meta.untrust_peer(&parsed.to_string())?;
let was_allowed = self.meta.disallow_push(&parsed.to_string())?;
if !was_trusted && !was_allowed {
return Err(OpError::NotFound(format!("{parsed} was not trusted")));
}
Ok(ResponseData::Done {})
}
Request::PeerAllowPush { node_id, ttl_secs } => {
let parsed: iroh::EndpointId = node_id
.parse()
.map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?;
let ttl = ttl_secs.min(varde_proto::MAX_PUSH_TTL_SECS);
self.meta
.allow_push(&parsed.to_string(), crate::meta::unix_now() + ttl)?;
debug!(peer = %parsed, ttl, "pushes allowed");
Ok(ResponseData::Done {})
}
Request::PeerList {} => Ok(ResponseData::Peers {
peers: self.present_trusted_peers(),
}),
@@ -579,11 +591,12 @@ impl Daemon {
let peer: iroh::EndpointId = node_id
.parse()
.map_err(|e| OpError::InvalidArgument(format!("invalid node id: {e}")))?;
// Consent, sending side: we only push to peers we trust.
// (The receiver independently requires that it trusts us.)
if !self.meta.is_trusted(&peer.to_string()) {
// Consent, sending side: we only push to peers we trust or
// hold a push allowance for. (The receiver independently
// requires the same of us.)
if !self.meta.may_push(&peer.to_string()) {
return Err(OpError::InvalidArgument(format!(
"{peer} is not a trusted peer; trust it before pushing"
"{peer} is neither trusted nor allowed to exchange pushes; trust it or allow pushes first"
)));
}
let (_, _, complete) = self.store.presence(parsed).await?;
+60
View File
@@ -40,6 +40,10 @@ struct MetaState {
/// Trusted peer NodeIds (z-base-32).
#[serde(default)]
trusted_peers: BTreeSet<String>,
/// Peers allowed to push to us and be pushed to, until a unix time
/// (`PeerAllowPush`): consent for that one thing, lapsing on its own.
#[serde(default)]
push_peers: BTreeMap<String, u64>,
/// Hashes (hex) the user shared via ticket export: standing consent
/// to serve them to anyone, like an `open_lan` pin.
#[serde(default)]
@@ -164,6 +168,31 @@ impl Meta {
.clone()
}
/// Allow pushes with `node_id` until `until` (unix seconds). Extends,
/// never shortens, an allowance already held; expired ones are
/// dropped on the way.
pub fn allow_push(&self, node_id: &str, until: u64) -> Result<()> {
let now = unix_now();
self.mutate(|s| {
s.push_peers.retain(|_, u| *u > now);
let entry = s.push_peers.entry(node_id.to_string()).or_insert(0);
*entry = (*entry).max(until);
})
}
/// Whether pushes with `node_id` are allowed now: a trusted peer, or
/// one holding an unexpired push allowance.
pub fn may_push(&self, node_id: &str) -> bool {
let state = self.state.lock().expect("meta lock poisoned");
state.trusted_peers.contains(node_id)
|| state.push_peers.get(node_id).is_some_and(|u| *u > unix_now())
}
/// End a push allowance. Returns whether there was one.
pub fn disallow_push(&self, node_id: &str) -> Result<bool> {
self.mutate(|s| s.push_peers.remove(node_id).is_some())
}
pub fn is_trusted(&self, node_id: &str) -> bool {
self.state
.lock()
@@ -173,6 +202,14 @@ impl Meta {
}
}
/// Seconds since the unix epoch.
pub fn unix_now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -201,4 +238,27 @@ mod tests {
assert!(meta.unpin("h").unwrap());
assert!(!meta.is_pinned("h"));
}
#[test]
fn a_push_allowance_lapses_and_never_shortens() {
let dir = tempfile::tempdir().unwrap();
let meta = Meta::load(dir.path()).unwrap();
let now = unix_now();
assert!(!meta.may_push("p"));
meta.allow_push("p", now + 600).unwrap();
assert!(meta.may_push("p"));
assert!(!meta.is_trusted("p"), "a push allowance is not trust");
meta.allow_push("p", now + 10).unwrap();
assert!(meta.may_push("p"), "a shorter ask does not cut it short");
meta.allow_push("q", now.saturating_sub(1)).unwrap();
assert!(!meta.may_push("q"), "expired");
// Survives a reload.
let again = Meta::load(dir.path()).unwrap();
assert!(again.may_push("p"));
assert!(again.disallow_push("p").unwrap());
assert!(!again.may_push("p"));
// Full trust allows pushes too.
again.trust_peer("t").unwrap();
assert!(again.may_push("t"));
}
}
+14 -5
View File
@@ -111,6 +111,8 @@ pub struct Gate {
#[derive(Debug, Clone)]
struct ConnInfo {
trusted: bool,
/// Trusted, or holding a push allowance (`PeerAllowPush`).
may_push: bool,
endpoint_id: Option<String>,
}
@@ -142,6 +144,11 @@ pub fn provider_events(gate: Gate) -> EventSender {
.endpoint_id
.map(|id| gate.meta.is_trusted(&id.to_string()))
.unwrap_or(false);
let may_push = m
.inner
.endpoint_id
.map(|id| gate.meta.may_push(&id.to_string()))
.unwrap_or(false);
let ClientConnected {
connection_id,
endpoint_id,
@@ -151,6 +158,7 @@ pub fn provider_events(gate: Gate) -> EventSender {
connection_id,
ConnInfo {
trusted,
may_push,
endpoint_id: endpoint_id.map(|id| id.to_string()),
},
);
@@ -208,12 +216,13 @@ pub fn provider_events(gate: Gate) -> EventSender {
m.tx.send(verdict).await.ok();
}
ProviderMessage::PushRequestReceived(m) => {
// Inbound writes: trusted peers only, deferred while
// metered (RateLimited invites a retry later).
// Inbound writes: trusted peers and push allowances
// only, deferred while metered (RateLimited invites a
// retry later).
let info = conns.get(&m.inner.connection_id);
let trusted = info.map(|i| i.trusted).unwrap_or(false);
debug!(hash = %m.inner.request.hash.to_hex(), trusted, "push request received");
let verdict = if !trusted {
let may_push = info.map(|i| i.may_push).unwrap_or(false);
debug!(hash = %m.inner.request.hash.to_hex(), may_push, "push request received");
let verdict = if !may_push {
Err(AbortReason::Permission)
} else if gate.metered.is_metered() {
Err(AbortReason::RateLimited)
+46
View File
@@ -422,3 +422,49 @@ fn push_rejected_without_receiver_trust() {
// And the receiver holds none of it.
assert!(!is_complete(&b.socket, &hash));
}
/// A push allowance is enough on both sides: two daemons that only
/// allowed pushes with each other (strangers sharing a place, in redoal)
/// exchange content without trusting each other - and the allowance
/// grants nothing else, so neither lists the other as a trusted peer.
#[test]
fn push_allowance_is_enough_and_is_not_trust() {
let _serial = MDNS_SERIAL.lock().unwrap_or_else(|e| e.into_inner());
let dir_a = tempfile::tempdir().unwrap();
let dir_b = tempfile::tempdir().unwrap();
let a = support::spawn_daemon_with_env(dir_a.path(), &[("VARDE_DISCOVERY", "true")]);
let b = support::spawn_daemon_with_env(dir_b.path(), &[("VARDE_DISCOVERY", "true")]);
let mut client_a = support::Client::connect(&a.socket);
let mut client_b = support::Client::connect(&b.socket);
let id_a = node_id(&mut client_a);
let id_b = node_id(&mut client_b);
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("voice.bin");
let payload = vec![7u8; 120_000];
std::fs::write(&src, &payload).unwrap();
let hash = add_file(&mut client_a, &src);
pin(&mut client_a, &hash, false);
assert!(client_a.request(&Request::PeerAllowPush { node_id: id_b.clone(), ttl_secs: 600 }).ok);
assert!(client_b.request(&Request::PeerAllowPush { node_id: id_a, ttl_secs: 600 }).ok);
match client_a.request(&Request::PeerList {}).data {
Some(ResponseData::Peers { peers }) => assert!(peers.is_empty(), "an allowance is not trust: {peers:?}"),
other => panic!("unexpected peer list: {other:?}"),
}
// The peer's address arrives by mdns; retry until it does.
let deadline = Instant::now() + Duration::from_secs(30);
let reply = loop {
let reply = client_a.request(&Request::Push { hash: hash.clone(), node_id: id_b.clone() });
if reply.ok || Instant::now() > deadline {
break reply;
}
std::thread::sleep(Duration::from_millis(500));
};
if !reply.ok {
eprintln!("SKIP: push never reached the peer (multicast unavailable?): {:?}", reply.error);
return;
}
assert!(wait_complete(&b.socket, &hash, Duration::from_secs(60)), "pushed content not complete on receiver");
}
+16
View File
@@ -20,6 +20,9 @@ use serde::{Deserialize, Serialize};
/// Bumped on incompatible changes; reported in [`GlobalStatus`].
pub const PROTOCOL_VERSION: u32 = 2;
/// The longest a [`Request::PeerAllowPush`] allowance may last: a day.
pub const MAX_PUSH_TTL_SECS: u64 = 24 * 60 * 60;
/// The most bytes a [`Request::Put`] or a [`Request::Read`] reply carries
/// inline. Larger content goes through `Add`/`Materialize` and a path.
pub const MAX_INLINE_BYTES: u64 = 16 * 1024 * 1024;
@@ -116,6 +119,19 @@ pub enum Request {
/// The peer's iroh NodeId (z-base-32).
node_id: String,
},
/// Allow pushes between this daemon and a peer for `ttl_secs` (at most
/// [`MAX_PUSH_TTL_SECS`]): we may push to it, and accept what it pushes
/// to us. Nothing else - unlike [`Request::PeerTrust`] it neither
/// syncs the peer's pins nor serves it our content - so an app can
/// grant it to strangers it shares something with for a while (two
/// phones at the same place) and let it lapse. Asking again extends
/// it; [`Request::PeerUntrust`] ends it early. Added in varde 0.1.5.
PeerAllowPush {
/// The peer's iroh NodeId (z-base-32).
node_id: String,
/// How long the allowance lasts, in seconds.
ttl_secs: u64,
},
/// List trusted peers and their current connectivity.
PeerList {},
/// Push fully-present content to a trusted peer, unprompted.