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.
This commit is contained in:
Lorenz Leutgeb 2026-04-01 11:11:58 +02:00 committed by Fintan Halpenny
parent 48551cde93
commit 22b2871f64
15 changed files with 182 additions and 44 deletions

View File

@ -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`. using `--verbose`, and the full OID when using `--verbose`.
These ranges make using `git range-diff` a lot easier, since you can copy These ranges make using `git range-diff` a lot easier, since you can copy
the range from each revision you want to compare. 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 ## 1.8.0

View File

@ -203,6 +203,17 @@ $ rad config schema
"description": "Node alias.", "description": "Node alias.",
"$ref": "#/$defs/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": { "listen": {
"description": "Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on.", "description": "Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on.",
"type": "array", "type": "array",
@ -329,6 +340,17 @@ $ rad config schema
"description": "Node alias, i.e. a short and memorable name for it.", "description": "Node alias, i.e. a short and memorable name for it.",
"type": "string" "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": { "PeerConfig": {
"description": "Peer configuration.", "description": "Peer configuration.",
"oneOf": [ "oneOf": [

View File

@ -5,11 +5,11 @@ use radicle::cob::cache::COBS_DB_FILE;
use radicle::crypto::ssh::{Keystore, keystore::MemorySigner}; use radicle::crypto::ssh::{Keystore, keystore::MemorySigner};
use radicle::crypto::{KeyPair, Seed}; use radicle::crypto::{KeyPair, Seed};
use radicle::git; use radicle::git;
use radicle::node;
use radicle::node::policy::store as policy; use radicle::node::policy::store as policy;
use radicle::node::{self, UserAgent};
use radicle::node::{Alias, Config, POLICIES_DB_FILE}; use radicle::node::{Alias, Config, POLICIES_DB_FILE};
use radicle::profile;
use radicle::profile::Home; use radicle::profile::Home;
use radicle::profile::{self};
use radicle::storage::git::transport; use radicle::storage::git::transport;
use radicle::{Profile, Storage}; use radicle::{Profile, Storage};
use radicle_localtime::LocalTime; use radicle_localtime::LocalTime;
@ -161,7 +161,7 @@ impl Environment {
&keypair.pk.into(), &keypair.pk.into(),
config.node.features(), config.node.features(),
&alias, &alias,
&UserAgent::default(), &config.node.user_agent(),
LocalTime::now().into(), LocalTime::now().into(),
config.node.external_addresses.iter(), config.node.external_addresses.iter(),
) )

View File

@ -19,15 +19,11 @@ pub mod tests;
extern crate radicle_localtime as localtime; extern crate radicle_localtime as localtime;
use std::str::FromStr;
use std::sync::LazyLock;
use radicle::version::Version; use radicle::version::Version;
pub use localtime::{LocalDuration, LocalTime}; pub use localtime::{LocalDuration, LocalTime};
pub use radicle::node::Link; pub use radicle::node::Link;
pub use radicle::node::PROTOCOL_VERSION; pub use radicle::node::PROTOCOL_VERSION;
pub use radicle::node::UserAgent;
pub use radicle::prelude::Timestamp; pub use radicle::prelude::Timestamp;
pub use radicle::{collections, crypto, git, identity, node, profile, rad, storage}; pub use radicle::{collections, crypto, git, identity, node, profile, rad, storage};
pub use runtime::Runtime; pub use runtime::Runtime;
@ -40,12 +36,6 @@ pub const VERSION: Version = Version {
timestamp: env!("SOURCE_DATE_EPOCH"), timestamp: env!("SOURCE_DATE_EPOCH"),
}; };
/// This node's user agent string.
pub static USER_AGENT: LazyLock<UserAgent> = LazyLock::new(|| {
FromStr::from_str(format!("/radicle:{}/", VERSION.version).as_str())
.expect("user agent is valid")
});
pub mod prelude { pub mod prelude {
pub use crate::crypto::{PublicKey, Signature}; pub use crate::crypto::{PublicKey, Signature};
pub use crate::identity::{Did, RepoId}; pub use crate::identity::{Did, RepoId};

View File

@ -3,6 +3,7 @@ pub mod thread;
use std::fmt::Debug; use std::fmt::Debug;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr as _;
use std::{fs, io, net}; use std::{fs, io, net};
#[cfg(unix)] #[cfg(unix)]
@ -197,7 +198,8 @@ impl Runtime {
radicle::node::Features::SEED, radicle::node::Features::SEED,
&alias, &alias,
0, 0,
&UserAgent::default(), &UserAgent::from_str("/radicle/runtime/bootstrap/")
.expect("valid user agent"),
clock.into(), clock.into(),
[node::KnownAddress::new(addr, address::Source::Bootstrap)], [node::KnownAddress::new(addr, address::Source::Bootstrap)],
)?; )?;

View File

@ -1,5 +1,3 @@
use std::str::FromStr;
use radicle::node; use radicle::node;
use radicle::node::UserAgent; use radicle::node::UserAgent;
use radicle::node::device::Device; use radicle::node::device::Device;
@ -34,7 +32,7 @@ pub fn messages(count: usize, now: LocalTime, delta: LocalDuration) -> Vec<Messa
alias: node::Alias::new(r#gen::string(5)), alias: node::Alias::new(r#gen::string(5)),
addresses: None.into(), addresses: None.into(),
nonce: 0, nonce: 0,
agent: UserAgent::from_str("/radicle:test/").unwrap(), agent: UserAgent::test(),
} }
.solve(0) .solve(0)
.unwrap(), .unwrap(),

View File

@ -320,7 +320,7 @@ where
alias: Alias::from_str(self.name).unwrap(), alias: Alias::from_str(self.name).unwrap(),
addresses: Some(net::SocketAddr::from((self.ip, node::DEFAULT_PORT)).into()).into(), addresses: Some(net::SocketAddr::from((self.ip, node::DEFAULT_PORT)).into()).into(),
nonce: 0, nonce: 0,
agent: UserAgent::from_str("/radicle:test/").unwrap(), agent: UserAgent::test(),
} }
.solve(0) .solve(0)
.unwrap(), .unwrap(),

View File

@ -1,21 +1,11 @@
pub mod store; pub mod store;
use std::str::FromStr;
use std::sync::LazyLock;
use super::*; use super::*;
use crate::bounded::BoundedVec; use crate::bounded::BoundedVec;
use radicle::node::PROTOCOL_VERSION; use radicle::node::PROTOCOL_VERSION;
use radicle::node::UserAgent;
pub use store::{AnnouncementId, Error, RelayStatus, Store}; pub use store::{AnnouncementId, Error, RelayStatus, Store};
/// This node's user agent string.
pub static PROTOCOL_VERSION_STRING: LazyLock<UserAgent> = LazyLock::new(|| {
FromStr::from_str(format!("/radicle:{PROTOCOL_VERSION}/").as_str())
.expect("user agent is valid")
});
pub fn node(config: &Config, timestamp: Timestamp) -> NodeAnnouncement { pub fn node(config: &Config, timestamp: Timestamp) -> NodeAnnouncement {
let features = config.features(); let features = config.features();
let alias = config.alias.clone(); let alias = config.alias.clone();
@ -24,7 +14,9 @@ pub fn node(config: &Config, timestamp: Timestamp) -> NodeAnnouncement {
.clone() .clone()
.try_into() .try_into()
.expect("external addresses are within the limit"); .expect("external addresses are within the limit");
let agent = PROTOCOL_VERSION_STRING.clone();
let agent = config.user_agent();
let version = PROTOCOL_VERSION; let version = PROTOCOL_VERSION;
NodeAnnouncement { NodeAnnouncement {

View File

@ -1,3 +1,4 @@
use std::str::FromStr;
use std::{fmt, mem}; use std::{fmt, mem};
use bytes::{Buf, BufMut}; use bytes::{Buf, BufMut};
@ -142,7 +143,9 @@ impl wire::Decode for NodeAnnouncement {
let nonce = u64::decode(buf)?; let nonce = u64::decode(buf)?;
let agent = match UserAgent::decode(buf) { let agent = match UserAgent::decode(buf) {
Ok(ua) => ua, 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), Err(e) => return Err(e),
}; };
@ -683,7 +686,6 @@ impl qcheck::Arbitrary for ZeroBytes {
#[cfg(test)] #[cfg(test)]
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
mod tests { mod tests {
use std::str::FromStr;
use fastrand; use fastrand;
use localtime::LocalTime; use localtime::LocalTime;
@ -784,12 +786,12 @@ mod tests {
alias: Alias::new("alice"), alias: Alias::new("alice"),
addresses: BoundedVec::new(), addresses: BoundedVec::new(),
nonce: 0, 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(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); assert_eq!(ann.solve(14).unwrap().work(), 14);
} }
} }

1
crates/radicle/build.rs Symbolic link
View File

@ -0,0 +1 @@
../../build.rs

View File

@ -220,18 +220,56 @@ impl PartialOrd for SyncStatus {
/// Node user agent. /// Node user agent.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Serialize, Deserialize)] #[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 { impl UserAgent {
const LEN_MAX: usize = 64;
/// Return a reference to the user agent string. /// Return a reference to the user agent string.
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
self.0.as_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 { impl Default for UserAgent {
fn default() -> Self { 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<Self, Self::Err> { fn from_str(input: &str) -> Result<Self, Self::Err> {
let reserved = ['/', ':']; let reserved = ['/', ':'];
if input.len() > 64 { if input.len() > UserAgent::LEN_MAX {
return Err(input.to_owned()); return Err(input.to_owned());
} }
let Some(s) = input.strip_prefix('/') else { let Some(s) = input.strip_prefix('/') else {
@ -1502,6 +1540,7 @@ mod test {
assert!(UserAgent::from_str("/radicle/").is_ok()); assert!(UserAgent::from_str("/radicle/").is_ok());
assert!(UserAgent::from_str("/rad/icle/").is_ok()); assert!(UserAgent::from_str("/rad/icle/").is_ok());
assert!(UserAgent::from_str("/rad:ic/le/").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());
assert!(UserAgent::from_str("//").is_err()); assert!(UserAgent::from_str("//").is_err());

View File

@ -8,8 +8,8 @@ use localtime::LocalDuration;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json as json; use serde_json as json;
use crate::node;
use crate::node::policy::SeedingPolicy; use crate::node::policy::SeedingPolicy;
use crate::node::{self, UserAgent};
use crate::node::{Address, Alias, NodeId}; use crate::node::{Address, Alias, NodeId};
use crate::storage::refs::FeatureLevel; use crate::storage::refs::FeatureLevel;
@ -530,6 +530,12 @@ impl Fetch {
pub struct Config { pub struct Config {
/// Node alias. /// Node alias.
pub alias: 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::<UserAgent>",
skip_serializing_if = "crate::serde_ext::is_some_default"
)]
pub user_agent: Option<UserAgent>,
/// Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on. /// Socket address (a combination of IPv4 or IPv6 address and TCP port) to listen on.
#[serde(default)] #[serde(default)]
#[cfg_attr(feature = "schemars", schemars(example = &"127.0.0.1:8776"))] #[cfg_attr(feature = "schemars", schemars(example = &"127.0.0.1:8776"))]
@ -603,6 +609,7 @@ impl Config {
pub fn new(alias: Alias) -> Self { pub fn new(alias: Alias) -> Self {
Self { Self {
alias, alias,
user_agent: Some(UserAgent::default()),
peers: PeerConfig::default(), peers: PeerConfig::default(),
listen: vec![], listen: vec![],
connect: HashSet::default(), connect: HashSet::default(),
@ -652,6 +659,15 @@ impl Config {
pub fn features(&self) -> node::Features { pub fn features(&self) -> node::Features {
node::Features::SEED 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)] #[derive(Clone, Copy, Debug, Display, Deserialize, Serialize, From)]
@ -781,7 +797,7 @@ wrapper!(
#[allow(clippy::unwrap_used)] #[allow(clippy::unwrap_used)]
mod test { mod test {
use super::{DefaultSeedingPolicy, Scope}; use super::{DefaultSeedingPolicy, Scope};
use crate::node::{Alias, policy}; use crate::node::{Alias, UserAgent, policy};
use serde_json::json; use serde_json::json;
#[test] #[test]
@ -974,4 +990,56 @@ mod test {
.unwrap(); .unwrap();
assert_eq!(super::AddressConfig::Drop, actual.onion); 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
);
}
} }

View File

@ -28,9 +28,7 @@ use crate::crypto::ssh::agent::Agent;
use crate::crypto::ssh::{Keystore, Passphrase, keystore}; use crate::crypto::ssh::{Keystore, Passphrase, keystore};
use crate::node::device::{BoxedDevice, Device}; use crate::node::device::{BoxedDevice, Device};
use crate::node::policy::config::store::Read; use crate::node::policy::config::store::Read;
use crate::node::{ use crate::node::{Alias, AliasStore, Handle as _, Node, notifications, policy, policy::Scope};
Alias, AliasStore, Handle as _, Node, UserAgent, notifications, policy, policy::Scope,
};
use crate::prelude::{Did, NodeId, RepoId}; use crate::prelude::{Did, NodeId, RepoId};
use crate::storage::ReadRepository; use crate::storage::ReadRepository;
use crate::storage::git::Storage; use crate::storage::git::Storage;
@ -257,7 +255,7 @@ impl Profile {
&public_key, &public_key,
config.node.features(), config.node.features(),
&config.node.alias, &config.node.alias,
&UserAgent::default(), &config.node.user_agent(),
LocalTime::now().into(), LocalTime::now().into(),
config.node.external_addresses.iter(), config.node.external_addresses.iter(),
)?; )?;

View File

@ -56,3 +56,15 @@ where
use serde::Deserialize as _; use serde::Deserialize as _;
Ok(Option::deserialize(deserializer)?.unwrap_or_default()) Ok(Option::deserialize(deserializer)?.unwrap_or_default())
} }
/// A helper that makes it easy to use `Option<T>` with the `serde(default)`
/// attribute, in case a default of `Some(T::default())` is desired instead
/// of `None`.
pub(crate) fn some_default<T: Default>() -> Option<T> {
Some(T::default())
}
/// Like [`is_default`], but for use in combination with [`some_default`].
pub(crate) fn is_some_default<T: Default + PartialEq>(t: &Option<T>) -> bool {
t.as_ref() == Some(&T::default())
}

View File

@ -276,7 +276,12 @@ impl Arbitrary for Timestamp {
impl Arbitrary for UserAgent { impl Arbitrary for UserAgent {
fn arbitrary(g: &mut qcheck::Gen) -> Self { fn arbitrary(g: &mut qcheck::Gen) -> Self {
UserAgent::from_str( 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() .unwrap()
} }