From f6fbff5270b64179850ea66f3733b97681c4c927 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 08:28:38 +0000 Subject: [PATCH] =?UTF-8?q?reputation:=20full=20parity=20port=20=E2=80=94?= =?UTF-8?q?=20CIDR=20ipv4/ipv6=20masking,=20reputationexpire=20decay=20rul?= =?UTF-8?q?es,=20whois=20visibility=20modes,=20config-driven=20db/bump/exp?= =?UTF-8?q?ire/save/minchanmembers/scorecap=20(no=20hardcoding)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- echoircd.conf.example | 14 +++ src/config.rs | 70 +++++++++--- src/coremods/core_info.rs | 12 +-- src/modules/reputation.rs | 198 ++++++++++++++++++++++++---------- src/modules/securitygroups.rs | 8 +- src/server.rs | 19 +++- 6 files changed, 233 insertions(+), 88 deletions(-) diff --git a/echoircd.conf.example b/echoircd.conf.example index 323e8fe..cc02d8a 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -90,3 +90,17 @@ amu_target = both # scoremin= scoremax= — use as an extban: MODE #c +b g: # securitygroup = trusted account tls public # securitygroup = newbies scoremax=10 public + +# --- reputation (m_reputation): per-address scoring + y: score extban --- +# reputation_database = reputation.db # default: .reputation +# reputation_ipv4prefix = 32 # CIDR bits used to key IPv4 scores +# reputation_ipv6prefix = 64 # CIDR bits used to key IPv6 scores +# reputation_bumpinterval = 5m # how often a score bumps (+1, +2 if logged in) +# reputation_expireinterval = 605 # how often decay rules run +# reputation_saveinterval = 902 # how often the db is written +# reputation_minchanmembers = 3 # only bump if in a channel this big +# reputation_scorecap = 10000 # max score +# reputation_whois = all # all | opers | self | none +# reputationexpire = 2 1h # score<=2 decays after 1h (repeatable; * = any) +# reputationexpire = * 90d # any score decays after 90d +# extban usage: MODE #chan +b y:<100 (ban score below 100) +b y:>500 (above 500) diff --git a/src/config.rs b/src/config.rs index 56dc744..0772655 100644 --- a/src/config.rs +++ b/src/config.rs @@ -114,10 +114,16 @@ pub struct Config { pub oper_umodes: String, // opermodes: umodes set on /OPER pub seenicks: bool, // snotice every nick change pub announce_chan: bool, // chancreate: snotice when a channel is created - pub rep_scorecap: u32, // reputation: max score - pub rep_bump_secs: u64, // reputation: seconds between score bumps - pub rep_minchanmembers: usize, // reputation: only bump if in a chan this big - pub rep_whois: bool, // reputation: show score in WHOIS (opers) + pub rep_database: String, // reputation: db file (default .reputation) + pub rep_ipv4prefix: u8, // reputation: IPv4 CIDR prefix for keying (32) + pub rep_ipv6prefix: u8, // reputation: IPv6 CIDR prefix for keying (64) + pub rep_scorecap: u32, // reputation: max score (10000) + pub rep_bump_secs: u64, // reputation: seconds between score bumps (300) + pub rep_expire_secs: u64, // reputation: seconds between expiry runs (605) + pub rep_save_secs: u64, // reputation: seconds between disk saves (902) + pub rep_minchanmembers: usize, // reputation: only bump if in a chan this big (3) + pub rep_whois: String, // reputation: whois visibility all|opers|self|none + pub rep_expire_rules: Vec<(i32, u64)>, // (score-threshold, age-secs) decay rules } impl Default for Config { @@ -158,10 +164,16 @@ impl Default for Config { oper_umodes: String::new(), seenicks: false, announce_chan: false, + rep_database: String::new(), + rep_ipv4prefix: 32, + rep_ipv6prefix: 64, rep_scorecap: 10000, rep_bump_secs: 300, - rep_minchanmembers: 0, - rep_whois: true, + rep_expire_secs: 605, + rep_save_secs: 902, + rep_minchanmembers: 3, + rep_whois: "all".to_string(), + rep_expire_rules: Vec::new(), } } } @@ -356,16 +368,35 @@ impl Config { "off" | "no" | "false" | "0" ) } + "reputation_database" => c.rep_database = v.to_string(), + "reputation_ipv4prefix" => { + if let Ok(n) = v.parse::() { + c.rep_ipv4prefix = n.clamp(1, 32); + } + } + "reputation_ipv6prefix" => { + if let Ok(n) = v.parse::() { + c.rep_ipv6prefix = n.clamp(1, 128); + } + } "reputation_scorecap" => { if let Ok(n) = v.parse() { c.rep_scorecap = n; } } "reputation_bumpinterval" => { - if let Some(d) = crate::xline::parse_duration(v) { - if d > 0 { - c.rep_bump_secs = d; - } + if let Some(d) = crate::xline::parse_duration(v).filter(|&d| d > 0) { + c.rep_bump_secs = d; + } + } + "reputation_expireinterval" => { + if let Some(d) = crate::xline::parse_duration(v).filter(|&d| d > 0) { + c.rep_expire_secs = d; + } + } + "reputation_saveinterval" => { + if let Some(d) = crate::xline::parse_duration(v).filter(|&d| d > 0) { + c.rep_save_secs = d; } } "reputation_minchanmembers" => { @@ -373,11 +404,20 @@ impl Config { c.rep_minchanmembers = n; } } - "reputation_whois" => { - c.rep_whois = !matches!( - v.to_ascii_lowercase().as_str(), - "off" | "no" | "false" | "0" - ) + "reputation_whois" => c.rep_whois = v.to_ascii_lowercase(), + "reputationexpire" => { + // reputationexpire = (decay rule; * = any score) + let mut it = v.split_whitespace(); + if let (Some(sc), Some(age)) = (it.next(), it.next()) { + let score = if sc == "*" { + -1 + } else { + sc.parse().unwrap_or(-1) + }; + if let Some(age) = crate::xline::parse_duration(age).filter(|&a| a > 0) { + c.rep_expire_rules.push((score, age)); + } + } } "securitygroup" | "secgroup" => { // securitygroup = [public] [tls|insecure] [account|unregistered] diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index 76e7ce9..73f51c1 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -238,14 +238,12 @@ impl Command for Whois { &format!(":is in security groups: {}", groups.join(", ")), ); } - // reputation score (opers only, when enabled) - if asker_oper && s.rep_whois { + // reputation score, subject to the configured whois visibility (all/opers/self/none) + if crate::modules::reputation::whois_visible(s, uid, tuid) { let score = crate::modules::reputation::score_of(s, tuid); - s.numeric( - uid, - RPL_WHOISSPECIAL, - &format!(":has a reputation score of {score}"), - ); + if score > 0 { + s.numeric(uid, RPL_WHOISSPECIAL, &format!(":Score: {score}")); + } } // opers can see through the cloak to the real host/ip if asker_oper && disp != realhost { diff --git a/src/modules/reputation.rs b/src/modules/reputation.rs index c1293fd..2c70bc7 100644 --- a/src/modules/reputation.rs +++ b/src/modules/reputation.rs @@ -1,24 +1,62 @@ -//! 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. +//! reputation — InspIRCd `m_reputation` (© reverse). Per-network-address reputation +//! scoring. Every `bumpinterval` (default 5m) each connected user's masked address +//! gains +1 (+2 if logged into services), provided they're in a channel with at +//! least `minchanmembers` members. Scores decay per the `reputationexpire` rules +//! and persist to disk. Exposes the `y:` score extban, WHOIS visibility, and the +//! `REPUTATION` oper command. Everything is config-driven (see `[reputation_*]`). use std::collections::HashMap; -use std::net::IpAddr; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use crate::command::{CmdResult, Command}; use crate::module::Module; use crate::numeric::{ERR_NOPRIVILEGES, ERR_NOSUCHNICK}; -use crate::server::Server; +use crate::server::{now, Server}; use crate::Uid; -/// per-IP reputation score. Stored in `Server.ext`. +/// One address's score plus when it was last active (for the decay rules). +#[derive(Clone, Default)] +pub struct Entry { + pub score: u32, + pub last_seen: u64, +} + +/// masked-address -> entry. Stored in `Server.ext`. #[derive(Default)] -pub struct Reputation(pub HashMap); +pub struct Reputation(pub HashMap); + +/// Mask an address to the configured CIDR prefix so a whole subnet shares a score. +fn mask_ip(ip: IpAddr, v4: u8, v6: u8) -> IpAddr { + match ip { + IpAddr::V4(a) => { + let bits = u32::from(a); + let keep = match v4 { + 0 => 0, + p if p >= 32 => u32::MAX, + p => u32::MAX << (32 - p), + }; + IpAddr::V4(Ipv4Addr::from(bits & keep)) + } + IpAddr::V6(a) => { + let bits = u128::from(a); + let keep = match v6 { + 0 => 0, + p if p >= 128 => u128::MAX, + p => u128::MAX << (128 - p), + }; + IpAddr::V6(Ipv6Addr::from(bits & keep)) + } + } +} + +/// The masked key for `uid`'s address. +fn key_of(s: &Server, uid: Uid) -> Option { + let ip = s.users.get(&uid).map(|u| u.addr.ip())?; + Some(mask_ip(ip, s.rep_ipv4prefix, s.rep_ipv6prefix)) +} /// Whether `uid` is in at least one channel with `min` or more members (the -/// reputation `minchanmembers` gate — stops idle bots farming score alone). +/// `minchanmembers` gate — stops idle bots farming score alone). fn in_active_channel(s: &Server, uid: Uid, min: usize) -> bool { if min <= 1 { return true; @@ -36,11 +74,11 @@ fn in_active_channel(s: &Server, uid: Uid, min: usize) -> bool { .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. +/// The tick-driven bump / expire / save. Tracks seconds since each last ran. #[derive(Default)] pub struct ReputationMod { - secs: u64, // seconds since the last bump + since_bump: u64, + since_expire: u64, since_save: u64, } impl Module for ReputationMod { @@ -48,43 +86,77 @@ impl Module for ReputationMod { "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; + let t = crate::server::TICK_SECS; + self.since_bump += t; + self.since_expire += t; + self.since_save += t; + if self.since_bump >= s.rep_bump_secs { + self.since_bump = 0; + bump_scores(s); } - 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_expire >= s.rep_expire_secs { + self.since_expire = 0; + expire_old(s); } - if self.since_save >= 600 { + if self.since_save >= s.rep_save_secs { self.since_save = 0; save(s); } } } -/// The reputation score of the IP `uid` is connecting from. +/// +1 per connected user's masked address (+1 more if logged in), capped, and +/// refresh their last_seen so active addresses don't decay. +fn bump_scores(s: &mut Server) { + let n = now(); + let cap = s.rep_scorecap; + let min = s.rep_minchanmembers; + let (v4, v6) = (s.rep_ipv4prefix, s.rep_ipv6prefix); + let bumps: Vec<(IpAddr, u32)> = s + .users + .values() + .filter(|u| u.registered) + .filter(|u| in_active_channel(s, u.uid, min)) + .map(|u| { + ( + mask_ip(u.addr.ip(), v4, v6), + 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_default(); + e.score = (e.score + amt).min(cap); + e.last_seen = n; + } +} + +/// Drop entries that have aged out under any matching `reputationexpire` rule. +fn expire_old(s: &mut Server) { + let n = now(); + let rules = s.rep_expire_rules.clone(); + if let Some(store) = s.ext.get_mut::() { + store.0.retain(|_, e| { + let expired = rules.iter().any(|&(score, age)| { + age > 0 + && n.saturating_sub(e.last_seen) > age + && (score == -1 || e.score <= score as u32) + }); + !expired + }); + } +} + +/// The reputation score of the (masked) address `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 { + let Some(k) = key_of(s, uid) else { return 0; }; s.ext .get::() - .and_then(|r| r.0.get(&ip)) - .copied() + .and_then(|r| r.0.get(&k)) + .map(|e| e.score) .unwrap_or(0) } @@ -105,12 +177,22 @@ pub fn score_ban_match(s: &Server, uid: Uid, spec: &str) -> bool { } } +/// Whether the WHOIS `source` may see `target`'s reputation, per the `whois` mode. +pub fn whois_visible(s: &Server, source: Uid, target: Uid) -> bool { + match s.rep_whois.as_str() { + "none" => false, + "self" => source == target, + "opers" => source == target || s.is_oper(source), + _ => true, // "all" + } +} + pub fn commands() -> Vec> { vec![Box::new(ReputationCmd)] } /// REPUTATION — `REPUTATION []` (oper). Show, or set, the reputation -/// of the IP `` is connecting from. +/// of the masked address `` is connecting from. struct ReputationCmd; impl Command for ReputationCmd { fn name(&self) -> &'static str { @@ -136,7 +218,7 @@ impl Command for ReputationCmd { ); return CmdResult::Fail; }; - let Some(ip) = s.users.get(&tuid).map(|u| u.addr.ip()) else { + let Some(k) = key_of(s, tuid) else { return CmdResult::Fail; }; let nick = params[0].clone(); @@ -146,30 +228,25 @@ impl Command for ReputationCmd { .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)); + let (cap, n) = (s.rep_scorecap, now()); + let store = s.ext.get_or_insert_with::(Reputation::default); + let e = store.0.entry(k).or_default(); + e.score = val.min(cap); + e.last_seen = n; save(s); s.send( uid, format!( - ":{} NOTICE {anick} :REPUTATION {nick} ({ip}) set to {val}", + ":{} NOTICE {anick} :REPUTATION {nick} ({k}) set to {val}", s.name ), ); } else { - let score = s - .ext - .get::() - .and_then(|r| r.0.get(&ip)) - .copied() - .unwrap_or(0); + let score = score_of(s, tuid); s.send( uid, format!( - ":{} NOTICE {anick} :REPUTATION {nick} ({ip}) = {score}", + ":{} NOTICE {anick} :REPUTATION {nick} ({k}) = {score}", s.name ), ); @@ -179,15 +256,19 @@ impl Command for ReputationCmd { } fn db_path(s: &Server) -> String { - format!("{}.reputation", s.conf_path) + if s.rep_database.is_empty() { + format!("{}.reputation", s.conf_path) + } else { + s.rep_database.clone() + } } -/// Persist per-IP reputation so it survives a restart. +/// Persist reputation (masked-ip score last_seen per line) 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")); + for (ip, e) in &r.0 { + out.push_str(&format!("{ip} {} {}\n", e.score, e.last_seen)); } } let _ = std::fs::write(db_path(s), out); @@ -202,8 +283,9 @@ pub fn load(s: &mut Server) { 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); + if let (Ok(ip), Ok(score)) = (ip.parse::(), sc.parse::()) { + let last_seen = it.next().and_then(|s| s.parse().ok()).unwrap_or_else(now); + store.0.insert(ip, Entry { score, last_seen }); } } } diff --git a/src/modules/securitygroups.rs b/src/modules/securitygroups.rs index fb3ad0c..0fed47b 100644 --- a/src/modules/securitygroups.rs +++ b/src/modules/securitygroups.rs @@ -54,13 +54,7 @@ fn matches(s: &Server, uid: Uid, g: &SecGroup) -> bool { return false; } if g.score_min.is_some() || g.score_max.is_some() { - let ip = u.addr.ip(); - let score = s - .ext - .get::() - .and_then(|r| r.0.get(&ip)) - .copied() - .unwrap_or(0); + let score = crate::modules::reputation::score_of(s, uid); if g.score_min.is_some_and(|m| score < m) || g.score_max.is_some_and(|m| score > m) { return false; } diff --git a/src/server.rs b/src/server.rs index 2aeb731..6a1e6ab 100644 --- a/src/server.rs +++ b/src/server.rs @@ -145,10 +145,16 @@ pub struct Server { pub oper_umodes: String, // opermodes: umodes set on /OPER pub seenicks: bool, // snotice every nick change pub announce_chan: bool, // chancreate: snotice on channel creation + pub rep_database: String, // reputation: db path ("" = .reputation) + pub rep_ipv4prefix: u8, // reputation: IPv4 CIDR prefix for keying + pub rep_ipv6prefix: u8, // reputation: IPv6 CIDR prefix for keying pub rep_scorecap: u32, // reputation: max score pub rep_bump_secs: u64, // reputation: seconds between bumps + pub rep_expire_secs: u64, // reputation: seconds between expiry runs + pub rep_save_secs: u64, // reputation: seconds between saves pub rep_minchanmembers: usize, // reputation: min channel size to bump - pub rep_whois: bool, // reputation: show score in WHOIS + pub rep_whois: String, // reputation: whois visibility mode + pub rep_expire_rules: Vec<(i32, u64)>, // reputation: (score, age) decay rules // labeled-response: while Some((uid, buf)), that client's own responses are // diverted into `buf` instead of the socket, so `on_line` can wrap them with // the command's `label` (single tag, BATCH, or ACK). RefCell because the @@ -213,10 +219,21 @@ impl Server { oper_umodes: cfg.oper_umodes, seenicks: cfg.seenicks, announce_chan: cfg.announce_chan, + rep_database: cfg.rep_database, + rep_ipv4prefix: cfg.rep_ipv4prefix, + rep_ipv6prefix: cfg.rep_ipv6prefix, rep_scorecap: cfg.rep_scorecap, rep_bump_secs: cfg.rep_bump_secs, + rep_expire_secs: cfg.rep_expire_secs, + rep_save_secs: cfg.rep_save_secs, rep_minchanmembers: cfg.rep_minchanmembers, rep_whois: cfg.rep_whois, + rep_expire_rules: if cfg.rep_expire_rules.is_empty() { + // Unreal defaults: score<=2 after 1h, <=6 after 7d, <=12 after 30d, any after 90d + vec![(2, 3600), (6, 604800), (12, 2592000), (-1, 7776000)] + } else { + cfg.rep_expire_rules + }, label_capture: RefCell::new(None), event_tx, conn_counter,