diff --git a/radicle-cli/tests/commands.rs b/radicle-cli/tests/commands.rs index d1092ff6..c9945a7e 100644 --- a/radicle-cli/tests/commands.rs +++ b/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"), 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"), 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"), 0, + &ua, now, [node::KnownAddress::new( node::Address::from(bob.addr), diff --git a/radicle-node/src/lib.rs b/radicle-node/src/lib.rs index 0d6f7083..8afed88b 100644 --- a/radicle-node/src/lib.rs +++ b/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}; diff --git a/radicle-node/src/main.rs b/radicle-node/src/main.rs index af50a3d2..ca7b93a1 100644 --- a/radicle-node/src/main.rs +++ b/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 diff --git a/radicle-node/src/runtime.rs b/radicle-node/src/runtime.rs index e42b5e22..713f99b1 100644 --- a/radicle-node/src/runtime.rs +++ b/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,6 +174,7 @@ 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())? @@ -181,6 +182,7 @@ impl Runtime { &id, announcement.features, announcement.alias.clone(), + &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, 0, + &UserAgent::default(), clock.into(), [node::KnownAddress::new(addr, address::Source::Bootstrap)], )?; diff --git a/radicle-node/src/service.rs b/radicle-node/src/service.rs index fe749798..56154ddb 100644 --- a/radicle-node/src/service.rs +++ b/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.version, + ann.features, ann.alias.clone(), 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)) diff --git a/radicle-node/src/service/gossip.rs b/radicle-node/src/service/gossip.rs index 471cb7d3..1ee912d8 100644 --- a/radicle-node/src/service/gossip.rs +++ b/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 = 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, } } diff --git a/radicle-node/src/service/message.rs b/radicle-node/src/service/message.rs index 7a3e09d9..aff7c4d3 100644 --- a/radicle-node/src/service/message.rs +++ b/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, /// 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(&self, writer: &mut W) -> Result { 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(reader: &mut R) -> Result { + 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::::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); } } diff --git a/radicle-node/src/test/arbitrary.rs b/radicle-node/src/test/arbitrary.rs index 5c9e99ce..19728895 100644 --- a/radicle-node/src/test/arbitrary.rs +++ b/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); diff --git a/radicle-node/src/test/environment.rs b/radicle-node/src/test/environment.rs index c8d4ba3b..517b9582 100644 --- a/radicle-node/src/test/environment.rs +++ b/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}; @@ -135,6 +134,7 @@ impl Environment { &public_key, config.node.features(), Alias::new(alias), + &UserAgent::default(), now.into(), config.node.external_addresses.iter(), ) diff --git a/radicle-node/src/test/gossip.rs b/radicle-node/src/test/gossip.rs index 1d276f14..0184e088 100644 --- a/radicle-node/src/test/gossip.rs +++ b/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 { @@ -28,11 +32,13 @@ pub fn messages(count: usize, now: LocalTime, delta: LocalDuration) -> Vec = service::Service; @@ -175,6 +175,7 @@ where &id, config.config.features(), config.config.alias.clone(), + &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(), 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(), diff --git a/radicle-node/src/wire.rs b/radicle-node/src/wire.rs index f8d490b8..c400ea0c 100644 --- a/radicle-node/src/wire.rs +++ b/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(&self, writer: &mut W) -> Result { + self.as_ref().encode(writer) + } +} + impl Encode for Alias { fn encode(&self, writer: &mut W) -> Result { self.as_ref().encode(writer) @@ -321,6 +330,13 @@ impl Decode for git::RefString { } } +impl Decode for UserAgent { + fn decode(reader: &mut R) -> Result { + String::decode(reader) + .and_then(|s| UserAgent::from_str(&s).map_err(Error::InvalidUserAgent)) + } +} + impl Decode for Alias { fn decode(reader: &mut R) -> Result { String::decode(reader).and_then(|s| Alias::from_str(&s).map_err(Error::from)) diff --git a/radicle-node/src/wire/frame.rs b/radicle-node/src/wire/frame.rs index 1f73f9e5..3da50029 100644 --- a/radicle-node/src/wire/frame.rs +++ b/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]); @@ -315,8 +313,8 @@ impl wire::Encode for Control { impl wire::Decode for Frame { fn decode(reader: &mut R) -> Result { 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)?; diff --git a/radicle-node/src/wire/message.rs b/radicle-node/src/wire/message.rs index 8c058e58..69e54fd9 100644 --- a/radicle-node/src/wire/message.rs +++ b/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::(&wire::serialize(&message)).unwrap(), - message - ); + let encoded = &wire::serialize(&message); + let decoded = wire::deserialize::(encoded).unwrap(); + + assert_eq!(message, decoded); } #[test] diff --git a/radicle-node/src/wire/protocol.rs b/radicle-node/src/wire/protocol.rs index fa0366d6..41f15cd6 100644 --- a/radicle-node/src/wire/protocol.rs +++ b/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; @@ -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}"); diff --git a/radicle/src/node.rs b/radicle/src/node.rs index 8ba21249..f775e7c4 100644 --- a/radicle/src/node.rs +++ b/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 { + 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 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")] @@ -1282,6 +1355,27 @@ mod test { use super::*; 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()); diff --git a/radicle/src/node/address.rs b/radicle/src/node/address.rs index d31689af..dac9ac43 100644 --- a/radicle/src/node/address.rs +++ b/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 DerefMut for AddressBook { /// 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. diff --git a/radicle/src/node/address/store.rs b/radicle/src/node/address/store.rs index 360a97d9..2c44ec4b 100644 --- a/radicle/src/node/address/store.rs +++ b/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, pow: u32, + agent: &UserAgent, timestamp: Timestamp, addrs: impl IntoIterator, ) -> Result; @@ -89,24 +97,30 @@ pub trait Store { impl Store for Database { fn get(&self, node: &NodeId) -> Result, 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::("version").try_into()?; let features = row.read::("features"); let alias = Alias::from_str(row.read::<&str, _>("alias"))?; let timestamp = row.read::("timestamp"); let pow = row.read::("pow") as u32; + let agent = row.read::("agent"); let penalty = row.read::("penalty").min(u8::MAX as i64); let penalty = Penalty(penalty as u8); let banned = row.read::("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, pow: u32, + agent: &UserAgent, timestamp: Timestamp, addrs: impl IntoIterator, ) -> Result { 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, ×tamp))?; + stmt.bind((2, version as i64))?; + stmt.bind((3, features))?; + stmt.bind((4, sql::Value::String(alias.into())))?; + stmt.bind((5, pow as i64))?; + stmt.bind((6, agent.as_str()))?; + stmt.bind((7, ×tamp))?; 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::, _>("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::("version").try_into()?; let banned = row.read::("banned").is_positive(); let penalty = row.read::("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::(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"), 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,21 @@ mod test { banned: false, }; let inserted = cache - .insert(&alice, features, alias.clone(), 0, timestamp, [ka.clone()]) + .insert( + &alice, + 1, + features, + alias.clone(), + 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 +647,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 +660,35 @@ 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.clone(), 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.clone(), + 0, + &ua1, + timestamp - 1, + [], + ) .unwrap(); assert!(!updated, "Can't update using a smaller timestamp"); @@ -624,12 +698,30 @@ mod test { assert_eq!(node.pow, 0); let updated = cache - .insert(&alice, features, alias2.clone(), 0, timestamp + 1, []) + .insert( + &alice, + 1, + features, + alias2.clone(), + 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 +731,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 +740,7 @@ mod test { let bob = arbitrary::gen::(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 +760,26 @@ mod test { cache .insert( &alice, + 1, features, alice_alias.clone(), 0, + &ua, timestamp, [ka.clone()], ) .unwrap(); cache - .insert(&bob, features, bob_alias.clone(), 0, timestamp, [ka]) + .insert( + &bob, + 1, + features, + bob_alias.clone(), + 0, + &ua, + timestamp, + [ka], + ) .unwrap(); } assert_eq!(cache.len().unwrap(), 6); @@ -695,6 +800,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 +817,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.clone(), 0, &ua, timestamp, [ka]) .unwrap(); } @@ -735,9 +842,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 +879,7 @@ mod test { #[test] fn test_disconnected_ban() { let alice = arbitrary::gen::(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::(1); @@ -780,9 +898,11 @@ mod test { db.insert( &alice, + 1, features, Alias::new("alice"), 16, + &ua, timestamp, [ka1.clone(), ka2.clone()], ) diff --git a/radicle/src/node/config.rs b/radicle/src/node/config.rs index e51978af..e65564bb 100644 --- a/radicle/src/node/config.rs +++ b/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![], diff --git a/radicle/src/node/db.rs b/radicle/src/node/db.rs index a53446a8..a71006da 100644 --- a/radicle/src/node/db.rs +++ b/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)] @@ -131,15 +134,18 @@ impl Database { node: &NodeId, features: Features, alias: Alias, + agent: &UserAgent, timestamp: Timestamp, addrs: impl IntoIterator, ) -> Result { address::Store::insert( &mut self, node, + PROTOCOL_VERSION, features, alias, 0, + agent, timestamp, addrs .into_iter() diff --git a/radicle/src/node/db/migrations/6.sql b/radicle/src/node/db/migrations/6.sql new file mode 100644 index 00000000..36aee208 --- /dev/null +++ b/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/"; diff --git a/radicle/src/profile.rs b/radicle/src/profile.rs index b76f82cd..9509e921 100644 --- a/radicle/src/profile.rs +++ b/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; @@ -319,6 +319,7 @@ impl Profile { &public_key, config.node.features(), config.node.alias.clone(), + &UserAgent::default(), LocalTime::now().into(), config.node.external_addresses.iter(), )?; diff --git a/radicle/src/sql.rs b/radicle/src/sql.rs index 8d077b45..69ab4be0 100644 --- a/radicle/src/sql.rs +++ b/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. @@ -93,3 +93,20 @@ 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 { + 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("sql: invalid type for user-agent".to_owned()), + }), + } + } +} diff --git a/radicle/src/test/arbitrary.rs b/radicle/src/test/arbitrary.rs index 8c584e15..fea78c07 100644 --- a/radicle/src/test/arbitrary.rs +++ b/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() + } +}