Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
radicle: Add Version to User Agent
Merged lorenz opened 29 days ago

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.

15 files changed +184 -43 48551cde 22b2871f
modified CHANGELOG.md
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

+
## New Features
+

+
- 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

## New Features
modified 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": [
modified crates/radicle-cli/tests/util/environment.rs
@@ -6,7 +6,7 @@ use radicle::crypto::ssh::{Keystore, keystore::MemorySigner};
use radicle::crypto::{KeyPair, Seed};
use radicle::git;
use radicle::node::policy::store as policy;
-
use radicle::node::{self, UserAgent};
+
use radicle::node::{self};
use radicle::node::{Alias, Config, POLICIES_DB_FILE};
use radicle::profile::Home;
use radicle::profile::{self};
@@ -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(),
            )
modified 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<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};
modified 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,7 @@ impl Runtime {
                        radicle::node::Features::SEED,
                        &alias,
                        0,
-
                        &UserAgent::default(),
+
                        &UserAgent::from_str("/radicle/fake/bootstrap/").expect("valid user agent"),
                        clock.into(),
                        [node::KnownAddress::new(addr, address::Source::Bootstrap)],
                    )?;
modified 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<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(),
modified crates/radicle-node/src/test/peer.rs
@@ -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(),
modified crates/radicle-protocol/src/service/gossip.rs
@@ -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 {
modified 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/fake/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);
    }
}
added crates/radicle/build.rs
@@ -0,0 +1 @@
+
../../build.rs

\ No newline at end of file
modified 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<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());
modified 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::<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
+
        );
+
    }
}
modified 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,8 @@ impl Profile {
            &public_key,
            config.node.features(),
            &config.node.alias,
-
            &UserAgent::default(),
+
            #[allow(deprecated)]
+
            &config.node.user_agent(),
            LocalTime::now().into(),
            config.node.external_addresses.iter(),
        )?;
modified 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<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())
+
}
modified 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()
    }