reputation: full parity port — CIDR ipv4/ipv6 masking, reputationexpire decay rules, whois visibility modes, config-driven db/bump/expire/save/minchanmembers/scorecap (no hardcoding)
This commit is contained in:
parent
eb121a75f8
commit
f6fbff5270
6 changed files with 233 additions and 88 deletions
|
|
@ -90,3 +90,17 @@ amu_target = both
|
||||||
# scoremin=<n> scoremax=<n> — use as an extban: MODE #c +b g:<name>
|
# scoremin=<n> scoremax=<n> — use as an extban: MODE #c +b g:<name>
|
||||||
# securitygroup = trusted account tls public
|
# securitygroup = trusted account tls public
|
||||||
# securitygroup = newbies scoremax=10 public
|
# securitygroup = newbies scoremax=10 public
|
||||||
|
|
||||||
|
# --- reputation (m_reputation): per-address scoring + y: score extban ---
|
||||||
|
# reputation_database = reputation.db # default: <conf>.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)
|
||||||
|
|
|
||||||
|
|
@ -114,10 +114,16 @@ pub struct Config {
|
||||||
pub oper_umodes: String, // opermodes: umodes set on /OPER
|
pub oper_umodes: String, // opermodes: umodes set on /OPER
|
||||||
pub seenicks: bool, // snotice every nick change
|
pub seenicks: bool, // snotice every nick change
|
||||||
pub announce_chan: bool, // chancreate: snotice when a channel is created
|
pub announce_chan: bool, // chancreate: snotice when a channel is created
|
||||||
pub rep_scorecap: u32, // reputation: max score
|
pub rep_database: String, // reputation: db file (default <conf>.reputation)
|
||||||
pub rep_bump_secs: u64, // reputation: seconds between score bumps
|
pub rep_ipv4prefix: u8, // reputation: IPv4 CIDR prefix for keying (32)
|
||||||
pub rep_minchanmembers: usize, // reputation: only bump if in a chan this big
|
pub rep_ipv6prefix: u8, // reputation: IPv6 CIDR prefix for keying (64)
|
||||||
pub rep_whois: bool, // reputation: show score in WHOIS (opers)
|
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 {
|
impl Default for Config {
|
||||||
|
|
@ -158,10 +164,16 @@ impl Default for Config {
|
||||||
oper_umodes: String::new(),
|
oper_umodes: String::new(),
|
||||||
seenicks: false,
|
seenicks: false,
|
||||||
announce_chan: false,
|
announce_chan: false,
|
||||||
|
rep_database: String::new(),
|
||||||
|
rep_ipv4prefix: 32,
|
||||||
|
rep_ipv6prefix: 64,
|
||||||
rep_scorecap: 10000,
|
rep_scorecap: 10000,
|
||||||
rep_bump_secs: 300,
|
rep_bump_secs: 300,
|
||||||
rep_minchanmembers: 0,
|
rep_expire_secs: 605,
|
||||||
rep_whois: true,
|
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"
|
"off" | "no" | "false" | "0"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
"reputation_database" => c.rep_database = v.to_string(),
|
||||||
|
"reputation_ipv4prefix" => {
|
||||||
|
if let Ok(n) = v.parse::<u8>() {
|
||||||
|
c.rep_ipv4prefix = n.clamp(1, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"reputation_ipv6prefix" => {
|
||||||
|
if let Ok(n) = v.parse::<u8>() {
|
||||||
|
c.rep_ipv6prefix = n.clamp(1, 128);
|
||||||
|
}
|
||||||
|
}
|
||||||
"reputation_scorecap" => {
|
"reputation_scorecap" => {
|
||||||
if let Ok(n) = v.parse() {
|
if let Ok(n) = v.parse() {
|
||||||
c.rep_scorecap = n;
|
c.rep_scorecap = n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"reputation_bumpinterval" => {
|
"reputation_bumpinterval" => {
|
||||||
if let Some(d) = crate::xline::parse_duration(v) {
|
if let Some(d) = crate::xline::parse_duration(v).filter(|&d| d > 0) {
|
||||||
if d > 0 {
|
c.rep_bump_secs = d;
|
||||||
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" => {
|
"reputation_minchanmembers" => {
|
||||||
|
|
@ -373,11 +404,20 @@ impl Config {
|
||||||
c.rep_minchanmembers = n;
|
c.rep_minchanmembers = n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"reputation_whois" => {
|
"reputation_whois" => c.rep_whois = v.to_ascii_lowercase(),
|
||||||
c.rep_whois = !matches!(
|
"reputationexpire" => {
|
||||||
v.to_ascii_lowercase().as_str(),
|
// reputationexpire = <score|*> <age> (decay rule; * = any score)
|
||||||
"off" | "no" | "false" | "0"
|
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" | "secgroup" => {
|
||||||
// securitygroup = <name> [public] [tls|insecure] [account|unregistered]
|
// securitygroup = <name> [public] [tls|insecure] [account|unregistered]
|
||||||
|
|
|
||||||
|
|
@ -238,14 +238,12 @@ impl Command for Whois {
|
||||||
&format!(":is in security groups: {}", groups.join(", ")),
|
&format!(":is in security groups: {}", groups.join(", ")),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// reputation score (opers only, when enabled)
|
// reputation score, subject to the configured whois visibility (all/opers/self/none)
|
||||||
if asker_oper && s.rep_whois {
|
if crate::modules::reputation::whois_visible(s, uid, tuid) {
|
||||||
let score = crate::modules::reputation::score_of(s, tuid);
|
let score = crate::modules::reputation::score_of(s, tuid);
|
||||||
s.numeric(
|
if score > 0 {
|
||||||
uid,
|
s.numeric(uid, RPL_WHOISSPECIAL, &format!(":Score: {score}"));
|
||||||
RPL_WHOISSPECIAL,
|
}
|
||||||
&format!(":has a reputation score of {score}"),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
// opers can see through the cloak to the real host/ip
|
// opers can see through the cloak to the real host/ip
|
||||||
if asker_oper && disp != realhost {
|
if asker_oper && disp != realhost {
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,62 @@
|
||||||
//! reputation — InspIRCd `m_reputation`. Tracks a per-IP reputation score that
|
//! reputation — InspIRCd `m_reputation` (© reverse). Per-network-address reputation
|
||||||
//! accrues while users from that IP stay connected (roughly, time-online), so
|
//! scoring. Every `bumpinterval` (default 5m) each connected user's masked address
|
||||||
//! opers can tell established users apart from fresh/throwaway connections.
|
//! gains +1 (+2 if logged into services), provided they're in a channel with at
|
||||||
//! Self-contained: the scores live in `Server.ext`, accrue on the tick, and
|
//! least `minchanmembers` members. Scores decay per the `reputationexpire` rules
|
||||||
//! persist to `<conf>.reputation`. `REPUTATION` reads/sets a user's score.
|
//! 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::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||||
|
|
||||||
use crate::command::{CmdResult, Command};
|
use crate::command::{CmdResult, Command};
|
||||||
use crate::module::Module;
|
use crate::module::Module;
|
||||||
use crate::numeric::{ERR_NOPRIVILEGES, ERR_NOSUCHNICK};
|
use crate::numeric::{ERR_NOPRIVILEGES, ERR_NOSUCHNICK};
|
||||||
use crate::server::Server;
|
use crate::server::{now, Server};
|
||||||
use crate::Uid;
|
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)]
|
#[derive(Default)]
|
||||||
pub struct Reputation(pub HashMap<IpAddr, u32>);
|
pub struct Reputation(pub HashMap<IpAddr, Entry>);
|
||||||
|
|
||||||
|
/// 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<IpAddr> {
|
||||||
|
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
|
/// 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 {
|
fn in_active_channel(s: &Server, uid: Uid, min: usize) -> bool {
|
||||||
if min <= 1 {
|
if min <= 1 {
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -36,11 +74,11 @@ fn in_active_channel(s: &Server, uid: Uid, min: usize) -> bool {
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The tick-driven accrual + periodic save. Bumps every `rep_bump_secs` (default
|
/// The tick-driven bump / expire / save. Tracks seconds since each last ran.
|
||||||
/// 5 min): +1 per connected user's IP, +1 more if they're logged into an account.
|
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct ReputationMod {
|
pub struct ReputationMod {
|
||||||
secs: u64, // seconds since the last bump
|
since_bump: u64,
|
||||||
|
since_expire: u64,
|
||||||
since_save: u64,
|
since_save: u64,
|
||||||
}
|
}
|
||||||
impl Module for ReputationMod {
|
impl Module for ReputationMod {
|
||||||
|
|
@ -48,43 +86,77 @@ impl Module for ReputationMod {
|
||||||
"reputation"
|
"reputation"
|
||||||
}
|
}
|
||||||
fn on_tick(&mut self, s: &mut Server) {
|
fn on_tick(&mut self, s: &mut Server) {
|
||||||
self.secs += crate::server::TICK_SECS;
|
let t = crate::server::TICK_SECS;
|
||||||
self.since_save += crate::server::TICK_SECS;
|
self.since_bump += t;
|
||||||
if self.secs < s.rep_bump_secs {
|
self.since_expire += t;
|
||||||
return;
|
self.since_save += t;
|
||||||
|
if self.since_bump >= s.rep_bump_secs {
|
||||||
|
self.since_bump = 0;
|
||||||
|
bump_scores(s);
|
||||||
}
|
}
|
||||||
self.secs = 0;
|
if self.since_expire >= s.rep_expire_secs {
|
||||||
let cap = s.rep_scorecap;
|
self.since_expire = 0;
|
||||||
let min = s.rep_minchanmembers;
|
expire_old(s);
|
||||||
// (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>(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 {
|
if self.since_save >= s.rep_save_secs {
|
||||||
self.since_save = 0;
|
self.since_save = 0;
|
||||||
save(s);
|
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>(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::<Reputation>() {
|
||||||
|
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 {
|
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;
|
return 0;
|
||||||
};
|
};
|
||||||
s.ext
|
s.ext
|
||||||
.get::<Reputation>()
|
.get::<Reputation>()
|
||||||
.and_then(|r| r.0.get(&ip))
|
.and_then(|r| r.0.get(&k))
|
||||||
.copied()
|
.map(|e| e.score)
|
||||||
.unwrap_or(0)
|
.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<Box<dyn Command>> {
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
vec![Box::new(ReputationCmd)]
|
vec![Box::new(ReputationCmd)]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// REPUTATION — `REPUTATION <nick> [<value>]` (oper). Show, or set, the reputation
|
/// REPUTATION — `REPUTATION <nick> [<value>]` (oper). Show, or set, the reputation
|
||||||
/// of the IP `<nick>` is connecting from.
|
/// of the masked address `<nick>` is connecting from.
|
||||||
struct ReputationCmd;
|
struct ReputationCmd;
|
||||||
impl Command for ReputationCmd {
|
impl Command for ReputationCmd {
|
||||||
fn name(&self) -> &'static str {
|
fn name(&self) -> &'static str {
|
||||||
|
|
@ -136,7 +218,7 @@ impl Command for ReputationCmd {
|
||||||
);
|
);
|
||||||
return CmdResult::Fail;
|
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;
|
return CmdResult::Fail;
|
||||||
};
|
};
|
||||||
let nick = params[0].clone();
|
let nick = params[0].clone();
|
||||||
|
|
@ -146,30 +228,25 @@ impl Command for ReputationCmd {
|
||||||
.map(|u| u.nick.clone())
|
.map(|u| u.nick.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
if let Some(val) = params.get(1).and_then(|v| v.parse::<u32>().ok()) {
|
if let Some(val) = params.get(1).and_then(|v| v.parse::<u32>().ok()) {
|
||||||
let cap = s.rep_scorecap;
|
let (cap, n) = (s.rep_scorecap, now());
|
||||||
s.ext
|
let store = s.ext.get_or_insert_with::<Reputation>(Reputation::default);
|
||||||
.get_or_insert_with::<Reputation>(Reputation::default)
|
let e = store.0.entry(k).or_default();
|
||||||
.0
|
e.score = val.min(cap);
|
||||||
.insert(ip, val.min(cap));
|
e.last_seen = n;
|
||||||
save(s);
|
save(s);
|
||||||
s.send(
|
s.send(
|
||||||
uid,
|
uid,
|
||||||
format!(
|
format!(
|
||||||
":{} NOTICE {anick} :REPUTATION {nick} ({ip}) set to {val}",
|
":{} NOTICE {anick} :REPUTATION {nick} ({k}) set to {val}",
|
||||||
s.name
|
s.name
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let score = s
|
let score = score_of(s, tuid);
|
||||||
.ext
|
|
||||||
.get::<Reputation>()
|
|
||||||
.and_then(|r| r.0.get(&ip))
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(0);
|
|
||||||
s.send(
|
s.send(
|
||||||
uid,
|
uid,
|
||||||
format!(
|
format!(
|
||||||
":{} NOTICE {anick} :REPUTATION {nick} ({ip}) = {score}",
|
":{} NOTICE {anick} :REPUTATION {nick} ({k}) = {score}",
|
||||||
s.name
|
s.name
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -179,15 +256,19 @@ impl Command for ReputationCmd {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn db_path(s: &Server) -> String {
|
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) {
|
pub fn save(s: &Server) {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
if let Some(r) = s.ext.get::<Reputation>() {
|
if let Some(r) = s.ext.get::<Reputation>() {
|
||||||
for (ip, score) in &r.0 {
|
for (ip, e) in &r.0 {
|
||||||
out.push_str(&format!("{ip} {score}\n"));
|
out.push_str(&format!("{ip} {} {}\n", e.score, e.last_seen));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let _ = std::fs::write(db_path(s), out);
|
let _ = std::fs::write(db_path(s), out);
|
||||||
|
|
@ -202,8 +283,9 @@ pub fn load(s: &mut Server) {
|
||||||
for line in text.lines() {
|
for line in text.lines() {
|
||||||
let mut it = line.split_whitespace();
|
let mut it = line.split_whitespace();
|
||||||
if let (Some(ip), Some(sc)) = (it.next(), it.next()) {
|
if let (Some(ip), Some(sc)) = (it.next(), it.next()) {
|
||||||
if let (Ok(ip), Ok(sc)) = (ip.parse::<IpAddr>(), sc.parse::<u32>()) {
|
if let (Ok(ip), Ok(score)) = (ip.parse::<IpAddr>(), sc.parse::<u32>()) {
|
||||||
store.0.insert(ip, sc);
|
let last_seen = it.next().and_then(|s| s.parse().ok()).unwrap_or_else(now);
|
||||||
|
store.0.insert(ip, Entry { score, last_seen });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,13 +54,7 @@ fn matches(s: &Server, uid: Uid, g: &SecGroup) -> bool {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if g.score_min.is_some() || g.score_max.is_some() {
|
if g.score_min.is_some() || g.score_max.is_some() {
|
||||||
let ip = u.addr.ip();
|
let score = crate::modules::reputation::score_of(s, uid);
|
||||||
let score = s
|
|
||||||
.ext
|
|
||||||
.get::<crate::modules::reputation::Reputation>()
|
|
||||||
.and_then(|r| r.0.get(&ip))
|
|
||||||
.copied()
|
|
||||||
.unwrap_or(0);
|
|
||||||
if g.score_min.is_some_and(|m| score < m) || g.score_max.is_some_and(|m| score > m) {
|
if g.score_min.is_some_and(|m| score < m) || g.score_max.is_some_and(|m| score > m) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -145,10 +145,16 @@ pub struct Server {
|
||||||
pub oper_umodes: String, // opermodes: umodes set on /OPER
|
pub oper_umodes: String, // opermodes: umodes set on /OPER
|
||||||
pub seenicks: bool, // snotice every nick change
|
pub seenicks: bool, // snotice every nick change
|
||||||
pub announce_chan: bool, // chancreate: snotice on channel creation
|
pub announce_chan: bool, // chancreate: snotice on channel creation
|
||||||
|
pub rep_database: String, // reputation: db path ("" = <conf>.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_scorecap: u32, // reputation: max score
|
||||||
pub rep_bump_secs: u64, // reputation: seconds between bumps
|
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_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
|
// 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
|
// 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
|
// the command's `label` (single tag, BATCH, or ACK). RefCell because the
|
||||||
|
|
@ -213,10 +219,21 @@ impl Server {
|
||||||
oper_umodes: cfg.oper_umodes,
|
oper_umodes: cfg.oper_umodes,
|
||||||
seenicks: cfg.seenicks,
|
seenicks: cfg.seenicks,
|
||||||
announce_chan: cfg.announce_chan,
|
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_scorecap: cfg.rep_scorecap,
|
||||||
rep_bump_secs: cfg.rep_bump_secs,
|
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_minchanmembers: cfg.rep_minchanmembers,
|
||||||
rep_whois: cfg.rep_whois,
|
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),
|
label_capture: RefCell::new(None),
|
||||||
event_tx,
|
event_tx,
|
||||||
conn_counter,
|
conn_counter,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue