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:
parent
48551cde93
commit
22b2871f64
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<UserAgent> = 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};
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -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<Messa
|
|||
alias: node::Alias::new(r#gen::string(5)),
|
||||
addresses: None.into(),
|
||||
nonce: 0,
|
||||
agent: UserAgent::from_str("/radicle:test/").unwrap(),
|
||||
agent: UserAgent::test(),
|
||||
}
|
||||
.solve(0)
|
||||
.unwrap(),
|
||||
|
|
|
|||
|
|
@ -320,7 +320,7 @@ where
|
|||
alias: Alias::from_str(self.name).unwrap(),
|
||||
addresses: Some(net::SocketAddr::from((self.ip, node::DEFAULT_PORT)).into()).into(),
|
||||
nonce: 0,
|
||||
agent: UserAgent::from_str("/radicle:test/").unwrap(),
|
||||
agent: UserAgent::test(),
|
||||
}
|
||||
.solve(0)
|
||||
.unwrap(),
|
||||
|
|
|
|||
|
|
@ -1,21 +1,11 @@
|
|||
pub mod store;
|
||||
|
||||
use std::str::FromStr;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use super::*;
|
||||
use crate::bounded::BoundedVec;
|
||||
use radicle::node::PROTOCOL_VERSION;
|
||||
use radicle::node::UserAgent;
|
||||
|
||||
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 {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
../../build.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<Self, Self::Err> {
|
||||
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());
|
||||
|
|
|
|||
|
|
@ -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::<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.
|
||||
#[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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)?;
|
||||
|
|
|
|||
|
|
@ -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<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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue