//! 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; /// per-IP reputation score. Stored in `Server.ext`. #[derive(Default)] pub struct Reputation(pub HashMap); /// Whether `uid` is in at least one channel with `min` or more members (the /// reputation `minchanmembers` gate — stops idle bots farming score alone). fn in_active_channel(s: &Server, uid: Uid, min: usize) -> bool { if min <= 1 { return true; } s.users .get(&uid) .map(|u| { u.channels.iter().any(|k| { s.channels .get(k) .map(|c| c.members.len() >= min) .unwrap_or(false) }) }) .unwrap_or(false) } /// The tick-driven accrual + periodic save. Bumps every `rep_bump_secs` (default /// 5 min): +1 per connected user's IP, +1 more if they're logged into an account. #[derive(Default)] pub struct ReputationMod { secs: u64, // seconds since the last bump since_save: u64, } impl Module for ReputationMod { fn name(&self) -> &'static str { "reputation" } fn on_tick(&mut self, s: &mut Server) { self.secs += crate::server::TICK_SECS; self.since_save += crate::server::TICK_SECS; if self.secs < s.rep_bump_secs { return; } self.secs = 0; let cap = s.rep_scorecap; let min = s.rep_minchanmembers; // (ip, bump amount): +1 base, +1 if the user is logged into services let bumps: Vec<(IpAddr, u32)> = s .users .values() .filter(|u| u.registered) .filter(|u| in_active_channel(s, u.uid, min)) .map(|u| (u.addr.ip(), if u.account.is_some() { 2 } else { 1 })) .collect(); let store = s.ext.get_or_insert_with::(Reputation::default); for (ip, amt) in bumps { let e = store.0.entry(ip).or_insert(0); *e = (*e + amt).min(cap); } if self.since_save >= 600 { self.since_save = 0; save(s); } } } /// The reputation score of the IP `uid` is connecting from. pub fn score_of(s: &Server, uid: Uid) -> u32 { let Some(ip) = s.users.get(&uid).map(|u| u.addr.ip()) else { return 0; }; s.ext .get::() .and_then(|r| r.0.get(&ip)) .copied() .unwrap_or(0) } /// The `y:` score extban: `y:N` above N. pub fn score_ban_match(s: &Server, uid: Uid, spec: &str) -> bool { let (gt, num) = match spec.strip_prefix('>') { Some(n) => (true, n), None => (false, spec.strip_prefix('<').unwrap_or(spec)), }; let Ok(threshold) = num.trim().parse::() else { return false; }; let score = score_of(s, uid); if gt { score > threshold } else { score < threshold } } 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()) { let cap = s.rep_scorecap; s.ext .get_or_insert_with::(Reputation::default) .0 .insert(ip, val.min(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); } } } }