diff --git a/.gitignore b/.gitignore index c6a63bc..f10090c 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ Cargo.lock # persisted x-line db (runtime state) *.xlines *.metadata +*.reputation diff --git a/src/ircd.rs b/src/ircd.rs index 18b6a2c..3769222 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -74,6 +74,7 @@ impl Ircd { let mut server = Server::new(cfg, event_tx, conn_counter); server.load_xlines(); // restore persisted bans (m_xline_db) crate::modules::metadata::load(&mut server); // restore channel metadata (m_metadata_db) + crate::modules::reputation::load(&mut server); // restore per-IP reputation Ircd { server, commands: command_table(), @@ -377,6 +378,9 @@ impl Ircd { self.server.purge_xlines(); // drop expired server bans self.server.purge_tbans(); // lift expired timed channel bans (TBAN) self.server.prune_conn_history(); // connflood bookkeeping + for m in &mut self.modules { + m.on_tick(&mut self.server); // timer-driven modules (e.g. reputation) + } let now = crate::server::now(); let (to_ping, to_quit) = self.server.idle_check(now); for uid in to_ping { diff --git a/src/module.rs b/src/module.rs index 19f4ff3..0d5cb16 100644 --- a/src/module.rs +++ b/src/module.rs @@ -66,4 +66,6 @@ pub trait Module: Send { fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {} fn on_part(&mut self, srv: &mut Server, uid: Uid, chan: &str, reason: &str) {} fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) {} + /// Fired on the background timer (every `TICK_SECS`). + fn on_tick(&mut self, srv: &mut Server) {} } diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 5b996a3..2d52671 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -12,6 +12,7 @@ pub mod flood; pub mod markread; pub mod metadata; pub mod multiline; +pub mod reputation; pub mod snoop; use crate::command::Command; @@ -28,6 +29,7 @@ pub fn default_modules() -> Vec> { Box::new(metadata::Metadata), Box::new(markread::MarkRead), Box::new(multiline::Multiline), + Box::new(reputation::ReputationMod::default()), ] } @@ -40,5 +42,6 @@ pub fn module_commands() -> Vec> { .chain(markread::commands()) .chain(multiline::commands()) .chain(chathistory::commands()) + .chain(reputation::commands()) .collect() } diff --git a/src/modules/reputation.rs b/src/modules/reputation.rs new file mode 100644 index 0000000..3427aed --- /dev/null +++ b/src/modules/reputation.rs @@ -0,0 +1,152 @@ +//! reputation — InspIRCd `m_reputation`. Tracks a per-IP reputation score that +//! accrues while users from that IP stay connected (roughly, time-online), so +//! opers can tell established users apart from fresh/throwaway connections. +//! Self-contained: the scores live in `Server.ext`, accrue on the tick, and +//! persist to `.reputation`. `REPUTATION` reads/sets a user's score. + +use std::collections::HashMap; +use std::net::IpAddr; + +use crate::command::{CmdResult, Command}; +use crate::module::Module; +use crate::numeric::{ERR_NOPRIVILEGES, ERR_NOSUCHNICK}; +use crate::server::Server; +use crate::Uid; + +const REP_CAP: u32 = 100_000; +const SAVE_EVERY: u32 = 20; // ticks between disk saves (~5 min at TICK_SECS=15) + +/// per-IP reputation score. Stored in `Server.ext`. +#[derive(Default)] +pub struct Reputation(pub HashMap); + +/// The tick-driven accrual + periodic save. Holds a tick counter of its own. +#[derive(Default)] +pub struct ReputationMod { + ticks: u32, +} +impl Module for ReputationMod { + fn name(&self) -> &'static str { + "reputation" + } + fn on_tick(&mut self, s: &mut Server) { + let ips: Vec = s + .users + .values() + .filter(|u| u.registered) + .map(|u| u.addr.ip()) + .collect(); + let store = s.ext.get_or_insert_with::(Reputation::default); + for ip in ips { + let e = store.0.entry(ip).or_insert(0); + *e = (*e + 1).min(REP_CAP); + } + self.ticks += 1; + if self.ticks % SAVE_EVERY == 0 { + save(s); + } + } +} + +pub fn commands() -> Vec> { + vec![Box::new(ReputationCmd)] +} + +/// REPUTATION — `REPUTATION []` (oper). Show, or set, the reputation +/// of the IP `` is connecting from. +struct ReputationCmd; +impl Command for ReputationCmd { + fn name(&self) -> &'static str { + "REPUTATION" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.is_oper(uid) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- You're not an IRC operator", + ); + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(¶ms[0]) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{} :No such nick/channel", params[0]), + ); + return CmdResult::Fail; + }; + let Some(ip) = s.users.get(&tuid).map(|u| u.addr.ip()) else { + return CmdResult::Fail; + }; + let nick = params[0].clone(); + let anick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + if let Some(val) = params.get(1).and_then(|v| v.parse::().ok()) { + s.ext + .get_or_insert_with::(Reputation::default) + .0 + .insert(ip, val.min(REP_CAP)); + save(s); + s.send( + uid, + format!( + ":{} NOTICE {anick} :REPUTATION {nick} ({ip}) set to {val}", + s.name + ), + ); + } else { + let score = s + .ext + .get::() + .and_then(|r| r.0.get(&ip)) + .copied() + .unwrap_or(0); + s.send( + uid, + format!( + ":{} NOTICE {anick} :REPUTATION {nick} ({ip}) = {score}", + s.name + ), + ); + } + CmdResult::Ok + } +} + +fn db_path(s: &Server) -> String { + format!("{}.reputation", s.conf_path) +} + +/// Persist per-IP reputation so it survives a restart. +pub fn save(s: &Server) { + let mut out = String::new(); + if let Some(r) = s.ext.get::() { + for (ip, score) in &r.0 { + out.push_str(&format!("{ip} {score}\n")); + } + } + let _ = std::fs::write(db_path(s), out); +} + +/// Reload persisted reputation at startup. +pub fn load(s: &mut Server) { + let Ok(text) = std::fs::read_to_string(db_path(s)) else { + return; + }; + let store = s.ext.get_or_insert_with::(Reputation::default); + for line in text.lines() { + let mut it = line.split_whitespace(); + if let (Some(ip), Some(sc)) = (it.next(), it.next()) { + if let (Ok(ip), Ok(sc)) = (ip.parse::(), sc.parse::()) { + store.0.insert(ip, sc); + } + } + } +}