Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
node: Add agent and version to node announcement
Merged did:key:z6MksFqX...wzpT opened 1 year ago
  1. Adds a user-agent string to the node announcement. This lets us keep track of what software version everyone is running. Especially useful to see what percentage of the network has upgraded to a new release.

For old nodes, this defaults to the string /radicle/.

The user agent format is roughly based on BIP 0014.

  1. Advertize protocol version in node announcement.

By advertizing the protocol version, nodes can decide to only connect to peers with a compatible version. The default and current version is 1.

We bundle these two changes as they both modify the node announcement and node database.

24 files changed +525 -114 5c0d1b10 83786fbd
modified radicle-cli/tests/commands.rs
@@ -8,6 +8,7 @@ use radicle::node::address::Store as _;
use radicle::node::config::seeds::{RADICLE_COMMUNITY_NODE, RADICLE_TEAM_NODE};
use radicle::node::routing::Store as _;
use radicle::node::Handle as _;
+
use radicle::node::UserAgent;
use radicle::node::{Address, Alias, DEFAULT_TIMEOUT};
use radicle::prelude::{NodeId, RepoId};
use radicle::profile;
@@ -21,6 +22,7 @@ use radicle_node::service::Event;
use radicle_node::test::environment::{Config, Environment, Node};
#[allow(unused_imports)]
use radicle_node::test::logger;
+
use radicle_node::PROTOCOL_VERSION;

/// Seed used in tests.
const RAD_SEED: &str = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff";
@@ -1317,9 +1319,11 @@ fn rad_clone_partial_fail() {
        .addresses_mut()
        .insert(
            &carol,
+
            PROTOCOL_VERSION,
            node::Features::SEED,
-
            Alias::new("carol"),
+
            &Alias::new("carol"),
            0,
+
            &UserAgent::default(),
            localtime::LocalTime::now().into(),
            [node::KnownAddress::new(
                // Eve will fail to connect to this address.
@@ -1363,6 +1367,7 @@ fn rad_clone_connect() {
    let bob = environment.node(Config::test(Alias::new("bob")));
    let mut eve = environment.node(Config::test(Alias::new("eve")));
    let acme = RepoId::from_str("z42hL2jL4XNk6K8oHQaSWfMgCL7ji").unwrap();
+
    let ua = UserAgent::default();
    let now = localtime::LocalTime::now().into();

    fixtures::repository(working.join("acme"));
@@ -1383,9 +1388,11 @@ fn rad_clone_connect() {
        .addresses_mut()
        .insert(
            &alice.id,
+
            PROTOCOL_VERSION,
            node::Features::SEED,
-
            Alias::new("alice"),
+
            &Alias::new("alice"),
            0,
+
            &ua,
            now,
            [node::KnownAddress::new(
                node::Address::from(alice.addr),
@@ -1397,9 +1404,11 @@ fn rad_clone_connect() {
        .addresses_mut()
        .insert(
            &bob.id,
+
            PROTOCOL_VERSION,
            node::Features::SEED,
-
            Alias::new("bob"),
+
            &Alias::new("bob"),
            0,
+
            &ua,
            now,
            [node::KnownAddress::new(
                node::Address::from(bob.addr),
modified radicle-node/src/lib.rs
@@ -10,12 +10,23 @@ pub mod tests;
pub mod wire;
pub mod worker;

+
use radicle::version::Version;
+

pub use localtime::{LocalDuration, LocalTime};
pub use netservices::Direction as Link;
+
pub use radicle::node::PROTOCOL_VERSION;
pub use radicle::prelude::Timestamp;
pub use radicle::{collections, crypto, git, identity, node, profile, rad, storage};
pub use runtime::Runtime;

+
/// Node version.
+
pub const VERSION: Version = Version {
+
    name: env!("CARGO_PKG_NAME"),
+
    commit: env!("GIT_HEAD"),
+
    version: env!("RADICLE_VERSION"),
+
    timestamp: env!("GIT_COMMIT_TIME"),
+
};
+

pub mod prelude {
    pub use crate::bounded::BoundedVec;
    pub use crate::crypto::{PublicKey, Signature, Signer};
modified radicle-node/src/main.rs
@@ -7,18 +7,10 @@ use crossbeam_channel as chan;
use radicle::logger;
use radicle::prelude::Signer;
use radicle::profile;
-
use radicle::version::Version;
use radicle_node::crypto::ssh::keystore::{Keystore, MemorySigner};
-
use radicle_node::Runtime;
+
use radicle_node::{Runtime, VERSION};
use radicle_signals as signals;

-
pub const VERSION: Version = Version {
-
    name: env!("CARGO_PKG_NAME"),
-
    commit: env!("GIT_HEAD"),
-
    version: env!("RADICLE_VERSION"),
-
    timestamp: env!("GIT_COMMIT_TIME"),
-
};
-

pub const HELP_MSG: &str = r#"
Usage

modified radicle-node/src/runtime.rs
@@ -19,6 +19,7 @@ use radicle::node::address;
use radicle::node::address::Store as _;
use radicle::node::notifications;
use radicle::node::Handle as _;
+
use radicle::node::UserAgent;
use radicle::profile::Home;
use radicle::{cob, git, storage, Storage};

@@ -127,7 +128,6 @@ impl Runtime {
        for (key, _) in &config.extra {
            log::warn!(target: "node", "Unused or deprecated configuration attribute {:?}", key);
        }
-
        log::info!(target: "node", "Opening node database..");

        log::info!(target: "node", "Opening policy database..");
        let policies = home.policies_mut()?;
@@ -174,13 +174,15 @@ impl Runtime {
                .expect("Runtime::init: unable to solve proof-of-work puzzle")
        };

+
        log::info!(target: "node", "Opening node database..");
        let db = home
            .database_mut()?
            .journal_mode(node::db::JournalMode::default())?
            .init(
                &id,
                announcement.features,
-
                announcement.alias.clone(),
+
                &announcement.alias,
+
                &announcement.agent,
                announcement.timestamp,
                announcement.addresses.iter(),
            )?;
@@ -189,14 +191,16 @@ impl Runtime {
        if config.connect.is_empty() && stores.addresses().is_empty()? {
            log::info!(target: "node", "Address book is empty. Adding bootstrap nodes..");

-
            for (alias, addr) in config.network.bootstrap() {
+
            for (alias, version, addr) in config.network.bootstrap() {
                let (id, addr) = addr.into();

                stores.addresses_mut().insert(
                    &id,
+
                    version,
                    radicle::node::Features::SEED,
-
                    alias,
+
                    &alias,
                    0,
+
                    &UserAgent::default(),
                    clock.into(),
                    [node::KnownAddress::new(addr, address::Source::Bootstrap)],
                )?;
modified radicle-node/src/service.rs
@@ -36,7 +36,6 @@ use radicle::storage::refs::SIGREFS_BRANCH;
use radicle::storage::RepositoryError;
use radicle_fetch::policy::SeedingPolicy;

-
use crate::crypto;
use crate::crypto::{Signer, Verified};
use crate::identity::{Doc, RepoId};
use crate::node::routing;
@@ -56,6 +55,7 @@ use crate::storage::{refs::RefsAt, Namespaces, ReadStorage};
use crate::worker::fetch;
use crate::worker::FetchError;
use crate::Link;
+
use crate::{crypto, PROTOCOL_VERSION};

pub use crate::node::events::{Event, Events};
pub use crate::node::{config::Network, Config, NodeId};
@@ -1693,9 +1693,11 @@ where

                match self.db.addresses_mut().insert(
                    announcer,
-
                    *features,
-
                    ann.alias.clone(),
+
                    ann.version,
+
                    ann.features,
+
                    &ann.alias,
                    ann.work(),
+
                    &ann.agent,
                    timestamp,
                    addresses
                        .iter()
@@ -2380,6 +2382,7 @@ where
                // Nb. we don't want to connect to any peers that already have a session with us,
                // even if it's in a disconnected state. Those sessions are re-attempted automatically.
                let mut peers = entries
+
                    .filter(|entry| entry.version == PROTOCOL_VERSION)
                    .filter(|entry| !entry.address.banned)
                    .filter(|entry| !entry.penalty.is_connect_threshold_reached())
                    .filter(|entry| !self.sessions.contains_key(&entry.node))
modified radicle-node/src/service/gossip.rs
@@ -1,9 +1,20 @@
pub mod store;

+
use std::str::FromStr;
+

use super::*;
+
use crate::{PROTOCOL_VERSION, VERSION};
+
use once_cell::sync::Lazy;
+
use radicle::node::UserAgent;

pub use store::{AnnouncementId, Error, RelayStatus, Store};

+
/// This node's user agent string.
+
pub static USER_AGENT: Lazy<UserAgent> = Lazy::new(|| {
+
    FromStr::from_str(format!("/radicle:{}/", VERSION.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();
@@ -12,13 +23,17 @@ pub fn node(config: &Config, timestamp: Timestamp) -> NodeAnnouncement {
        .clone()
        .try_into()
        .expect("external addresses are within the limit");
+
    let agent = USER_AGENT.clone();
+
    let version = PROTOCOL_VERSION;

    NodeAnnouncement {
        features,
+
        version,
        timestamp,
        alias,
        addresses,
        nonce: 0,
+
        agent,
    }
}

modified radicle-node/src/service/message.rs
@@ -7,7 +7,7 @@ use radicle::storage::refs::RefsAt;
use crate::crypto;
use crate::identity::RepoId;
use crate::node;
-
use crate::node::{Address, Alias};
+
use crate::node::{Address, Alias, UserAgent};
use crate::prelude::BoundedVec;
use crate::service::filter::Filter;
use crate::service::{Link, NodeId, Timestamp};
@@ -54,6 +54,8 @@ impl Subscribe {
/// Node announcing itself to the network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NodeAnnouncement {
+
    /// Supported protocol version.
+
    pub version: u8,
    /// Advertized features.
    pub features: node::Features,
    /// Monotonic timestamp.
@@ -64,6 +66,8 @@ pub struct NodeAnnouncement {
    pub addresses: BoundedVec<Address, ADDRESS_LIMIT>,
    /// Nonce used for announcement proof-of-work.
    pub nonce: u64,
+
    /// User-agent string.
+
    pub agent: UserAgent,
}

impl NodeAnnouncement {
@@ -125,11 +129,13 @@ impl wire::Encode for NodeAnnouncement {
    fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<usize, io::Error> {
        let mut n = 0;

+
        n += self.version.encode(writer)?;
        n += self.features.encode(writer)?;
        n += self.timestamp.encode(writer)?;
        n += self.alias.encode(writer)?;
        n += self.addresses.encode(writer)?;
        n += self.nonce.encode(writer)?;
+
        n += self.agent.encode(writer)?;

        Ok(n)
    }
@@ -137,18 +143,26 @@ impl wire::Encode for NodeAnnouncement {

impl wire::Decode for NodeAnnouncement {
    fn decode<R: std::io::Read + ?Sized>(reader: &mut R) -> Result<Self, wire::Error> {
+
        let version = u8::decode(reader)?;
        let features = node::Features::decode(reader)?;
        let timestamp = Timestamp::decode(reader)?;
        let alias = wire::Decode::decode(reader)?;
        let addresses = BoundedVec::<Address, ADDRESS_LIMIT>::decode(reader)?;
        let nonce = u64::decode(reader)?;
+
        let agent = match UserAgent::decode(reader) {
+
            Ok(ua) => ua,
+
            Err(e) if e.is_eof() => UserAgent::default(),
+
            Err(e) => return Err(e),
+
        };

        Ok(Self {
+
            version,
            features,
            timestamp,
            alias,
            addresses,
            nonce,
+
            agent,
        })
    }
}
@@ -330,10 +344,10 @@ impl fmt::Debug for AnnouncementMessage {
pub struct Announcement {
    /// Node identifier.
    pub node: NodeId,
-
    /// Unsigned node announcement.
-
    pub message: AnnouncementMessage,
    /// Signature over the announcement.
    pub signature: crypto::Signature,
+
    /// Unsigned node announcement.
+
    pub message: AnnouncementMessage,
}

impl Announcement {
@@ -576,16 +590,18 @@ impl ZeroBytes {
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
-
    use super::*;
-
    use crate::prelude::*;
-
    use crate::wire::Encode;
+
    use std::str::FromStr;

-
    use crate::crypto::test::signer::MockSigner;
-
    use crate::test::arbitrary;
    use fastrand;
    use qcheck_macros::quickcheck;
    use radicle::git::raw;

+
    use super::*;
+
    use crate::crypto::test::signer::MockSigner;
+
    use crate::prelude::*;
+
    use crate::test::arbitrary;
+
    use crate::wire::Encode;
+

    #[test]
    fn test_ref_remote_limit() {
        let mut refs = BoundedVec::<_, REF_REMOTE_LIMIT>::new();
@@ -672,16 +688,18 @@ mod tests {
    #[test]
    fn test_node_announcement_validate() {
        let ann = NodeAnnouncement {
+
            version: 1,
            features: node::Features::SEED,
            timestamp: Timestamp::try_from(42491841u64).unwrap(),
            alias: Alias::new("alice"),
            addresses: BoundedVec::new(),
            nonce: 0,
+
            agent: UserAgent::from_str("/heartwood:1.0.0/").unwrap(),
        };

-
        assert_eq!(ann.work(), 0);
-
        assert_eq!(ann.clone().solve(1).unwrap().work(), 4);
-
        assert_eq!(ann.clone().solve(8).unwrap().work(), 9);
+
        assert_eq!(ann.work(), 1);
+
        assert_eq!(ann.clone().solve(1).unwrap().work(), 1);
+
        assert_eq!(ann.clone().solve(8).unwrap().work(), 10);
        assert_eq!(ann.solve(14).unwrap().work(), 14);
    }
}
modified radicle-node/src/test/arbitrary.rs
@@ -2,6 +2,7 @@ use std::collections::HashSet;

use bloomy::BloomFilter;
use qcheck::Arbitrary;
+
use radicle::node::UserAgent;

use crate::crypto;
use crate::identity::DocAt;
@@ -80,11 +81,13 @@ impl Arbitrary for Message {
            .into(),
            MessageType::NodeAnnouncement => {
                let message = NodeAnnouncement {
+
                    version: u8::arbitrary(g),
                    features: u64::arbitrary(g).into(),
                    timestamp: Timestamp::arbitrary(g),
                    alias: Alias::arbitrary(g),
                    addresses: Arbitrary::arbitrary(g),
                    nonce: u64::arbitrary(g),
+
                    agent: UserAgent::arbitrary(g),
                }
                .into();
                let bytes: [u8; 64] = Arbitrary::arbitrary(g);
modified radicle-node/src/test/environment.rs
@@ -20,8 +20,7 @@ use radicle::identity::{RepoId, Visibility};
use radicle::node::config::ConnectAddress;
use radicle::node::policy::store as policy;
use radicle::node::seed::Store as _;
-
use radicle::node::Database;
-
use radicle::node::{Alias, POLICIES_DB_FILE};
+
use radicle::node::{Alias, Database, UserAgent, POLICIES_DB_FILE};
use radicle::node::{ConnectOptions, Handle as _};
use radicle::profile;
use radicle::profile::{env, Home, Profile};
@@ -134,7 +133,8 @@ impl Environment {
            .init(
                &public_key,
                config.node.features(),
-
                Alias::new(alias),
+
                &Alias::new(alias),
+
                &UserAgent::default(),
                now.into(),
                config.node.external_addresses.iter(),
            )
modified radicle-node/src/test/gossip.rs
@@ -1,11 +1,15 @@
+
use std::str::FromStr;
+

use radicle::crypto::test::signer::MockSigner;
use radicle::node;
+
use radicle::node::UserAgent;
use radicle::test::fixtures::gen;

use crate::test::arbitrary;
use crate::{
    prelude::{LocalDuration, LocalTime, Message},
    service::message::{InventoryAnnouncement, NodeAnnouncement},
+
    PROTOCOL_VERSION,
};

pub fn messages(count: usize, now: LocalTime, delta: LocalDuration) -> Vec<Message> {
@@ -28,11 +32,13 @@ pub fn messages(count: usize, now: LocalTime, delta: LocalDuration) -> Vec<Messa

        msgs.push(Message::node(
            NodeAnnouncement {
+
                version: PROTOCOL_VERSION,
                features: node::Features::SEED,
                timestamp: time.into(),
                alias: node::Alias::new(gen::string(5)),
                addresses: None.into(),
                nonce: 0,
+
                agent: UserAgent::from_str("/radicle:test/").unwrap(),
            }
            .solve(0)
            .unwrap(),
modified radicle-node/src/test/peer.rs
@@ -10,6 +10,7 @@ use log::*;
use radicle::identity::Visibility;
use radicle::node::address::Store as _;
use radicle::node::Database;
+
use radicle::node::UserAgent;
use radicle::node::{address, Alias, ConnectOptions};
use radicle::rad;
use radicle::storage::refs::{RefsAt, SignedRefsAt};
@@ -33,8 +34,7 @@ use crate::storage::{RemoteId, WriteStorage};
use crate::test::storage::MockStorage;
use crate::test::{arbitrary, fixtures, simulator};
use crate::wire::MessageType;
-
use crate::Link;
-
use crate::{LocalDuration, LocalTime};
+
use crate::{Link, LocalDuration, LocalTime, PROTOCOL_VERSION};

/// Service instantiation used for testing.
pub type Service<S, G> = service::Service<Database, S, G>;
@@ -174,7 +174,8 @@ where
            .init(
                &id,
                config.config.features(),
-
                config.config.alias.clone(),
+
                &config.config.alias,
+
                &UserAgent::default(),
                config.local_time.into(),
                config.config.external_addresses.iter(),
            )
@@ -251,9 +252,11 @@ where
                .addresses_mut()
                .insert(
                    &peer.node_id(),
+
                    PROTOCOL_VERSION,
                    radicle::node::Features::default(),
-
                    Alias::from_str(peer.name).unwrap(),
+
                    &Alias::from_str(peer.name).unwrap(),
                    0,
+
                    &UserAgent::default(),
                    timestamp,
                    Some(known_address),
                )
@@ -303,11 +306,13 @@ where
    pub fn node_announcement(&self) -> Message {
        Message::node(
            NodeAnnouncement {
+
                version: PROTOCOL_VERSION,
                features: node::Features::SEED,
                timestamp: self.timestamp(),
                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(),
            }
            .solve(0)
            .unwrap(),
modified radicle-node/src/wire.rs
@@ -6,6 +6,7 @@ mod varint;
pub use frame::StreamId;
pub use message::{AddressType, MessageType};
pub use protocol::{Control, Wire, WireReader, WireSession, WireWriter};
+
use radicle::node::UserAgent;

use std::collections::BTreeMap;
use std::convert::TryFrom;
@@ -54,6 +55,8 @@ pub enum Error {
    InvalidRefName(#[from] fmt::Error),
    #[error(transparent)]
    InvalidAlias(#[from] node::AliasError),
+
    #[error("invalid user agent string: {0:?}")]
+
    InvalidUserAgent(String),
    #[error("invalid control message with type `{0}`")]
    InvalidControlMessage(u8),
    #[error("invalid protocol version header `{0:x?}`")]
@@ -62,8 +65,8 @@ pub enum Error {
    InvalidOnionAddr(#[from] tor::OnionAddrDecodeError),
    #[error("invalid timestamp: {0}")]
    InvalidTimestamp(u64),
-
    #[error("unknown protocol version `{0}`")]
-
    UnknownProtocolVersion(u8),
+
    #[error("wrong protocol version `{0}`")]
+
    WrongProtocolVersion(u8),
    #[error("unknown address type `{0}`")]
    UnknownAddressType(u8),
    #[error("unknown message type `{0}`")]
@@ -251,6 +254,12 @@ impl Encode for cyphernet::addr::tor::OnionAddrV3 {
    }
}

+
impl Encode for UserAgent {
+
    fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<usize, io::Error> {
+
        self.as_ref().encode(writer)
+
    }
+
}
+

impl Encode for Alias {
    fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<usize, io::Error> {
        self.as_ref().encode(writer)
@@ -321,6 +330,13 @@ impl Decode for git::RefString {
    }
}

+
impl Decode for UserAgent {
+
    fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, Error> {
+
        String::decode(reader)
+
            .and_then(|s| UserAgent::from_str(&s).map_err(Error::InvalidUserAgent))
+
    }
+
}
+

impl Decode for Alias {
    fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, Error> {
        String::decode(reader).and_then(|s| Alias::from_str(&s).map_err(Error::from))
modified radicle-node/src/wire/frame.rs
@@ -2,10 +2,8 @@
#![warn(clippy::missing_docs_in_private_items)]
use std::{fmt, io};

-
use crate::{wire, wire::varint, wire::varint::VarInt, wire::Message, Link};
+
use crate::{wire, wire::varint, wire::varint::VarInt, wire::Message, Link, PROTOCOL_VERSION};

-
/// Protocol version.
-
pub const PROTOCOL_VERSION: u8 = 1;
/// Protocol version strings all start with the magic sequence `rad`, followed
/// by a version number.
pub const PROTOCOL_VERSION_STRING: Version = Version([b'r', b'a', b'd', PROTOCOL_VERSION]);
@@ -193,16 +191,16 @@ impl TryFrom<u8> for StreamKind {
/// |                     Data                                   ...| Data (variable size)
/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
#[derive(Debug, PartialEq, Eq)]
-
pub struct Frame {
+
pub struct Frame<M = Message> {
    /// The protocol version.
    pub version: Version,
    /// The stream identifier.
    pub stream: StreamId,
    /// The frame payload.
-
    pub data: FrameData,
+
    pub data: FrameData<M>,
}

-
impl Frame {
+
impl<M> Frame<M> {
    /// Create a 'git' protocol frame.
    pub fn git(stream: StreamId, data: Vec<u8>) -> Self {
        Self {
@@ -222,14 +220,16 @@ impl Frame {
    }

    /// Create a 'gossip' protocol frame.
-
    pub fn gossip(link: Link, msg: Message) -> Self {
+
    pub fn gossip(link: Link, msg: M) -> Self {
        Self {
            version: PROTOCOL_VERSION_STRING,
            stream: StreamId::gossip(link),
            data: FrameData::Gossip(msg),
        }
    }
+
}

+
impl<M: wire::Encode> Frame<M> {
    /// Serialize frame to bytes.
    pub fn to_bytes(&self) -> Vec<u8> {
        wire::serialize(self)
@@ -238,11 +238,11 @@ impl Frame {

/// Frame payload.
#[derive(Debug, PartialEq, Eq)]
-
pub enum FrameData {
+
pub enum FrameData<M> {
    /// Control frame payload.
    Control(Control),
    /// Gossip frame payload.
-
    Gossip(Message),
+
    Gossip(M),
    /// Git frame payload. May contain packet-lines as well as packfile data.
    Git(Vec<u8>),
}
@@ -312,11 +312,11 @@ impl wire::Encode for Control {
    }
}

-
impl wire::Decode for Frame {
+
impl<M: wire::Decode> wire::Decode for Frame<M> {
    fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, wire::Error> {
        let version = Version::decode(reader)?;
-
        if version.number() > PROTOCOL_VERSION {
-
            return Err(wire::Error::UnknownProtocolVersion(version.number()));
+
        if version.number() != PROTOCOL_VERSION {
+
            return Err(wire::Error::WrongProtocolVersion(version.number()));
        }
        let stream = StreamId::decode(reader)?;

@@ -333,7 +333,7 @@ impl wire::Decode for Frame {
            Ok(StreamKind::Gossip) => {
                let data = varint::payload::decode(reader)?;
                let mut cursor = io::Cursor::new(data);
-
                let msg = Message::decode(&mut cursor)?;
+
                let msg = M::decode(&mut cursor)?;
                let frame = Frame {
                    version,
                    stream,
@@ -354,7 +354,7 @@ impl wire::Decode for Frame {
    }
}

-
impl wire::Encode for Frame {
+
impl<M: wire::Encode> wire::Encode for Frame<M> {
    fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<usize, io::Error> {
        let mut n = 0;

modified radicle-node/src/wire/message.rs
@@ -274,8 +274,8 @@ impl wire::Encode for Message {
                signature,
            }) => {
                n += node.encode(writer)?;
-
                n += message.encode(writer)?;
                n += signature.encode(writer)?;
+
                n += message.encode(writer)?;
            }
            Self::Info(info) => {
                n += info.encode(writer)?;
@@ -317,8 +317,8 @@ impl wire::Decode for Message {
            }
            Ok(MessageType::NodeAnnouncement) => {
                let node = NodeId::decode(reader)?;
-
                let message = NodeAnnouncement::decode(reader)?.into();
                let signature = Signature::decode(reader)?;
+
                let message = NodeAnnouncement::decode(reader)?.into();

                Ok(Announcement {
                    node,
@@ -329,8 +329,8 @@ impl wire::Decode for Message {
            }
            Ok(MessageType::InventoryAnnouncement) => {
                let node = NodeId::decode(reader)?;
-
                let message = InventoryAnnouncement::decode(reader)?.into();
                let signature = Signature::decode(reader)?;
+
                let message = InventoryAnnouncement::decode(reader)?.into();

                Ok(Announcement {
                    node,
@@ -341,8 +341,8 @@ impl wire::Decode for Message {
            }
            Ok(MessageType::RefsAnnouncement) => {
                let node = NodeId::decode(reader)?;
-
                let message = RefsAnnouncement::decode(reader)?.into();
                let signature = Signature::decode(reader)?;
+
                let message = RefsAnnouncement::decode(reader)?.into();

                Ok(Announcement {
                    node,
@@ -458,6 +458,7 @@ impl wire::Decode for ZeroBytes {
mod tests {
    use super::*;
    use qcheck_macros::quickcheck;
+
    use radicle::node::UserAgent;
    use radicle::storage::refs::RefsAt;
    use radicle_crypto::test::signer::MockSigner;

@@ -502,11 +503,13 @@ mod tests {
        let addrs: [Address; ADDRESS_LIMIT] = arbitrary::gen(1);
        let alias = ['@'; radicle::node::MAX_ALIAS_LENGTH];
        let ann = AnnouncementMessage::Node(NodeAnnouncement {
+
            version: 1,
            features: Default::default(),
            alias: radicle::node::Alias::new(String::from_iter(alias)),
            addresses: BoundedVec::collect_from(&mut addrs.into_iter()),
            timestamp: arbitrary::gen(1),
            nonce: u64::MAX,
+
            agent: UserAgent::default(),
        });
        let ann = ann.signed(&signer);
        let msg = Message::Announcement(ann);
@@ -555,10 +558,10 @@ mod tests {

    #[quickcheck]
    fn prop_message_encode_decode(message: Message) {
-
        assert_eq!(
-
            wire::deserialize::<Message>(&wire::serialize(&message)).unwrap(),
-
            message
-
        );
+
        let encoded = &wire::serialize(&message);
+
        let decoded = wire::deserialize::<Message>(encoded).unwrap();
+

+
        assert_eq!(message, decoded);
    }

    #[test]
modified radicle-node/src/wire/protocol.rs
@@ -31,7 +31,6 @@ use crate::service;
use crate::service::io::Io;
use crate::service::FETCH_TIMEOUT;
use crate::service::{session, DisconnectReason, Metrics, Service};
-
use crate::wire;
use crate::wire::frame;
use crate::wire::frame::{Frame, FrameData, StreamId};
use crate::wire::Encode;
@@ -419,7 +418,7 @@ where
                    target: "wire", "Stream {} of {} closing with {} byte(s) sent and {} byte(s) received",
                    task.stream, task.remote, s.sent_bytes, s.received_bytes
                );
-
                let frame = Frame::control(
+
                let frame = Frame::<service::Message>::control(
                    *link,
                    frame::Control::Close {
                        stream: task.stream,
@@ -471,7 +470,7 @@ where
                ChannelEvent::Data(data) => {
                    metrics.sent_git_bytes += data.len();
                    metrics.sent_bytes += data.len();
-
                    Frame::git(stream, data)
+
                    Frame::<service::Message>::git(stream, data)
                }
                ChannelEvent::Close => Frame::control(*link, frame::Control::Close { stream }),
                ChannelEvent::Eof => Frame::control(*link, frame::Control::Eof { stream }),
@@ -839,15 +838,6 @@ where
                                // Buffer is empty, or message isn't complete.
                                break;
                            }
-
                            Err(wire::Error::UnknownProtocolVersion(v)) => {
-
                                // It's ok for a peer to send frames from a newer protocol version
-
                                // that we don't understand. We just ignore them.
-
                                log::debug!(
-
                                    target: "wire",
-
                                    "Ignoring frame with newer protocol version ({v})",
-
                                );
-
                                continue;
-
                            }
                            Err(e) => {
                                log::error!(target: "wire", "Invalid gossip message from {nid}: {e}");

@@ -1119,7 +1109,8 @@ where

                    self.actions.push_back(Action::Send(
                        fd,
-
                        Frame::control(link, frame::Control::Open { stream }).to_bytes(),
+
                        Frame::<service::Message>::control(link, frame::Control::Open { stream })
+
                            .to_bytes(),
                    ));
                }
            }
@@ -1238,7 +1229,7 @@ mod test {
    use crate::wire::varint;

    #[test]
-
    fn test_message_with_extension() {
+
    fn test_pong_message_with_extension() {
        use crate::deserializer;

        let mut stream = Vec::new();
@@ -1269,4 +1260,104 @@ mod test {
        assert!(de.deserialize_next().unwrap().is_none());
        assert!(de.is_empty());
    }
+

+
    #[test]
+
    fn test_inventory_ann_with_extension() {
+
        use crate::deserializer;
+

+
        #[derive(Debug)]
+
        struct MessageWithExt {
+
            msg: Message,
+
            ext: String,
+
        }
+

+
        impl wire::Encode for MessageWithExt {
+
            fn encode<W: io::Write + ?Sized>(&self, writer: &mut W) -> Result<usize, io::Error> {
+
                let mut n = self.msg.encode(writer)?;
+
                n += self.ext.encode(writer)?;
+

+
                Ok(n)
+
            }
+
        }
+

+
        impl wire::Decode for MessageWithExt {
+
            fn decode<R: io::Read + ?Sized>(reader: &mut R) -> Result<Self, wire::Error> {
+
                let msg = Message::decode(reader)?;
+
                let ext = String::decode(reader).unwrap_or_default();
+

+
                Ok(MessageWithExt { msg, ext })
+
            }
+
        }
+

+
        let rid = radicle::test::arbitrary::gen(1);
+
        let pk = radicle::test::arbitrary::gen(1);
+
        let sig: [u8; 64] = radicle::test::arbitrary::gen(1);
+

+
        // Message with extension.
+
        let mut stream = Vec::new();
+
        let ann = Message::announcement(
+
            pk,
+
            service::gossip::inventory(radicle::node::Timestamp::MAX, [rid]),
+
            radicle::crypto::Signature::from(sig),
+
        );
+
        let pong = Message::Pong {
+
            zeroes: ZeroBytes::new(42),
+
        };
+
        // Framed message with extension.
+
        frame::Frame::gossip(
+
            Link::Outbound,
+
            MessageWithExt {
+
                msg: ann.clone(),
+
                ext: String::from("extra"),
+
            },
+
        )
+
        .encode(&mut stream)
+
        .unwrap();
+
        // Pong message that comes after, without extension.
+
        frame::Frame::gossip(Link::Outbound, pong.clone())
+
            .encode(&mut stream)
+
            .unwrap();
+

+
        // First test deserializing using the message with extension type.
+
        {
+
            let mut de = deserializer::Deserializer::<1024, Frame<MessageWithExt>>::new(1024);
+
            de.input(&stream).unwrap();
+

+
            radicle::assert_matches!(
+
                de.deserialize_next().unwrap().unwrap().data,
+
                FrameData::Gossip(MessageWithExt {
+
                    msg,
+
                    ext,
+
                }) if msg == ann && ext == String::from("extra")
+
            );
+
            radicle::assert_matches!(
+
                de.deserialize_next().unwrap().unwrap().data,
+
                FrameData::Gossip(MessageWithExt {
+
                    msg,
+
                    ext,
+
                }) if msg == pong && ext.is_empty()
+
            );
+
            assert!(de.deserialize_next().unwrap().is_none());
+
            assert!(de.is_empty());
+
        }
+

+
        // Then test deserializing using the current message type without the extension.
+
        {
+
            let mut de = deserializer::Deserializer::<1024, Frame<Message>>::new(1024);
+
            de.input(&stream).unwrap();
+

+
            radicle::assert_matches!(
+
                de.deserialize_next().unwrap().unwrap().data,
+
                FrameData::Gossip(msg)
+
                if msg == ann
+
            );
+
            radicle::assert_matches!(
+
                de.deserialize_next().unwrap().unwrap().data,
+
                FrameData::Gossip(msg)
+
                if msg == pong
+
            );
+
            assert!(de.deserialize_next().unwrap().is_none());
+
            assert!(de.is_empty());
+
        }
+
    }
}
modified radicle/src/node.rs
@@ -45,6 +45,8 @@ pub use features::Features;
pub use seed::SyncedAt;
pub use timestamp::Timestamp;

+
/// Peer-to-peer protocol version.
+
pub const PROTOCOL_VERSION: u8 = 1;
/// Default name for control socket file.
pub const DEFAULT_SOCKET_NAME: &str = "control.sock";
/// Default radicle protocol port.
@@ -217,6 +219,77 @@ impl PartialOrd for SyncStatus {
    }
}

+
/// Node user agent.
+
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, serde::Serialize, serde::Deserialize)]
+
pub struct UserAgent(String);
+

+
impl UserAgent {
+
    /// Return a reference to the user agent string.
+
    pub fn as_str(&self) -> &str {
+
        self.0.as_str()
+
    }
+
}
+

+
impl Default for UserAgent {
+
    fn default() -> Self {
+
        UserAgent(String::from("/radicle/"))
+
    }
+
}
+

+
impl std::fmt::Display for UserAgent {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        self.0.fmt(f)
+
    }
+
}
+

+
impl FromStr for UserAgent {
+
    type Err = String;
+

+
    fn from_str(input: &str) -> Result<Self, Self::Err> {
+
        let reserved = ['/', ':'];
+

+
        if input.len() > 64 {
+
            return Err(input.to_owned());
+
        }
+
        let Some(s) = input.strip_prefix('/') else {
+
            return Err(input.to_owned());
+
        };
+
        let Some(s) = s.strip_suffix('/') else {
+
            return Err(input.to_owned());
+
        };
+
        if s.is_empty() {
+
            return Err(input.to_owned());
+
        }
+
        if s.split('/').all(|segment| {
+
            if let Some((client, version)) = segment.split_once(':') {
+
                if client.is_empty() || version.is_empty() {
+
                    false
+
                } else {
+
                    let client = client
+
                        .chars()
+
                        .all(|c| c.is_ascii_graphic() && !reserved.contains(&c));
+
                    let version = version
+
                        .chars()
+
                        .all(|c| c.is_ascii_graphic() || !reserved.contains(&c));
+
                    client && version
+
                }
+
            } else {
+
                true
+
            }
+
        }) {
+
            Ok(Self(input.to_owned()))
+
        } else {
+
            Err(input.to_owned())
+
        }
+
    }
+
}
+

+
impl AsRef<str> for UserAgent {
+
    fn as_ref(&self) -> &str {
+
        self.0.as_str()
+
    }
+
}
+

/// Node alias.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, serde::Serialize, serde::Deserialize)]
#[serde(try_from = "String", into = "String")]
@@ -232,6 +305,11 @@ impl Alias {
            Err(e) => panic!("Alias::new: {e}"),
        }
    }
+

+
    /// Return a reference to the alias string.
+
    pub fn as_str(&self) -> &str {
+
        self.0.as_str()
+
    }
}

impl From<Alias> for String {
@@ -1283,6 +1361,27 @@ mod test {
    use crate::assert_matches;

    #[test]
+
    fn test_user_agent() {
+
        assert!(UserAgent::from_str("/radicle:1.0.0/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:1.0.0/heartwood:0.9/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:1.0.0/heartwood:0.9/rust:1.77/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:1.0.0-rc.1/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:1.0.0-rc.1/").is_ok());
+
        assert!(UserAgent::from_str("/radicle:@a.b.c/").is_ok());
+
        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("/:/").is_err());
+
        assert!(UserAgent::from_str("//").is_err());
+
        assert!(UserAgent::from_str("").is_err());
+
        assert!(UserAgent::from_str("radicle:1.0.0/").is_err());
+
        assert!(UserAgent::from_str("/radicle:1.0.0").is_err());
+
        assert!(UserAgent::from_str("/radi cle:1.0/").is_err());
+
        assert!(UserAgent::from_str("/radi\ncle:1.0/").is_err());
+
    }
+

+
    #[test]
    fn test_alias() {
        assert!(Alias::from_str("cloudhead").is_ok());
        assert!(Alias::from_str("cloud-head").is_ok());
modified radicle/src/node/address.rs
@@ -10,7 +10,7 @@ use localtime::LocalTime;
use nonempty::NonEmpty;

use crate::collections::RandomMap;
-
use crate::node::{Address, Alias, Penalty};
+
use crate::node::{Address, Alias, Penalty, UserAgent};
use crate::prelude::Timestamp;
use crate::{node, profile};

@@ -120,6 +120,8 @@ impl<K: hash::Hash + Eq, V> DerefMut for AddressBook<K, V> {
/// Node public data.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Node {
+
    /// Protocol version.
+
    pub version: u8,
    /// Advertized alias.
    pub alias: Alias,
    /// Advertized features.
@@ -130,6 +132,8 @@ pub struct Node {
    pub pow: u32,
    /// When this data was published.
    pub timestamp: Timestamp,
+
    /// User agent string.
+
    pub agent: UserAgent,
    /// Node connection penalty.
    pub penalty: Penalty,
    /// Whether the node is banned.
modified radicle/src/node/address/store.rs
@@ -1,4 +1,5 @@
use std::net::IpAddr;
+
use std::num::TryFromIntError;
use std::str::FromStr;

use localtime::LocalTime;
@@ -7,6 +8,7 @@ use thiserror::Error;

use crate::node;
use crate::node::address::{AddressType, KnownAddress, Node, Source};
+
use crate::node::UserAgent;
use crate::node::{Address, Alias, AliasError, AliasStore, Database, NodeId, Penalty, Severity};
use crate::prelude::Timestamp;
use crate::sql::transaction;
@@ -18,6 +20,8 @@ pub enum Error {
    Internal(#[from] sql::Error),
    #[error("alias error: {0}")]
    InvalidAlias(#[from] AliasError),
+
    #[error("integer conversion error: {0}")]
+
    TryFromInt(#[from] TryFromIntError),
    /// No rows returned in query result.
    #[error("no rows returned")]
    NoRows,
@@ -28,6 +32,8 @@ pub enum Error {
pub struct AddressEntry {
    /// Node ID.
    pub node: NodeId,
+
    /// Node protocol version.
+
    pub version: u8,
    /// Node penalty.
    pub penalty: Penalty,
    /// Node address.
@@ -48,9 +54,11 @@ pub trait Store {
    fn insert(
        &mut self,
        node: &NodeId,
+
        version: u8,
        features: node::Features,
-
        alias: Alias,
+
        alias: &Alias,
        pow: u32,
+
        agent: &UserAgent,
        timestamp: Timestamp,
        addrs: impl IntoIterator<Item = KnownAddress>,
    ) -> Result<bool, Error>;
@@ -89,24 +97,30 @@ pub trait Store {
impl Store for Database {
    fn get(&self, node: &NodeId) -> Result<Option<Node>, Error> {
        let mut stmt = self.db.prepare(
-
            "SELECT features, alias, pow, penalty, banned, timestamp FROM nodes WHERE id = ?",
+
            "SELECT version, features, alias, pow, penalty, banned, agent, timestamp
+
             FROM nodes
+
             WHERE id = ?",
        )?;
        stmt.bind((1, node))?;

        if let Some(Ok(row)) = stmt.into_iter().next() {
+
            let version = row.read::<i64, _>("version").try_into()?;
            let features = row.read::<node::Features, _>("features");
            let alias = Alias::from_str(row.read::<&str, _>("alias"))?;
            let timestamp = row.read::<Timestamp, _>("timestamp");
            let pow = row.read::<i64, _>("pow") as u32;
+
            let agent = row.read::<UserAgent, _>("agent");
            let penalty = row.read::<i64, _>("penalty").min(u8::MAX as i64);
            let penalty = Penalty(penalty as u8);
            let banned = row.read::<i64, _>("banned").is_positive();
            let addrs = self.addresses_of(node)?;

            Ok(Some(Node {
+
                version,
                features,
                alias,
                pow,
+
                agent,
                timestamp,
                penalty,
                addrs,
@@ -207,26 +221,30 @@ impl Store for Database {
    fn insert(
        &mut self,
        node: &NodeId,
+
        version: u8,
        features: node::Features,
-
        alias: Alias,
+
        alias: &Alias,
        pow: u32,
+
        agent: &UserAgent,
        timestamp: Timestamp,
        addrs: impl IntoIterator<Item = KnownAddress>,
    ) -> Result<bool, Error> {
        transaction(&self.db, move |db| {
            let mut stmt = db.prepare(
-
                "INSERT INTO nodes (id, features, alias, pow, timestamp)
-
                 VALUES (?1, ?2, ?3, ?4, ?5)
+
                "INSERT INTO nodes (id, version, features, alias, pow, agent, timestamp)
+
                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
                 ON CONFLICT DO UPDATE
-
                 SET features = ?2, alias = ?3, pow = ?4, timestamp = ?5
-
                 WHERE timestamp < ?5",
+
                 SET version = ?2, features = ?3, alias = ?4, pow = ?5, agent = ?6, timestamp = ?7
+
                 WHERE timestamp < ?7",
            )?;

            stmt.bind((1, node))?;
-
            stmt.bind((2, features))?;
-
            stmt.bind((3, sql::Value::String(alias.into())))?;
-
            stmt.bind((4, pow as i64))?;
-
            stmt.bind((5, &timestamp))?;
+
            stmt.bind((2, version as i64))?;
+
            stmt.bind((3, features))?;
+
            stmt.bind((4, alias.as_str()))?;
+
            stmt.bind((5, pow as i64))?;
+
            stmt.bind((6, agent.as_str()))?;
+
            stmt.bind((7, &timestamp))?;
            stmt.next()?;

            for addr in addrs {
@@ -261,7 +279,7 @@ impl Store for Database {
        let mut stmt = self
            .db
            .prepare(
-
                "SELECT a.node, a.type, a.value, a.source, a.last_success, a.last_attempt, a.banned, n.penalty
+
                "SELECT a.node, a.type, a.value, a.source, a.last_success, a.last_attempt, a.banned, n.version, n.penalty
                 FROM addresses AS a
                 JOIN nodes AS n ON a.node = n.id
                 ORDER BY n.penalty ASC, n.id ASC",
@@ -278,12 +296,14 @@ impl Store for Database {
            let last_attempt = row.read::<Option<i64>, _>("last_attempt");
            let last_success = last_success.map(|t| LocalTime::from_millis(t as u128));
            let last_attempt = last_attempt.map(|t| LocalTime::from_millis(t as u128));
+
            let version = row.read::<i64, _>("version").try_into()?;
            let banned = row.read::<i64, _>("banned").is_positive();
            let penalty = row.read::<i64, _>("penalty");
            let penalty = Penalty(penalty as u8); // Clamped at `u8::MAX`.

            entries.push(AddressEntry {
                node,
+
                version,
                penalty,
                address: KnownAddress {
                    addr,
@@ -510,15 +530,34 @@ mod test {
        let mut cache = Database::memory().unwrap();
        let features = node::Features::SEED;
        let timestamp = Timestamp::from(LocalTime::now());
+
        let ua = UserAgent::default();

        cache
-
            .insert(&alice, features, Alias::new("alice"), 16, timestamp, [])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                &Alias::new("alice"),
+
                16,
+
                &ua,
+
                timestamp,
+
                [],
+
            )
            .unwrap();
        let node = cache.get(&alice).unwrap().unwrap();
        assert_eq!(node.alias.as_ref(), "alice");

        cache
-
            .insert(&alice, features, Alias::new("bob"), 16, timestamp + 1, [])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                &Alias::new("bob"),
+
                16,
+
                &ua,
+
                timestamp + 1,
+
                [],
+
            )
            .unwrap();
        let node = cache.get(&alice).unwrap().unwrap();
        assert_eq!(node.alias.as_ref(), "bob");
@@ -528,8 +567,10 @@ mod test {
    fn test_insert_and_get() {
        let alice = arbitrary::gen::<NodeId>(1);
        let mut cache = Database::memory().unwrap();
+
        let version = 2;
        let features = node::Features::SEED;
        let timestamp = LocalTime::now().into();
+
        let ua = UserAgent::default();

        let ka = KnownAddress {
            addr: net::SocketAddr::from(([4, 4, 4, 4], 8776)).into(),
@@ -541,9 +582,11 @@ mod test {
        let inserted = cache
            .insert(
                &alice,
+
                version,
                features,
-
                Alias::new("alice"),
+
                &Alias::new("alice"),
                16,
+
                &ua,
                timestamp,
                [ka.clone()],
            )
@@ -552,6 +595,7 @@ mod test {

        let node = cache.get(&alice).unwrap().unwrap();

+
        assert_eq!(node.version, version);
        assert_eq!(node.features, features);
        assert_eq!(node.pow, 16);
        assert_eq!(node.timestamp, timestamp);
@@ -566,6 +610,7 @@ mod test {
        let features = node::Features::SEED;
        let timestamp = LocalTime::now().into();
        let alias = Alias::new("alice");
+
        let ua = UserAgent::default();

        let ka = KnownAddress {
            addr: net::SocketAddr::from(([4, 4, 4, 4], 8776)).into(),
@@ -575,12 +620,12 @@ mod test {
            banned: false,
        };
        let inserted = cache
-
            .insert(&alice, features, alias.clone(), 0, timestamp, [ka.clone()])
+
            .insert(&alice, 1, features, &alias, 0, &ua, timestamp, [ka.clone()])
            .unwrap();
        assert!(inserted);

        let inserted = cache
-
            .insert(&alice, features, alias, 0, timestamp, [ka])
+
            .insert(&alice, 1, features, &alias, 0, &ua, timestamp, [ka])
            .unwrap();
        assert!(!inserted);

@@ -593,6 +638,8 @@ mod test {
        let mut cache = Database::memory().unwrap();
        let timestamp = LocalTime::now().into();
        let features = node::Features::SEED;
+
        let ua1 = UserAgent::default();
+
        let ua2 = UserAgent::default();
        let alias1 = Alias::new("alice");
        let alias2 = Alias::new("~alice~");
        let ka = KnownAddress {
@@ -604,17 +651,26 @@ mod test {
        };

        let updated = cache
-
            .insert(&alice, features, alias1, 0, timestamp, [ka.clone()])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                &alias1,
+
                0,
+
                &ua1,
+
                timestamp,
+
                [ka.clone()],
+
            )
            .unwrap();
        assert!(updated);

        let updated = cache
-
            .insert(&alice, features, alias2.clone(), 0, timestamp, [])
+
            .insert(&alice, 1, features, &alias2, 0, &ua1, timestamp, [])
            .unwrap();
        assert!(!updated, "Can't update using the same timestamp");

        let updated = cache
-
            .insert(&alice, features, alias2.clone(), 0, timestamp - 1, [])
+
            .insert(&alice, 1, features, &alias2, 0, &ua1, timestamp - 1, [])
            .unwrap();
        assert!(!updated, "Can't update using a smaller timestamp");

@@ -624,12 +680,21 @@ mod test {
        assert_eq!(node.pow, 0);

        let updated = cache
-
            .insert(&alice, features, alias2.clone(), 0, timestamp + 1, [])
+
            .insert(&alice, 1, features, &alias2, 0, &ua2, timestamp + 1, [])
            .unwrap();
        assert!(updated, "Can update with a larger timestamp");

        let updated = cache
-
            .insert(&alice, node::Features::NONE, alias2, 1, timestamp + 2, [])
+
            .insert(
+
                &alice,
+
                1,
+
                node::Features::NONE,
+
                &alias2,
+
                1,
+
                &ua2,
+
                timestamp + 2,
+
                [],
+
            )
            .unwrap();
        assert!(updated);

@@ -639,6 +704,7 @@ mod test {
        assert_eq!(node.timestamp, timestamp + 2);
        assert_eq!(node.pow, 1);
        assert_eq!(node.addrs, vec![ka]);
+
        assert_eq!(node.agent, ua2);
    }

    #[test]
@@ -647,6 +713,7 @@ mod test {
        let bob = arbitrary::gen::<NodeId>(1);
        let mut cache = Database::memory().unwrap();
        let timestamp = LocalTime::now().into();
+
        let ua = UserAgent::default();
        let features = node::Features::SEED;
        let alice_alias = Alias::new("alice");
        let bob_alias = Alias::new("bob");
@@ -666,15 +733,17 @@ mod test {
            cache
                .insert(
                    &alice,
+
                    1,
                    features,
-
                    alice_alias.clone(),
+
                    &alice_alias,
                    0,
+
                    &ua,
                    timestamp,
                    [ka.clone()],
                )
                .unwrap();
            cache
-
                .insert(&bob, features, bob_alias.clone(), 0, timestamp, [ka])
+
                .insert(&bob, 1, features, &bob_alias, 0, &ua, timestamp, [ka])
                .unwrap();
        }
        assert_eq!(cache.len().unwrap(), 6);
@@ -695,6 +764,7 @@ mod test {
        let mut cache = Database::memory().unwrap();
        let mut expected = Vec::new();
        let timestamp = LocalTime::now().into();
+
        let ua = UserAgent::default();
        let features = node::Features::SEED;
        let alias = Alias::new("alice");

@@ -711,11 +781,12 @@ mod test {
            };
            expected.push(AddressEntry {
                node: id,
+
                version: 3,
                penalty: Penalty::default(),
                address: ka.clone(),
            });
            cache
-
                .insert(&id, features, alias.clone(), 0, timestamp, [ka])
+
                .insert(&id, 3, features, &alias, 0, &ua, timestamp, [ka])
                .unwrap();
        }

@@ -735,9 +806,19 @@ mod test {
        let mut cache = Database::memory().unwrap();
        let features = node::Features::SEED;
        let timestamp = Timestamp::from(LocalTime::now());
+
        let ua = UserAgent::default();

        cache
-
            .insert(&alice, features, Alias::new("alice"), 16, timestamp, [])
+
            .insert(
+
                &alice,
+
                1,
+
                features,
+
                &Alias::new("alice"),
+
                16,
+
                &ua,
+
                timestamp,
+
                [],
+
            )
            .unwrap();
        let node = cache.get(&alice).unwrap().unwrap();
        assert_eq!(node.penalty, Penalty::default());
@@ -762,6 +843,7 @@ mod test {
    #[test]
    fn test_disconnected_ban() {
        let alice = arbitrary::gen::<NodeId>(1);
+
        let ua = UserAgent::default();
        let ip1: net::Ipv4Addr = [8, 8, 8, 8].into();
        let ip2: net::Ipv4Addr = [9, 9, 9, 9].into();
        let ka1 = arbitrary::gen::<KnownAddress>(1);
@@ -780,9 +862,11 @@ mod test {

        db.insert(
            &alice,
+
            1,
            features,
-
            Alias::new("alice"),
+
            &Alias::new("alice"),
            16,
+
            &ua,
            timestamp,
            [ka1.clone(), ka2.clone()],
        )
modified radicle/src/node/config.rs
@@ -10,6 +10,9 @@ use crate::node;
use crate::node::policy::SeedingPolicy;
use crate::node::{Address, Alias, NodeId};

+
/// Peer-to-peer protocol version.
+
pub type ProtocolVersion = u8;
+

/// Default number of workers to spawn.
pub const DEFAULT_WORKERS: usize = 8;

@@ -63,14 +66,14 @@ pub enum Network {

impl Network {
    /// Bootstrap nodes for this network.
-
    pub fn bootstrap(&self) -> Vec<(Alias, ConnectAddress)> {
+
    pub fn bootstrap(&self) -> Vec<(Alias, ProtocolVersion, ConnectAddress)> {
        match self {
            Self::Main => [
                ("seed.radicle.garden", seeds::RADICLE_COMMUNITY_NODE.clone()),
                ("seed.radicle.xyz", seeds::RADICLE_TEAM_NODE.clone()),
            ]
            .into_iter()
-
            .map(|(a, s)| (Alias::new(a), s))
+
            .map(|(a, s)| (Alias::new(a), 1, s))
            .collect(),

            Self::Test => vec![],
modified radicle/src/node/db.rs
@@ -15,7 +15,9 @@ use std::{fmt, time};
use sqlite as sql;
use thiserror::Error;

-
use crate::node::{address, Address, Alias, Features, KnownAddress, NodeId, Timestamp};
+
use crate::node::{
+
    address, Address, Alias, Features, KnownAddress, NodeId, Timestamp, UserAgent, PROTOCOL_VERSION,
+
};
use crate::sql::transaction;

/// How long to wait for the database lock to be released before failing a read.
@@ -31,6 +33,7 @@ const MIGRATIONS: &[&str] = &[
    include_str!("db/migrations/3.sql"),
    include_str!("db/migrations/4.sql"),
    include_str!("db/migrations/5.sql"),
+
    include_str!("db/migrations/6.sql"),
];

#[derive(Error, Debug)]
@@ -130,16 +133,19 @@ impl Database {
        mut self,
        node: &NodeId,
        features: Features,
-
        alias: Alias,
+
        alias: &Alias,
+
        agent: &UserAgent,
        timestamp: Timestamp,
        addrs: impl IntoIterator<Item = &'a Address>,
    ) -> Result<Self, Error> {
        address::Store::insert(
            &mut self,
            node,
+
            PROTOCOL_VERSION,
            features,
            alias,
            0,
+
            agent,
            timestamp,
            addrs
                .into_iter()
added radicle/src/node/db/migrations/6.sql
@@ -0,0 +1,3 @@
+
-- Add the version and user-agent columns.
+
alter table "nodes" add column "version" integer not null default 1;
+
alter table "nodes" add column "agent" text not null default "/radicle/";
modified radicle/src/profile.rs
@@ -27,7 +27,7 @@ use crate::node::policy::config::store::Read;
use crate::node::{
    notifications, policy,
    policy::{Policy, Scope, SeedingPolicy},
-
    Alias, AliasStore, Handle as _, Node,
+
    Alias, AliasStore, Handle as _, Node, UserAgent,
};
use crate::prelude::{Did, NodeId, RepoId};
use crate::storage::git::transport;
@@ -318,7 +318,8 @@ impl Profile {
            .init(
                &public_key,
                config.node.features(),
-
                config.node.alias.clone(),
+
                &config.node.alias,
+
                &UserAgent::default(),
                LocalTime::now().into(),
                config.node.external_addresses.iter(),
            )?;
modified radicle/src/sql.rs
@@ -6,7 +6,7 @@ use sqlite::Value;

use crate::identity::RepoId;
use crate::node;
-
use crate::node::Address;
+
use crate::node::{Address, UserAgent};

/// Run an SQL query inside a transaction.
/// Commits the transaction on success, and rolls back on error.
@@ -39,7 +39,7 @@ impl TryFrom<&Value> for RepoId {
            }),
            _ => Err(sql::Error {
                code: None,
-
                message: Some("sql: invalid type for id".to_owned()),
+
                message: Some(format!("sql: invalid type `{:?}` for id", value.kind())),
            }),
        }
    }
@@ -65,7 +65,10 @@ impl TryFrom<&Value> for node::Features {
            Value::Integer(bits) => Ok(node::Features::from(*bits as u64)),
            _ => Err(sql::Error {
                code: None,
-
                message: Some("sql: invalid type for node features".to_owned()),
+
                message: Some(format!(
+
                    "sql: invalid type `{:?}` for node features",
+
                    value.kind()
+
                )),
            }),
        }
    }
@@ -82,7 +85,10 @@ impl TryFrom<&sql::Value> for Address {
            }),
            _ => Err(sql::Error {
                code: None,
-
                message: Some("sql: invalid type for address".to_owned()),
+
                message: Some(format!(
+
                    "sql: invalid type `{:?}` for address",
+
                    value.kind()
+
                )),
            }),
        }
    }
@@ -93,3 +99,23 @@ impl sql::BindableWithIndex for &Address {
        self.to_string().bind(stmt, i)
    }
}
+

+
impl TryFrom<&Value> for UserAgent {
+
    type Error = sql::Error;
+

+
    fn try_from(value: &Value) -> Result<Self, Self::Error> {
+
        match value {
+
            Value::String(ua) => UserAgent::from_str(ua).map_err(|e| sql::Error {
+
                code: None,
+
                message: Some(e.to_string()),
+
            }),
+
            _ => Err(sql::Error {
+
                code: None,
+
                message: Some(format!(
+
                    "sql: invalid type `{:?}` for user-agent",
+
                    value.kind()
+
                )),
+
            }),
+
        }
+
    }
+
}
modified radicle/src/test/arbitrary.rs
@@ -19,7 +19,7 @@ use crate::identity::{
    Did,
};
use crate::node::address::{AddressType, Source};
-
use crate::node::{Address, Alias, KnownAddress, Timestamp};
+
use crate::node::{Address, Alias, KnownAddress, Timestamp, UserAgent};
use crate::storage;
use crate::storage::refs::{Refs, RefsAt, SignedRefs};
use crate::test::storage::{MockRepository, MockStorage};
@@ -324,3 +324,12 @@ impl Arbitrary for Timestamp {
        Self::try_from(u64::arbitrary(g).min(*Self::MAX)).unwrap()
    }
}
+

+
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(),
+
        )
+
        .unwrap()
+
    }
+
}