From 22b2871f64ecf34a22d32add0dd59a0c7c96ad10 Mon Sep 17 00:00:00 2001 From: Lorenz Leutgeb Date: Wed, 1 Apr 2026 11:11:58 +0200 Subject: [PATCH] radicle: Add Version to User Agent The main motivation behind this change is to get just a little telemetry information from nodes on the network, namely the version of Radicle they are running. This is achieved by rewriting `impl Default for UserAgent` which now uses the version information provided by the build script also used in other crates. Also, a new configuration option `node.userAgent` is added, which allows users to override the user agent if they so please, or set the value `null`, which will in turn send the user agent `/radicle/`, which is not really helpful, and the default prior to this commit. Creations of `UserAgent` in the whole workspace is cleaned up. In order to do that `UserAgent::test` is introduced. --- CHANGELOG.md | 9 +++ crates/radicle-cli/examples/rad-config.md | 22 ++++++ crates/radicle-cli/tests/util/environment.rs | 6 +- crates/radicle-node/src/lib.rs | 10 --- crates/radicle-node/src/runtime.rs | 4 +- crates/radicle-node/src/test/gossip.rs | 4 +- crates/radicle-node/src/test/peer.rs | 2 +- crates/radicle-protocol/src/service/gossip.rs | 14 +--- .../radicle-protocol/src/service/message.rs | 12 ++-- crates/radicle/build.rs | 1 + crates/radicle/src/node.rs | 45 +++++++++++- crates/radicle/src/node/config.rs | 72 ++++++++++++++++++- crates/radicle/src/profile.rs | 6 +- crates/radicle/src/serde_ext.rs | 12 ++++ crates/radicle/src/test/arbitrary.rs | 7 +- 15 files changed, 182 insertions(+), 44 deletions(-) create mode 120000 crates/radicle/build.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed337d8..010e7fff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 using `--verbose`, and the full OID when using `--verbose`. These ranges make using `git range-diff` a lot easier, since you can copy the range from each revision you want to compare. +- Nodes will now advertise the version of Radicle they are running in the node + announcement as part of the "user agent", which is shared among the network. + For example, the value that will be shared by Radicle 1.9.0 will be + `/radicle:1.9.0/`. This value is also customizable by node operators via the + configuration value `node.userAgent`. Refer to `rad config schema` for more + information on the possible values. Operators that choose to set a custom + value are asked to keep the substring `/radicle:{YOUR_VERSION}/` which allows + for better telemetry regarding version distribution on the network. + To opt-out of sending any meaningful user agent, set `node.userAgent = null`. ## 1.8.0 diff --git a/crates/radicle-cli/examples/rad-config.md b/crates/radicle-cli/examples/rad-config.md index 263e170f..babf644f 100644 --- a/crates/radicle-cli/examples/rad-config.md +++ b/crates/radicle-cli/examples/rad-config.md @@ -203,6 +203,17 @@ $ rad config schema "description": "Node alias.", "$ref": "#/$defs/Alias" }, + "userAgent": { + "description": "User agent string to advertise in the node announcement, which is sent out to other nodes.", + "anyOf": [ + { + "$ref": "#/$defs/UserAgent" + }, + { + "type": "null" + } + ] + }, "listen": { "description": "Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on.", "type": "array", @@ -329,6 +340,17 @@ $ rad config schema "description": "Node alias, i.e. a short and memorable name for it.", "type": "string" }, + "UserAgent": { + "description": "A user agent string that starts and ends with the symbol '/', and contains segments of the form 'client:version' separated by '/'. The client and version parts must be non-empty, and must consist of printable ASCII characters excluding '/' and ':'. The entire string must be at most 64 characters long.", + "type": "string", + "minLength": 3, + "maxLength": 64, + "examples": [ + "/radicle:1.9.0/", + "/example:42.0.0/other-client:2.3.4/" + ], + "pattern": "^/([^:///s]+((:[^:///s]+))?/)+$" + }, "PeerConfig": { "description": "Peer configuration.", "oneOf": [ diff --git a/crates/radicle-cli/tests/util/environment.rs b/crates/radicle-cli/tests/util/environment.rs index e01a89a2..dbd1b594 100644 --- a/crates/radicle-cli/tests/util/environment.rs +++ b/crates/radicle-cli/tests/util/environment.rs @@ -5,11 +5,11 @@ use radicle::cob::cache::COBS_DB_FILE; use radicle::crypto::ssh::{Keystore, keystore::MemorySigner}; use radicle::crypto::{KeyPair, Seed}; use radicle::git; +use radicle::node; use radicle::node::policy::store as policy; -use radicle::node::{self, UserAgent}; use radicle::node::{Alias, Config, POLICIES_DB_FILE}; +use radicle::profile; use radicle::profile::Home; -use radicle::profile::{self}; use radicle::storage::git::transport; use radicle::{Profile, Storage}; use radicle_localtime::LocalTime; @@ -161,7 +161,7 @@ impl Environment { &keypair.pk.into(), config.node.features(), &alias, - &UserAgent::default(), + &config.node.user_agent(), LocalTime::now().into(), config.node.external_addresses.iter(), ) diff --git a/crates/radicle-node/src/lib.rs b/crates/radicle-node/src/lib.rs index 3082aede..53bb3bb1 100644 --- a/crates/radicle-node/src/lib.rs +++ b/crates/radicle-node/src/lib.rs @@ -19,15 +19,11 @@ pub mod tests; extern crate radicle_localtime as localtime; -use std::str::FromStr; -use std::sync::LazyLock; - use radicle::version::Version; pub use localtime::{LocalDuration, LocalTime}; pub use radicle::node::Link; pub use radicle::node::PROTOCOL_VERSION; -pub use radicle::node::UserAgent; pub use radicle::prelude::Timestamp; pub use radicle::{collections, crypto, git, identity, node, profile, rad, storage}; pub use runtime::Runtime; @@ -40,12 +36,6 @@ pub const VERSION: Version = Version { timestamp: env!("SOURCE_DATE_EPOCH"), }; -/// This node's user agent string. -pub static USER_AGENT: LazyLock = LazyLock::new(|| { - FromStr::from_str(format!("/radicle:{}/", VERSION.version).as_str()) - .expect("user agent is valid") -}); - pub mod prelude { pub use crate::crypto::{PublicKey, Signature}; pub use crate::identity::{Did, RepoId}; diff --git a/crates/radicle-node/src/runtime.rs b/crates/radicle-node/src/runtime.rs index b824d719..7cd7f55f 100644 --- a/crates/radicle-node/src/runtime.rs +++ b/crates/radicle-node/src/runtime.rs @@ -3,6 +3,7 @@ pub mod thread; use std::fmt::Debug; use std::path::PathBuf; +use std::str::FromStr as _; use std::{fs, io, net}; #[cfg(unix)] @@ -197,7 +198,8 @@ impl Runtime { radicle::node::Features::SEED, &alias, 0, - &UserAgent::default(), + &UserAgent::from_str("/radicle/runtime/bootstrap/") + .expect("valid user agent"), clock.into(), [node::KnownAddress::new(addr, address::Source::Bootstrap)], )?; diff --git a/crates/radicle-node/src/test/gossip.rs b/crates/radicle-node/src/test/gossip.rs index 87d2698a..4a774a32 100644 --- a/crates/radicle-node/src/test/gossip.rs +++ b/crates/radicle-node/src/test/gossip.rs @@ -1,5 +1,3 @@ -use std::str::FromStr; - use radicle::node; use radicle::node::UserAgent; use radicle::node::device::Device; @@ -34,7 +32,7 @@ pub fn messages(count: usize, now: LocalTime, delta: LocalDuration) -> Vec = LazyLock::new(|| { - FromStr::from_str(format!("/radicle:{PROTOCOL_VERSION}/").as_str()) - .expect("user agent is valid") -}); - pub fn node(config: &Config, timestamp: Timestamp) -> NodeAnnouncement { let features = config.features(); let alias = config.alias.clone(); @@ -24,7 +14,9 @@ pub fn node(config: &Config, timestamp: Timestamp) -> NodeAnnouncement { .clone() .try_into() .expect("external addresses are within the limit"); - let agent = PROTOCOL_VERSION_STRING.clone(); + + let agent = config.user_agent(); + let version = PROTOCOL_VERSION; NodeAnnouncement { diff --git a/crates/radicle-protocol/src/service/message.rs b/crates/radicle-protocol/src/service/message.rs index cca54ded..74d7f243 100644 --- a/crates/radicle-protocol/src/service/message.rs +++ b/crates/radicle-protocol/src/service/message.rs @@ -1,3 +1,4 @@ +use std::str::FromStr; use std::{fmt, mem}; use bytes::{Buf, BufMut}; @@ -142,7 +143,9 @@ impl wire::Decode for NodeAnnouncement { let nonce = u64::decode(buf)?; let agent = match UserAgent::decode(buf) { Ok(ua) => ua, - Err(wire::Error::UnexpectedEnd { .. }) => UserAgent::default(), + Err(wire::Error::UnexpectedEnd { .. }) => { + UserAgent::from_str("/radicle/message/truncated/").expect("valid user agent") + } Err(e) => return Err(e), }; @@ -683,7 +686,6 @@ impl qcheck::Arbitrary for ZeroBytes { #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { - use std::str::FromStr; use fastrand; use localtime::LocalTime; @@ -784,12 +786,12 @@ mod tests { alias: Alias::new("alice"), addresses: BoundedVec::new(), nonce: 0, - agent: UserAgent::from_str("/heartwood:1.0.0/").unwrap(), + agent: UserAgent::test(), }; - assert_eq!(ann.work(), 1); + assert_eq!(ann.work(), 2); assert_eq!(ann.clone().solve(1).unwrap().work(), 1); - assert_eq!(ann.clone().solve(8).unwrap().work(), 10); + assert_eq!(ann.clone().solve(8).unwrap().work(), 8); assert_eq!(ann.solve(14).unwrap().work(), 14); } } diff --git a/crates/radicle/build.rs b/crates/radicle/build.rs new file mode 120000 index 00000000..c6b197af --- /dev/null +++ b/crates/radicle/build.rs @@ -0,0 +1 @@ +../../build.rs \ No newline at end of file diff --git a/crates/radicle/src/node.rs b/crates/radicle/src/node.rs index 25f344b2..b2181f74 100644 --- a/crates/radicle/src/node.rs +++ b/crates/radicle/src/node.rs @@ -220,18 +220,56 @@ impl PartialOrd for SyncStatus { /// Node user agent. #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Deserialize)] -pub struct UserAgent(String); +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[cfg_attr( + feature = "schemars", + schemars(description = "\ + A user agent string that starts and ends with the symbol '/', and contains segments of the form 'client:version' separated by '/'. \ + The client and version parts must be non-empty, and must consist of printable ASCII characters excluding '/' and ':'. \ + The entire string must be at most 64 characters long.", + extend( + "examples" = [ + "/radicle:1.9.0/", + "/example:42.0.0/other-client:2.3.4/", + ], + "pattern" = r"^/([^:/\s]+((:[^:/\s]+))?/)+$", + ), +))] +pub struct UserAgent( + #[cfg_attr(feature = "schemars", schemars( + length(min = 3, max = UserAgent::LEN_MAX), + ))] + String, +); impl UserAgent { + const LEN_MAX: usize = 64; + /// Return a reference to the user agent string. pub fn as_str(&self) -> &str { self.0.as_str() } + + /// Return a user agent that can be used for testing purposes. + #[cfg(any(test, feature = "test"))] + pub fn test() -> Self { + UserAgent("/radicle:test/".to_owned()) + } } impl Default for UserAgent { fn default() -> Self { - UserAgent(String::from("/radicle/")) + const NAME: &str = env!("CARGO_PKG_NAME"); + const VERSION: &str = env!("RADICLE_VERSION"); + + // The length check can be performed at compile time. + #[allow(clippy::int_plus_one)] + const _: () = assert!(1 + NAME.len() + 1 + VERSION.len() + 1 <= UserAgent::LEN_MAX); + + // All other checks are performed by the `FromStr` implementation + // at run time. + UserAgent::from_str(&format!("/{NAME}:{VERSION}/")) + .expect("default user agent should be valid") } } @@ -247,7 +285,7 @@ impl FromStr for UserAgent { fn from_str(input: &str) -> Result { let reserved = ['/', ':']; - if input.len() > 64 { + if input.len() > UserAgent::LEN_MAX { return Err(input.to_owned()); } let Some(s) = input.strip_prefix('/') else { @@ -1502,6 +1540,7 @@ mod test { assert!(UserAgent::from_str("/radicle/").is_ok()); assert!(UserAgent::from_str("/rad/icle/").is_ok()); assert!(UserAgent::from_str("/rad:ic/le/").is_ok()); + assert!(UserAgent::from_str("/heartwood:1.8.0-6-gf223afd9d-dirty/").is_ok()); assert!(UserAgent::from_str("/:/").is_err()); assert!(UserAgent::from_str("//").is_err()); diff --git a/crates/radicle/src/node/config.rs b/crates/radicle/src/node/config.rs index 16c46486..d82ae479 100644 --- a/crates/radicle/src/node/config.rs +++ b/crates/radicle/src/node/config.rs @@ -8,8 +8,8 @@ use localtime::LocalDuration; use serde::{Deserialize, Serialize}; use serde_json as json; -use crate::node; use crate::node::policy::SeedingPolicy; +use crate::node::{self, UserAgent}; use crate::node::{Address, Alias, NodeId}; use crate::storage::refs::FeatureLevel; @@ -530,6 +530,12 @@ impl Fetch { pub struct Config { /// Node alias. pub alias: Alias, + /// User agent string to advertise in the node announcement, which is sent out to other nodes. + #[serde( + default = "crate::serde_ext::some_default::", + skip_serializing_if = "crate::serde_ext::is_some_default" + )] + pub user_agent: Option, /// Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on. #[serde(default)] #[cfg_attr(feature = "schemars", schemars(example = &"127.0.0.1:8776"))] @@ -603,6 +609,7 @@ impl Config { pub fn new(alias: Alias) -> Self { Self { alias, + user_agent: Some(UserAgent::default()), peers: PeerConfig::default(), listen: vec![], connect: HashSet::default(), @@ -652,6 +659,15 @@ impl Config { pub fn features(&self) -> node::Features { node::Features::SEED } + + /// Return the configured user agent, if set. Otherwise fall back to the + /// unintetesting value `"/radicle/"`. + pub fn user_agent(&self) -> UserAgent { + match self.user_agent.as_ref() { + Some(agent) => agent.clone(), + None => UserAgent::from_str("/radicle/").expect("valid user agent"), + } + } } #[derive(Clone, Copy, Debug, Display, Deserialize, Serialize, From)] @@ -781,7 +797,7 @@ wrapper!( #[allow(clippy::unwrap_used)] mod test { use super::{DefaultSeedingPolicy, Scope}; - use crate::node::{Alias, policy}; + use crate::node::{Alias, UserAgent, policy}; use serde_json::json; #[test] @@ -974,4 +990,56 @@ mod test { .unwrap(); assert_eq!(super::AddressConfig::Drop, actual.onion); } + + #[test] + fn user_agent_opt_out() { + let actual: super::Config = serde_json::from_value(json!({ + "alias": "radicle", + "userAgent": null, + })) + .unwrap(); + assert_eq!(None, actual.user_agent); + } + + #[test] + fn user_agent_default() { + let actual: super::Config = serde_json::from_value(json!({ + "alias": "radicle", + })) + .unwrap(); + assert_eq!(Some(UserAgent::default()), actual.user_agent); + } + + #[test] + fn user_agent_custom() { + use std::str::FromStr as _; + + let actual: super::Config = serde_json::from_value(json!({ + "alias": "radicle", + "userAgent": "/example:0.1.0/", + })) + .unwrap(); + assert_eq!( + Some(UserAgent::from_str("/example:0.1.0/").unwrap()), + actual.user_agent + ); + } + + #[test] + fn user_agent_default_explicit() { + use std::str::FromStr as _; + + let default_as_string = UserAgent::default().to_string(); + assert!(default_as_string.contains(":")); + + let actual: super::Config = serde_json::from_value(json!({ + "alias": "radicle", + "userAgent": default_as_string, + })) + .unwrap(); + assert_eq!( + Some(UserAgent::from_str(&default_as_string).unwrap()), + actual.user_agent + ); + } } diff --git a/crates/radicle/src/profile.rs b/crates/radicle/src/profile.rs index fc557910..8b90c1eb 100644 --- a/crates/radicle/src/profile.rs +++ b/crates/radicle/src/profile.rs @@ -28,9 +28,7 @@ use crate::crypto::ssh::agent::Agent; use crate::crypto::ssh::{Keystore, Passphrase, keystore}; use crate::node::device::{BoxedDevice, Device}; use crate::node::policy::config::store::Read; -use crate::node::{ - Alias, AliasStore, Handle as _, Node, UserAgent, notifications, policy, policy::Scope, -}; +use crate::node::{Alias, AliasStore, Handle as _, Node, notifications, policy, policy::Scope}; use crate::prelude::{Did, NodeId, RepoId}; use crate::storage::ReadRepository; use crate::storage::git::Storage; @@ -257,7 +255,7 @@ impl Profile { &public_key, config.node.features(), &config.node.alias, - &UserAgent::default(), + &config.node.user_agent(), LocalTime::now().into(), config.node.external_addresses.iter(), )?; diff --git a/crates/radicle/src/serde_ext.rs b/crates/radicle/src/serde_ext.rs index b1b06d23..0ee041ec 100644 --- a/crates/radicle/src/serde_ext.rs +++ b/crates/radicle/src/serde_ext.rs @@ -56,3 +56,15 @@ where use serde::Deserialize as _; Ok(Option::deserialize(deserializer)?.unwrap_or_default()) } + +/// A helper that makes it easy to use `Option` with the `serde(default)` +/// attribute, in case a default of `Some(T::default())` is desired instead +/// of `None`. +pub(crate) fn some_default() -> Option { + Some(T::default()) +} + +/// Like [`is_default`], but for use in combination with [`some_default`]. +pub(crate) fn is_some_default(t: &Option) -> bool { + t.as_ref() == Some(&T::default()) +} diff --git a/crates/radicle/src/test/arbitrary.rs b/crates/radicle/src/test/arbitrary.rs index d08e0954..449102d0 100644 --- a/crates/radicle/src/test/arbitrary.rs +++ b/crates/radicle/src/test/arbitrary.rs @@ -276,7 +276,12 @@ impl Arbitrary for Timestamp { impl Arbitrary for UserAgent { fn arbitrary(g: &mut qcheck::Gen) -> Self { UserAgent::from_str( - format!("/radicle:1.{}.{}/", u8::arbitrary(g), u8::arbitrary(g)).as_str(), + format!( + "/radicle:1.{}.{}/fake/arbitrary/", + u8::arbitrary(g), + u8::arbitrary(g) + ) + .as_str(), ) .unwrap() }