node: Introduce connection penalty system

Previously, we might ban a node if we're having trouble connecting to
it, but this could be due to internet connectivity issues.

We introduce a penalty score for nodes, that increases if there are
connection issues or the node misbehaves, and decreases on successful
connections.

When connected to new nodes, we sort them so that low-penalty nodes are
attempted first.
This commit is contained in:
cloudhead 2023-12-15 15:55:42 +01:00
parent bdcaa60474
commit 6f7c2dca91
No known key found for this signature in database
6 changed files with 213 additions and 84 deletions

View File

@ -29,7 +29,7 @@ use radicle::node::config::PeerConfig;
use radicle::node::routing::Store as _; use radicle::node::routing::Store as _;
use radicle::node::seed; use radicle::node::seed;
use radicle::node::seed::Store as _; use radicle::node::seed::Store as _;
use radicle::node::ConnectOptions; use radicle::node::{ConnectOptions, Penalty, Severity};
use radicle::storage::RepositoryError; use radicle::storage::RepositoryError;
use crate::crypto; use crate::crypto;
@ -118,6 +118,14 @@ impl SyncedRouting {
} }
} }
/// A peer we can connect to.
#[derive(Debug, Clone)]
struct Peer {
nid: NodeId,
addresses: Vec<KnownAddress>,
penalty: Penalty,
}
/// General service error. /// General service error.
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum Error { pub enum Error {
@ -1029,6 +1037,7 @@ where
} }
}; };
let link = session.link; let link = session.link;
let addr = session.addr.clone();
self.fetching.retain(|_, fetching| { self.fetching.retain(|_, fetching| {
if fetching.from != remote { if fetching.from != remote {
@ -1058,15 +1067,31 @@ where
self.outbox.wakeup(delay); self.outbox.wakeup(delay);
} else { } else {
debug!(target: "service", "Dropping peer {remote}.."); debug!(target: "service", "Dropping peer {remote}..");
self.sessions.remove(&remote);
if let Err(e) = let severity = match reason {
self.db DisconnectReason::Dial(_)
.addresses_mut() | DisconnectReason::Fetch(_)
.disconnected(&remote, &session.addr, reason.is_transient()) | DisconnectReason::Connection(_) => {
if self.is_online() {
// If we're "online", there's something wrong with this
// peer connection specifically.
Severity::Medium
} else {
Severity::Low
}
}
DisconnectReason::Session(e) => e.severity(),
DisconnectReason::Command => Severity::Low,
};
if let Err(e) = self
.db
.addresses_mut()
.disconnected(&remote, &addr, severity)
{ {
error!(target: "service", "Error updating address store: {e}"); error!(target: "service", "Error updating address store: {e}");
} }
self.sessions.remove(&remote);
// Only re-attempt outbound connections, since we don't care if an inbound connection // Only re-attempt outbound connections, since we don't care if an inbound connection
// is dropped. // is dropped.
if link.is_outbound() { if link.is_outbound() {
@ -1590,6 +1615,15 @@ where
] ]
} }
/// Try to guess whether we're online or not.
fn is_online(&self) -> bool {
self.sessions
.connected()
.filter(|(_, s)| s.addr.is_routable() && s.last_active >= self.clock - IDLE_INTERVAL)
.count()
> 0
}
/// Update our routing table with our local node's inventory. /// Update our routing table with our local node's inventory.
fn sync_inventory(&mut self) -> Result<SyncedRouting, Error> { fn sync_inventory(&mut self) -> Result<SyncedRouting, Error> {
let inventory = self.storage.inventory()?; let inventory = self.storage.inventory()?;
@ -1892,24 +1926,35 @@ where
} }
} }
/// Get a list of peers available to connect to. /// Get a list of peers available to connect to, sorted by lowest penalty.
fn available_peers(&mut self) -> HashMap<NodeId, Vec<KnownAddress>> { fn available_peers(&mut self) -> Vec<Peer> {
match self.db.addresses().entries() { match self.db.addresses().entries() {
Ok(entries) => { Ok(entries) => {
// Nb. we don't want to connect to any peers that already have a session with us, // 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. // even if it's in a disconnected state. Those sessions are re-attempted automatically.
entries let mut peers = entries
.filter(|(_, ka)| !ka.banned) .filter(|entry| !entry.address.banned)
.filter(|(nid, _)| !self.sessions.contains_key(nid)) .filter(|entry| !entry.penalty.is_threshold_reached())
.filter(|(nid, _)| nid != &self.node_id()) .filter(|entry| !self.sessions.contains_key(&entry.node))
.fold(HashMap::new(), |mut acc, (nid, addr)| { .filter(|entry| &entry.node != self.nid())
acc.entry(nid).or_default().push(addr); .fold(HashMap::new(), |mut acc, entry| {
acc.entry(entry.node)
.and_modify(|e: &mut Peer| e.addresses.push(entry.address.clone()))
.or_insert_with(|| Peer {
nid: entry.node,
addresses: vec![entry.address],
penalty: entry.penalty,
});
acc acc
}) })
.into_values()
.collect::<Vec<_>>();
peers.sort_by_key(|p| p.penalty);
peers
} }
Err(e) => { Err(e) => {
error!(target: "service", "Unable to lookup available peers in address book: {e}"); error!(target: "service", "Unable to lookup available peers in address book: {e}");
HashMap::new() Vec::new()
} }
} }
} }
@ -1974,8 +2019,9 @@ where
for (id, ka) in self for (id, ka) in self
.available_peers() .available_peers()
.into_iter() .into_iter()
.filter_map(|(nid, kas)| { .filter_map(|peer| {
kas.into_iter() peer.addresses
.into_iter()
.find(|ka| match (ka.last_success, ka.last_attempt) { .find(|ka| match (ka.last_success, ka.last_attempt) {
// If we succeeded the last time we tried, this is a good address. // If we succeeded the last time we tried, this is a good address.
// TODO: This will always be hit after a success, and never re-attempted after // TODO: This will always be hit after a success, and never re-attempted after
@ -1986,7 +2032,7 @@ where
// If we've never tried this address, it's worth a try. // If we've never tried this address, it's worth a try.
(None, None) => true, (None, None) => true,
}) })
.map(|ka| (nid, ka)) .map(|ka| (peer.nid, ka))
}) })
.take(wanted) .take(wanted)
{ {
@ -2094,18 +2140,6 @@ impl DisconnectReason {
pub fn is_connection_err(&self) -> bool { pub fn is_connection_err(&self) -> bool {
matches!(self, Self::Connection(_)) matches!(self, Self::Connection(_))
} }
// TODO: These aren't quite correct, since dial errors *can* be transient, eg.
// temporary DNS issue.
pub fn is_transient(&self) -> bool {
match self {
Self::Dial(_) => false,
Self::Connection(_) => true,
Self::Command => false,
Self::Fetch(_) => true,
Self::Session(err) => err.is_transient(),
}
}
} }
impl fmt::Display for DisconnectReason { impl fmt::Display for DisconnectReason {

View File

@ -2,6 +2,7 @@ use std::collections::HashSet;
use std::fmt; use std::fmt;
use crate::node::config::Limits; use crate::node::config::Limits;
use crate::node::Severity;
use crate::service::message; use crate::service::message;
use crate::service::message::Message; use crate::service::message::Message;
use crate::service::{Address, Id, LocalTime, NodeId, Outbox, Rng}; use crate::service::{Address, Id, LocalTime, NodeId, Outbox, Rng};
@ -9,7 +10,7 @@ use crate::Link;
pub use crate::node::{PingState, State}; pub use crate::node::{PingState, State};
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug, Clone, Copy)]
pub enum Error { pub enum Error {
/// The remote peer sent an invalid announcement timestamp, /// The remote peer sent an invalid announcement timestamp,
/// for eg. a timestamp far in the future. /// for eg. a timestamp far in the future.
@ -28,13 +29,13 @@ pub enum Error {
} }
impl Error { impl Error {
/// Check whether this error is transient. /// Return the severity for this error.
pub fn is_transient(&self) -> bool { pub fn severity(&self) -> Severity {
match self { match self {
Self::InvalidTimestamp(_) => false, Self::InvalidTimestamp(_) => Severity::High,
Self::ProtocolMismatch => true, Self::ProtocolMismatch => Severity::High,
Self::Misbehavior => false, Self::Misbehavior => Severity::High,
Self::Timeout => true, Self::Timeout => Severity::Low,
} }
} }
} }

View File

@ -9,6 +9,7 @@ use std::time;
use crossbeam_channel as chan; use crossbeam_channel as chan;
use netservices::Direction as Link; use netservices::Direction as Link;
use radicle::identity::Visibility; use radicle::identity::Visibility;
use radicle::node::address::Store;
use radicle::node::routing::Store as _; use radicle::node::routing::Store as _;
use radicle::node::{ConnectOptions, DEFAULT_TIMEOUT}; use radicle::node::{ConnectOptions, DEFAULT_TIMEOUT};
use radicle::storage::refs::RefsAt; use radicle::storage::refs::RefsAt;
@ -898,7 +899,12 @@ fn test_refs_announcement_offline() {
// Now we restart Alice's node. It should pick up that something's changed in storage. // Now we restart Alice's node. It should pick up that something's changed in storage.
alice.elapse(LocalDuration::from_secs(60)); alice.elapse(LocalDuration::from_secs(60));
alice.disconnected(bob.id, &DisconnectReason::Command); alice
.database_mut()
.addresses_mut()
.remove(&bob.id)
.unwrap(); // Make sure we don't reconnect automatically.
alice.disconnected(bob.id, &DisconnectReason::Session(session::Error::Timeout));
alice.outbox().for_each(drop); alice.outbox().for_each(drop);
alice.restart(); alice.restart();
alice.connect_to(&bob); alice.connect_to(&bob);
@ -1157,11 +1163,9 @@ fn test_maintain_connections() {
alice.import_addresses(&unconnected); alice.import_addresses(&unconnected);
// A non-transient error such as this will cause Alice to attempt a different peer. // A non-transient error such as this will cause Alice to attempt a different peer.
let error = Arc::new(io::Error::from(io::ErrorKind::ConnectionReset)); let error = session::Error::Misbehavior;
for peer in connected.iter() { for peer in connected.iter() {
let reason = DisconnectReason::Dial(error.clone()); alice.disconnected(peer.id(), &DisconnectReason::Session(error));
assert!(!reason.is_transient());
alice.disconnected(peer.id(), &reason);
let id = alice let id = alice
.outbox() .outbox()
@ -1210,8 +1214,6 @@ fn test_maintain_connections_failed_attempt() {
let reason = let reason =
DisconnectReason::Connection(Arc::new(io::Error::from(io::ErrorKind::ConnectionReset))); DisconnectReason::Connection(Arc::new(io::Error::from(io::ErrorKind::ConnectionReset)));
assert!(reason.is_transient());
alice.connect_to(&eve); alice.connect_to(&eve);
// Make sure Alice knows about Eve. // Make sure Alice knows about Eve.
alice.disconnected(eve.id(), &reason); alice.disconnected(eve.id(), &reason);

View File

@ -45,6 +45,8 @@ pub const DEFAULT_PORT: u16 = 8776;
pub const DEFAULT_TIMEOUT: time::Duration = time::Duration::from_secs(9); pub const DEFAULT_TIMEOUT: time::Duration = time::Duration::from_secs(9);
/// Maximum length in bytes of a node alias. /// Maximum length in bytes of a node alias.
pub const MAX_ALIAS_LENGTH: usize = 32; pub const MAX_ALIAS_LENGTH: usize = 32;
/// Penalty threshold at which point we avoid connecting to this node.
pub const PENALTY_THRESHOLD: u8 = 32;
/// Filename of node database under the node directory. /// Filename of node database under the node directory.
pub const NODE_DB_FILE: &str = "node.db"; pub const NODE_DB_FILE: &str = "node.db";
/// Filename of policies database under the node directory. /// Filename of policies database under the node directory.
@ -128,6 +130,26 @@ impl fmt::Display for State {
} }
} }
/// Severity of a peer misbehavior or a connection problem.
#[derive(Debug, Copy, Clone, PartialEq, Eq)]
pub enum Severity {
Low = 0,
Medium = 1,
High = 8,
}
/// Node connection penalty. Nodes with a high penalty are deprioritized as peers.
#[derive(Debug, Copy, Clone, PartialEq, Eq, Default, PartialOrd, Ord)]
pub struct Penalty(u8);
impl Penalty {
/// If the penalty threshold is reached, at which point we should just avoid
/// connecting to this node.
pub fn is_threshold_reached(&self) -> bool {
self.0 >= PENALTY_THRESHOLD
}
}
/// Repository sync status for our own refs. /// Repository sync status for our own refs.
#[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)] #[derive(Debug, PartialEq, Eq, Clone, serde::Serialize, serde::Deserialize)]
#[serde(tag = "status")] #[serde(tag = "status")]

View File

@ -10,7 +10,7 @@ use localtime::LocalTime;
use nonempty::NonEmpty; use nonempty::NonEmpty;
use crate::collections::RandomMap; use crate::collections::RandomMap;
use crate::node::{Address, Alias}; use crate::node::{Address, Alias, Penalty};
use crate::prelude::Timestamp; use crate::prelude::Timestamp;
use crate::{node, profile}; use crate::{node, profile};
@ -130,6 +130,8 @@ pub struct Node {
pub pow: u32, pub pow: u32,
/// When this data was published. /// When this data was published.
pub timestamp: Timestamp, pub timestamp: Timestamp,
/// Node connection penalty.
pub penalty: Penalty,
} }
/// A known address. /// A known address.

View File

@ -6,7 +6,7 @@ use thiserror::Error;
use crate::node; use crate::node;
use crate::node::address::{AddressType, KnownAddress, Node, Source}; use crate::node::address::{AddressType, KnownAddress, Node, Source};
use crate::node::{Address, Alias, AliasError, AliasStore, Database, NodeId}; use crate::node::{Address, Alias, AliasError, AliasStore, Database, NodeId, Penalty, Severity};
use crate::prelude::Timestamp; use crate::prelude::Timestamp;
use crate::sql::transaction; use crate::sql::transaction;
@ -22,6 +22,17 @@ pub enum Error {
NoRows, NoRows,
} }
/// An entry returned by the store.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AddressEntry {
/// Node ID.
pub node: NodeId,
/// Node penalty.
pub penalty: Penalty,
/// Node address.
pub address: KnownAddress,
}
/// Address store. /// Address store.
/// ///
/// Used to store node addresses and metadata. /// Used to store node addresses and metadata.
@ -51,20 +62,25 @@ pub trait Store {
self.len().map(|l| l == 0) self.len().map(|l| l == 0)
} }
/// Get the address entries in the store. /// Get the address entries in the store.
fn entries(&self) -> Result<Box<dyn Iterator<Item = (NodeId, KnownAddress)>>, Error>; fn entries(&self) -> Result<Box<dyn Iterator<Item = AddressEntry>>, Error>;
/// Mark a node as attempted at a certain time. /// Mark a node as attempted at a certain time.
fn attempted(&self, nid: &NodeId, addr: &Address, time: Timestamp) -> Result<(), Error>; fn attempted(&self, nid: &NodeId, addr: &Address, time: Timestamp) -> Result<(), Error>;
/// Mark a node as successfully connected at a certain time. /// Mark a node as successfully connected at a certain time.
fn connected(&self, nid: &NodeId, addr: &Address, time: Timestamp) -> Result<(), Error>; fn connected(&self, nid: &NodeId, addr: &Address, time: Timestamp) -> Result<(), Error>;
/// Mark a node as disconnected. /// Mark a node as disconnected.
fn disconnected(&mut self, nid: &NodeId, addr: &Address, transient: bool) -> Result<(), Error>; fn disconnected(
&mut self,
nid: &NodeId,
addr: &Address,
severity: Severity,
) -> Result<(), Error>;
} }
impl Store for Database { impl Store for Database {
fn get(&self, node: &NodeId) -> Result<Option<Node>, Error> { fn get(&self, node: &NodeId) -> Result<Option<Node>, Error> {
let mut stmt = self let mut stmt = self
.db .db
.prepare("SELECT features, alias, pow, timestamp FROM nodes WHERE id = ?")?; .prepare("SELECT features, alias, pow, penalty, timestamp FROM nodes WHERE id = ?")?;
stmt.bind((1, node))?; stmt.bind((1, node))?;
@ -73,6 +89,8 @@ impl Store for Database {
let alias = Alias::from_str(row.read::<&str, _>("alias"))?; let alias = Alias::from_str(row.read::<&str, _>("alias"))?;
let timestamp = row.read::<i64, _>("timestamp") as Timestamp; let timestamp = row.read::<i64, _>("timestamp") as Timestamp;
let pow = row.read::<i64, _>("pow") as u32; let pow = row.read::<i64, _>("pow") as u32;
let penalty = row.read::<i64, _>("penalty").min(u8::MAX as i64);
let penalty = Penalty(penalty as u8);
let addrs = self.addresses_of(node)?; let addrs = self.addresses_of(node)?;
Ok(Some(Node { Ok(Some(Node {
@ -80,6 +98,7 @@ impl Store for Database {
alias, alias,
pow, pow,
timestamp, timestamp,
penalty,
addrs, addrs,
})) }))
} else { } else {
@ -183,10 +202,15 @@ impl Store for Database {
Ok(self.db.change_count() > 0) Ok(self.db.change_count() > 0)
} }
fn entries(&self) -> Result<Box<dyn Iterator<Item = (NodeId, KnownAddress)>>, Error> { fn entries(&self) -> Result<Box<dyn Iterator<Item = AddressEntry>>, Error> {
let mut stmt = self let mut stmt = self
.db .db
.prepare("SELECT node, type, value, source, last_success, last_attempt, banned FROM addresses ORDER BY node")? .prepare(
"SELECT a.node, a.type, a.value, a.source, a.last_success, a.last_attempt, a.banned, n.penalty
FROM addresses AS a
JOIN nodes AS n ON a.node = n.id
ORDER BY n.penalty ASC, n.id ASC",
)?
.into_iter(); .into_iter();
let mut entries = Vec::new(); let mut entries = Vec::new();
@ -200,17 +224,20 @@ impl Store for Database {
let last_success = last_success.map(|t| LocalTime::from_millis(t as u128)); 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 last_attempt = last_attempt.map(|t| LocalTime::from_millis(t as u128));
let banned = row.read::<i64, _>("banned").is_positive(); 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(( entries.push(AddressEntry {
node, node,
KnownAddress { penalty,
address: KnownAddress {
addr, addr,
source, source,
last_success, last_success,
last_attempt, last_attempt,
banned, banned,
}, },
)); });
} }
Ok(Box::new(entries.into_iter())) Ok(Box::new(entries.into_iter()))
} }
@ -234,37 +261,47 @@ impl Store for Database {
} }
fn connected(&self, nid: &NodeId, addr: &Address, time: Timestamp) -> Result<(), Error> { fn connected(&self, nid: &NodeId, addr: &Address, time: Timestamp) -> Result<(), Error> {
let mut stmt = self.db.prepare( transaction(&self.db, |db| {
"UPDATE `addresses` let mut stmt = db.prepare(
SET last_success = ?1
WHERE node = ?2
AND type = ?3
AND value = ?4",
)?;
stmt.bind((1, time as i64))?;
stmt.bind((2, nid))?;
stmt.bind((3, AddressType::from(addr)))?;
stmt.bind((4, addr))?;
stmt.next()?;
Ok(())
}
fn disconnected(&mut self, nid: &NodeId, addr: &Address, transient: bool) -> Result<(), Error> {
// Ban address if not a transient failure.
if !transient {
let mut stmt = self.db.prepare(
"UPDATE `addresses` "UPDATE `addresses`
SET banned = 1 SET last_success = ?1
WHERE node = ?1 AND type = ?2 AND value = ?3", WHERE node = ?2
AND type = ?3
AND value = ?4",
)?; )?;
stmt.bind((1, nid))?; stmt.bind((1, time as i64))?;
stmt.bind((2, AddressType::from(addr)))?; stmt.bind((2, nid))?;
stmt.bind((3, addr))?; stmt.bind((3, AddressType::from(addr)))?;
stmt.bind((4, addr))?;
stmt.next()?; stmt.next()?;
}
// Reduce penalty by half on successful connect.
db.prepare("UPDATE `nodes` SET penalty = penalty / 2 WHERE node = ?1")?;
stmt.bind((1, nid))?;
stmt.next()?;
Ok(())
})
}
fn disconnected(
&mut self,
nid: &NodeId,
_addr: &Address,
severity: Severity,
) -> Result<(), Error> {
let mut stmt = self.db.prepare(
"UPDATE `nodes`
SET penalty = penalty + ?2
WHERE id = ?1",
)?;
stmt.bind((1, nid))?;
stmt.bind((2, severity as i64))?;
stmt.next()?;
Ok(()) Ok(())
} }
} }
@ -584,7 +621,11 @@ mod test {
last_attempt: None, last_attempt: None,
banned: false, banned: false,
}; };
expected.push((id, ka.clone())); expected.push(AddressEntry {
node: id,
penalty: Penalty::default(),
address: ka.clone(),
});
cache cache
.insert(&id, features, alias.clone(), 0, timestamp, [ka]) .insert(&id, features, alias.clone(), 0, timestamp, [ka])
.unwrap(); .unwrap();
@ -592,10 +633,37 @@ mod test {
let mut actual = cache.entries().unwrap().collect::<Vec<_>>(); let mut actual = cache.entries().unwrap().collect::<Vec<_>>();
actual.sort_by_key(|(i, _)| *i); actual.sort_by_key(|ae| ae.node);
expected.sort_by_key(|(i, _)| *i); expected.sort_by_key(|ae| ae.node);
assert_eq!(cache.len().unwrap(), actual.len()); assert_eq!(cache.len().unwrap(), actual.len());
assert_eq!(actual, expected); assert_eq!(actual, expected);
} }
#[test]
fn test_disconnected() {
let alice = arbitrary::gen::<NodeId>(1);
let addr = arbitrary::gen::<Address>(1);
let mut cache = Database::memory().unwrap();
let features = node::Features::SEED;
let timestamp = LocalTime::now().as_millis();
cache
.insert(&alice, features, Alias::new("alice"), 16, timestamp, [])
.unwrap();
let node = cache.get(&alice).unwrap().unwrap();
assert_eq!(node.penalty, Penalty::default());
cache.disconnected(&alice, &addr, Severity::Low).unwrap();
let node = cache.get(&alice).unwrap().unwrap();
assert_eq!(node.penalty, Penalty::default());
cache.disconnected(&alice, &addr, Severity::Medium).unwrap();
let node = cache.get(&alice).unwrap().unwrap();
assert_eq!(node.penalty, Penalty(1));
cache.disconnected(&alice, &addr, Severity::High).unwrap();
let node = cache.get(&alice).unwrap().unwrap();
assert_eq!(node.penalty, Penalty(9));
}
} }