node: Implement IP-based banning

The previous banning implementation didn't work, since it included port
numbers in the address value.

Here, we keep a separate table with (node, ip) pairs and update this
table when a node connects or is banned.
This commit is contained in:
cloudhead 2024-05-14 13:07:34 +02:00
parent 72c715016f
commit a6cb4d0f48
No known key found for this signature in database
8 changed files with 129 additions and 34 deletions

View File

@ -11,6 +11,7 @@ pub mod session;
use std::collections::hash_map::Entry; use std::collections::hash_map::Entry;
use std::collections::{BTreeSet, HashMap, VecDeque}; use std::collections::{BTreeSet, HashMap, VecDeque};
use std::net::IpAddr;
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::sync::Arc; use std::sync::Arc;
use std::{fmt, net, time}; use std::{fmt, net, time};
@ -1161,25 +1162,25 @@ where
} }
/// Inbound connection attempt. /// Inbound connection attempt.
pub fn accepted(&mut self, addr: Address) -> bool { pub fn accepted(&mut self, ip: IpAddr) -> bool {
// Always accept trusted connections, even if we already reached // Always accept localhost connections, even if we already reached
// our inbound connection limit. // our inbound connection limit.
if addr.is_trusted() { if ip.is_loopback() || ip.is_unspecified() {
return true; return true;
} }
// Check for inbound connection limit. // Check for inbound connection limit.
if self.sessions.inbound().count() >= self.config.limits.connection.inbound { if self.sessions.inbound().count() >= self.config.limits.connection.inbound {
return false; return false;
} }
match self.db.addresses().is_banned(&addr) { match self.db.addresses().is_ip_banned(ip) {
Ok(banned) => { Ok(banned) => {
if banned { if banned {
return false; return false;
} }
} }
Err(e) => error!(target: "service", "Error querying ban status for {addr}: {e}"), Err(e) => error!(target: "service", "Error querying ban status for {ip}: {e}"),
} }
let host: HostName = addr.into(); let host: HostName = ip.into();
if self.limiter.limit( if self.limiter.limit(
host.clone(), host.clone(),
@ -1211,7 +1212,7 @@ where
} }
pub fn connected(&mut self, remote: NodeId, addr: Address, link: Link) { pub fn connected(&mut self, remote: NodeId, addr: Address, link: Link) {
info!(target: "service", "Connected to {} ({:?})", remote, link); info!(target: "service", "Connected to {remote} ({addr}) ({link:?})");
self.emitter.emit(Event::PeerConnected { nid: remote }); self.emitter.emit(Event::PeerConnected { nid: remote });
let msgs = self.initial(link); let msgs = self.initial(link);
@ -1230,6 +1231,17 @@ where
); );
} }
Entry::Vacant(e) => { Entry::Vacant(e) => {
if let HostName::Ip(ip) = addr.host {
if !ip.is_loopback() && !self.config.is_proxy_ip(ip) {
if let Err(e) =
self.db
.addresses_mut()
.record_ip(&remote, ip, self.clock.into())
{
log::error!(target: "service", "Error recording IP address for {remote}: {e}");
}
}
}
let peer = e.insert(Session::inbound( let peer = e.insert(Session::inbound(
remote, remote,
addr, addr,

View File

@ -514,26 +514,31 @@ where
) { ) {
match event { match event {
ListenerEvent::Accepted(connection) => { ListenerEvent::Accepted(connection) => {
let Ok(addr) = connection.remote_addr() else { let Ok(remote) = connection.remote_addr() else {
log::warn!(target: "wire", "Accepted connection doesn't have remote address; dropping.."); log::warn!(target: "wire", "Accepted connection doesn't have remote address; dropping..");
drop(connection); drop(connection);
return; return;
}; };
let addr: NetAddr<HostName> = addr.into(); let InetHost::Ip(ip) = remote.host else {
log::error!(target: "wire", "Unexpected host type for inbound connection {remote}; dropping..");
drop(connection);
return;
};
let fd = connection.as_raw_fd(); let fd = connection.as_raw_fd();
log::debug!(target: "wire", "Accepting inbound connection from {addr} (fd={fd}).."); log::debug!(target: "wire", "Inbound connection from {remote} (fd={fd})..");
// If the service doesn't want to accept this connection, // If the service doesn't want to accept this connection,
// we drop the connection here, which disconnects the socket. // we drop the connection here, which disconnects the socket.
if !self.service.accepted(addr.clone().into()) { if !self.service.accepted(ip) {
log::debug!(target: "wire", "Rejecting inbound connection from {addr} (fd={fd}).."); log::debug!(target: "wire", "Rejecting inbound connection from {ip} (fd={fd})..");
drop(connection); drop(connection);
return; return;
} }
let session = accept::<G>(addr.clone(), connection, self.signer.clone()); let session = accept::<G>(remote.clone().into(), connection, self.signer.clone());
let transport = match NetTransport::with_session(session, Link::Inbound) { let transport = match NetTransport::with_session(session, Link::Inbound) {
Ok(transport) => transport, Ok(transport) => transport,
Err(err) => { Err(err) => {
@ -541,8 +546,15 @@ where
return; return;
} }
}; };
log::debug!(target: "wire", "Accepted inbound connection from {remote} (fd={fd})..");
self.inbound.insert(fd, Inbound { id: None, addr }); self.inbound.insert(
fd,
Inbound {
id: None,
addr: remote.into(),
},
);
self.actions self.actions
.push_back(reactor::Action::RegisterTransport(transport)) .push_back(reactor::Action::RegisterTransport(transport))
} }

View File

@ -414,15 +414,6 @@ impl Address {
} }
} }
/// Check whether this address is trusted.
/// Returns true if the address is 127.0.0.1 or 0.0.0.0.
pub fn is_trusted(&self) -> bool {
match self.0.host {
HostName::Ip(ip) => ip.is_loopback() || ip.is_unspecified(),
_ => false,
}
}
/// Check whether this address is globally routable. /// Check whether this address is globally routable.
pub fn is_routable(&self) -> bool { pub fn is_routable(&self) -> bool {
match self.0.host { match self.0.host {

View File

@ -1,3 +1,4 @@
use std::net::IpAddr;
use std::str::FromStr; use std::str::FromStr;
use localtime::LocalTime; use localtime::LocalTime;
@ -65,13 +66,17 @@ pub trait Store {
} }
/// Check if an address is banned. Also returns `true` if the node this address belongs /// Check if an address is banned. Also returns `true` if the node this address belongs
/// to is banned. /// to is banned.
fn is_banned(&self, addr: &Address) -> Result<bool, Error>; fn is_addr_banned(&self, addr: &Address) -> Result<bool, Error>;
/// Check if an IP is banned.
fn is_ip_banned(&self, ip: IpAddr) -> Result<bool, Error>;
/// Get the address entries in the store. /// Get the address entries in the store.
fn entries(&self) -> Result<Box<dyn Iterator<Item = AddressEntry>>, 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>;
/// Record a node IP address and connection time.
fn record_ip(&self, nid: &NodeId, ip: IpAddr, time: Timestamp) -> Result<(), Error>;
/// Mark a node as disconnected. /// Mark a node as disconnected.
fn disconnected( fn disconnected(
&mut self, &mut self,
@ -112,12 +117,12 @@ impl Store for Database {
} }
} }
fn is_banned(&self, addr: &Address) -> Result<bool, Error> { fn is_addr_banned(&self, addr: &Address) -> Result<bool, Error> {
let mut stmt = self.db.prepare( let mut stmt = self.db.prepare(
"SELECT a.banned, n.banned "SELECT a.banned, n.banned
FROM addresses AS a FROM addresses AS a
JOIN nodes AS n ON a.node = n.id JOIN nodes AS n ON a.node = n.id
WHERE value = ?1 AND type =?2", WHERE value = ?1 AND type = ?2",
)?; )?;
stmt.bind((1, addr))?; stmt.bind((1, addr))?;
stmt.bind((2, AddressType::from(addr)))?; stmt.bind((2, AddressType::from(addr)))?;
@ -133,6 +138,17 @@ impl Store for Database {
} }
} }
fn is_ip_banned(&self, ip: IpAddr) -> Result<bool, Error> {
let mut stmt = self.db.prepare(
"SELECT banned
FROM ips
WHERE ip = ?1 AND banned > 0",
)?;
stmt.bind((1, ip.to_string().as_str()))?;
Ok(stmt.into_iter().next().is_some())
}
fn addresses_of(&self, node: &NodeId) -> Result<Vec<KnownAddress>, Error> { fn addresses_of(&self, node: &NodeId) -> Result<Vec<KnownAddress>, Error> {
let mut addrs = Vec::new(); let mut addrs = Vec::new();
let mut stmt = self.db.prepare( let mut stmt = self.db.prepare(
@ -325,10 +341,26 @@ impl Store for Database {
}) })
} }
fn record_ip(&self, nid: &NodeId, ip: IpAddr, time: Timestamp) -> Result<(), Error> {
let mut stmt = self.db.prepare(
"INSERT INTO ips (ip, node, last_attempt)
VALUES (?1, ?2, ?3)
ON CONFLICT DO UPDATE
SET last_attempt = ?3
WHERE last_attempt < ?3",
)?;
stmt.bind((1, ip.to_string().as_str()))?;
stmt.bind((2, nid))?;
stmt.bind((3, &time))?;
stmt.next()?;
Ok(())
}
fn disconnected( fn disconnected(
&mut self, &mut self,
nid: &NodeId, nid: &NodeId,
_addr: &Address, addr: &Address,
severity: Severity, severity: Severity,
) -> Result<(), Error> { ) -> Result<(), Error> {
transaction(&self.db, |db| { transaction(&self.db, |db| {
@ -348,7 +380,11 @@ impl Store for Database {
stmt.bind((1, nid))?; stmt.bind((1, nid))?;
stmt.next()?; stmt.next()?;
let mut stmt = db.prepare("UPDATE `addresses` SET banned = 1 WHERE node = ?1")?; let mut stmt = db.prepare("UPDATE `addresses` SET banned = 1 WHERE value = ?1")?;
stmt.bind((1, addr))?;
stmt.next()?;
let mut stmt = db.prepare("UPDATE `ips` SET banned = 1 WHERE node = ?1")?;
stmt.bind((1, nid))?; stmt.bind((1, nid))?;
stmt.next()?; stmt.next()?;
} }
@ -438,6 +474,7 @@ mod test {
use super::*; use super::*;
use crate::test::arbitrary; use crate::test::arbitrary;
use cyphernet::addr::NetAddr;
use localtime::LocalTime; use localtime::LocalTime;
#[test] #[test]
@ -725,8 +762,18 @@ mod test {
#[test] #[test]
fn test_disconnected_ban() { fn test_disconnected_ban() {
let alice = arbitrary::gen::<NodeId>(1); let alice = arbitrary::gen::<NodeId>(1);
let ip1: net::Ipv4Addr = [8, 8, 8, 8].into();
let ip2: net::Ipv4Addr = [9, 9, 9, 9].into();
let ka1 = arbitrary::gen::<KnownAddress>(1); let ka1 = arbitrary::gen::<KnownAddress>(1);
let ka1 = KnownAddress {
addr: Address::from(NetAddr::new(ip1.into(), 8776)),
..ka1
};
let ka2 = arbitrary::gen::<KnownAddress>(1); let ka2 = arbitrary::gen::<KnownAddress>(1);
let ka2 = KnownAddress {
addr: Address::from(NetAddr::new(ip2.into(), 8776)),
..ka2
};
let mut db = Database::memory().unwrap(); let mut db = Database::memory().unwrap();
let features = node::Features::SEED; let features = node::Features::SEED;
let timestamp = Timestamp::from(LocalTime::now()); let timestamp = Timestamp::from(LocalTime::now());
@ -740,6 +787,9 @@ mod test {
[ka1.clone(), ka2.clone()], [ka1.clone(), ka2.clone()],
) )
.unwrap(); .unwrap();
db.record_ip(&alice, ip1.into(), timestamp).unwrap();
db.record_ip(&alice, ip2.into(), timestamp).unwrap();
let node = db.get(&alice).unwrap().unwrap(); let node = db.get(&alice).unwrap().unwrap();
assert_eq!(node.penalty, Penalty::default()); assert_eq!(node.penalty, Penalty::default());
@ -758,9 +808,15 @@ mod test {
assert!(node.banned); assert!(node.banned);
for addr in node.addrs { for addr in node.addrs {
assert!(addr.banned); if addr.addr == ka1.addr {
assert!(addr.banned);
} else {
assert!(!addr.banned);
}
} }
assert!(db.is_banned(&ka1.addr).unwrap()); assert!(db.is_addr_banned(&ka1.addr).unwrap());
assert!(db.is_banned(&ka2.addr).unwrap()); // Banned because node is banned. assert!(db.is_addr_banned(&ka2.addr).unwrap()); // Banned because node is banned.
assert!(db.is_ip_banned(ip1.into()).unwrap());
assert!(db.is_ip_banned(ip2.into()).unwrap());
} }
} }

View File

@ -7,7 +7,7 @@ use localtime::LocalDuration;
use crate::node; use crate::node;
use crate::node::policy::{Policy, Scope}; use crate::node::policy::{Policy, Scope};
use crate::node::{db, Address, Alias, NodeId}; use crate::node::{address, db, Address, Alias, NodeId};
/// Target number of peers to maintain connections to. /// Target number of peers to maintain connections to.
pub const TARGET_OUTBOUND_PEERS: usize = 8; pub const TARGET_OUTBOUND_PEERS: usize = 8;
@ -359,6 +359,15 @@ impl Config {
self.peer(id).is_some() self.peer(id).is_some()
} }
/// Check if the given IP address is our proxy.
pub fn is_proxy_ip(&self, ip: net::IpAddr) -> bool {
match self.tor {
None => false,
Some(TorConfig::Proxy { address }) => address.ip() == ip,
Some(TorConfig::Transparent) => address::is_local(&ip),
}
}
/// Are we a relay node? This determines what we do with gossip messages from other peers. /// Are we a relay node? This determines what we do with gossip messages from other peers.
pub fn is_relay(&self) -> bool { pub fn is_relay(&self) -> bool {
match self.relay { match self.relay {

View File

@ -28,6 +28,7 @@ const MIGRATIONS: &[&str] = &[
include_str!("db/migrations/1.sql"), include_str!("db/migrations/1.sql"),
include_str!("db/migrations/2.sql"), include_str!("db/migrations/2.sql"),
include_str!("db/migrations/3.sql"), include_str!("db/migrations/3.sql"),
include_str!("db/migrations/4.sql"),
]; ];
#[derive(Error, Debug)] #[derive(Error, Debug)]

View File

@ -0,0 +1,14 @@
-- Peer IP addresses.
create table if not exists "ips" (
-- IP address. This *omits* the port.
"ip" text primary key not null,
-- Node this address belongs to. Can be `null` if the session is not yet
-- established.
"node" text references "nodes" ("id") on delete cascade,
-- When this connection was last attempted by the peer.
"last_attempt" integer not null,
-- If this IP is banned. Used as a boolean.
"banned" integer default false
--
) strict;

View File

@ -15,13 +15,13 @@ create table if not exists "nodes" (
-- --
) strict; ) strict;
-- Node addresses. -- Node addresses. These are adresses advertized by a node.
create table if not exists "addresses" ( create table if not exists "addresses" (
-- Node ID. -- Node ID.
"node" text not null references "nodes" ("id") on delete cascade, "node" text not null references "nodes" ("id") on delete cascade,
-- Address type. -- Address type.
"type" text not null, "type" text not null,
-- Address value. -- Address value, including port.
"value" text not null, "value" text not null,
-- Where we got this address from. -- Where we got this address from.
"source" text not null, "source" text not null,