reputation: full port — y: score extban (y:<N/y:>N), WHOIS score, +2 for accounts, bump interval/scorecap/minchanmembers config; conn_join/conn_umodes/connbanner/operjoin/opermodes/seenicks/chancreate
This commit is contained in:
parent
64dcbd8bf4
commit
eb121a75f8
6 changed files with 211 additions and 16 deletions
|
|
@ -699,6 +699,15 @@ impl Server {
|
|||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.channels.insert(key.clone());
|
||||
}
|
||||
// chancreate: snotice when a brand-new channel comes into being
|
||||
if is_new && self.announce_chan {
|
||||
let who = self
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
self.snotice(&format!("{who} created channel {name}"));
|
||||
}
|
||||
|
||||
// JOIN broadcast — extended-join clients also get the account + realname
|
||||
let (prefix, acct, realname) = {
|
||||
|
|
@ -892,6 +901,9 @@ impl Server {
|
|||
if b.mask.as_bytes().get(1) == Some(&b':') {
|
||||
match b.mask.as_bytes().first() {
|
||||
Some(b'g') => crate::modules::securitygroups::in_group(self, uid, &b.mask[2..]),
|
||||
Some(b'y') => {
|
||||
crate::modules::reputation::score_ban_match(self, uid, &b.mask[2..])
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
} else {
|
||||
|
|
@ -1100,8 +1112,8 @@ pub fn normalize_mask(m: &str) -> String {
|
|||
pub fn normalize_ban_mask(m: &str) -> String {
|
||||
let b = m.as_bytes();
|
||||
if b.len() >= 2 && b[1] == b':' && (b[0] as char).is_ascii_alphabetic() {
|
||||
// the g: security-group extban's argument is a group name, not a host mask
|
||||
if b[0] == b'g' {
|
||||
// g: (security-group name) and y: (reputation score spec) aren't host masks
|
||||
if b[0] == b'g' || b[0] == b'y' {
|
||||
return m.to_string();
|
||||
}
|
||||
return format!("{}:{}", &m[..1], normalize_mask(&m[2..]));
|
||||
|
|
|
|||
|
|
@ -107,6 +107,17 @@ pub struct Config {
|
|||
pub aliases: Vec<(String, String)>, // command aliases: (name, target-nick)
|
||||
pub connflood: Option<(u32, u64)>, // (max conns, per secs) from one IP before refusing
|
||||
pub sec_groups: Vec<SecGroup>, // UnrealIRCd-style security groups
|
||||
pub autojoin: Vec<String>, // conn_join: channels every user joins on connect
|
||||
pub auto_umodes: String, // conn_umodes: umodes set on connect (e.g. "+ix")
|
||||
pub conn_banner: Vec<String>, // connbanner: NOTICE lines sent on connect
|
||||
pub oper_autojoin: Vec<String>, // operjoin: channels opers join on /OPER
|
||||
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)
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
|
|
@ -140,6 +151,17 @@ impl Default for Config {
|
|||
aliases: Vec::new(),
|
||||
connflood: None,
|
||||
sec_groups: Vec::new(),
|
||||
autojoin: Vec::new(),
|
||||
auto_umodes: String::new(),
|
||||
conn_banner: Vec::new(),
|
||||
oper_autojoin: Vec::new(),
|
||||
oper_umodes: String::new(),
|
||||
seenicks: false,
|
||||
announce_chan: false,
|
||||
rep_scorecap: 10000,
|
||||
rep_bump_secs: 300,
|
||||
rep_minchanmembers: 0,
|
||||
rep_whois: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -309,6 +331,54 @@ impl Config {
|
|||
}
|
||||
}
|
||||
}
|
||||
"autojoin" | "conn_join" => {
|
||||
for chan in v.split([',', ' ']).filter(|c| !c.is_empty()) {
|
||||
c.autojoin.push(chan.to_string());
|
||||
}
|
||||
}
|
||||
"autoumodes" | "conn_umodes" => c.auto_umodes = v.to_string(),
|
||||
"connbanner" => c.conn_banner.push(v.to_string()),
|
||||
"operjoin" => {
|
||||
for chan in v.split([',', ' ']).filter(|c| !c.is_empty()) {
|
||||
c.oper_autojoin.push(chan.to_string());
|
||||
}
|
||||
}
|
||||
"opermodes" | "oper_umodes" => c.oper_umodes = v.to_string(),
|
||||
"seenicks" => {
|
||||
c.seenicks = !matches!(
|
||||
v.to_ascii_lowercase().as_str(),
|
||||
"off" | "no" | "false" | "0"
|
||||
)
|
||||
}
|
||||
"chancreate" | "announce_channels" => {
|
||||
c.announce_chan = !matches!(
|
||||
v.to_ascii_lowercase().as_str(),
|
||||
"off" | "no" | "false" | "0"
|
||||
)
|
||||
}
|
||||
"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;
|
||||
}
|
||||
}
|
||||
}
|
||||
"reputation_minchanmembers" => {
|
||||
if let Ok(n) = v.parse() {
|
||||
c.rep_minchanmembers = n;
|
||||
}
|
||||
}
|
||||
"reputation_whois" => {
|
||||
c.rep_whois = !matches!(
|
||||
v.to_ascii_lowercase().as_str(),
|
||||
"off" | "no" | "false" | "0"
|
||||
)
|
||||
}
|
||||
"securitygroup" | "secgroup" => {
|
||||
// securitygroup = <name> [public] [tls|insecure] [account|unregistered]
|
||||
// [oper|exclude-oper] [bot|exclude-bot] [webirc|exclude-webirc]
|
||||
|
|
|
|||
|
|
@ -238,6 +238,15 @@ impl Command for Whois {
|
|||
&format!(":is in security groups: {}", groups.join(", ")),
|
||||
);
|
||||
}
|
||||
// reputation score (opers only, when enabled)
|
||||
if asker_oper && s.rep_whois {
|
||||
let score = crate::modules::reputation::score_of(s, tuid);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSPECIAL,
|
||||
&format!(":has a reputation score of {score}"),
|
||||
);
|
||||
}
|
||||
// opers can see through the cloak to the real host/ip
|
||||
if asker_oper && disp != realhost {
|
||||
s.numeric(
|
||||
|
|
|
|||
|
|
@ -13,42 +13,98 @@ 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<IpAddr, u32>);
|
||||
|
||||
/// The tick-driven accrual + periodic save. Holds a tick counter of its own.
|
||||
/// 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 {
|
||||
ticks: u32,
|
||||
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) {
|
||||
let ips: Vec<IpAddr> = s
|
||||
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)
|
||||
.map(|u| u.addr.ip())
|
||||
.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 in ips {
|
||||
for (ip, amt) in bumps {
|
||||
let e = store.0.entry(ip).or_insert(0);
|
||||
*e = (*e + 1).min(REP_CAP);
|
||||
*e = (*e + amt).min(cap);
|
||||
}
|
||||
self.ticks += 1;
|
||||
#[allow(clippy::manual_is_multiple_of)] // is_multiple_of is unstable on our MSRV
|
||||
if self.ticks % SAVE_EVERY == 0 {
|
||||
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::<Reputation>()
|
||||
.and_then(|r| r.0.get(&ip))
|
||||
.copied()
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The `y:` score extban: `y:<N` matches a score below N, `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::<u32>() else {
|
||||
return false;
|
||||
};
|
||||
let score = score_of(s, uid);
|
||||
if gt {
|
||||
score > threshold
|
||||
} else {
|
||||
score < threshold
|
||||
}
|
||||
}
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![Box::new(ReputationCmd)]
|
||||
}
|
||||
|
|
@ -90,10 +146,11 @@ impl Command for ReputationCmd {
|
|||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
if let Some(val) = params.get(1).and_then(|v| v.parse::<u32>().ok()) {
|
||||
let cap = s.rep_scorecap;
|
||||
s.ext
|
||||
.get_or_insert_with::<Reputation>(Reputation::default)
|
||||
.0
|
||||
.insert(ip, val.min(REP_CAP));
|
||||
.insert(ip, val.min(cap));
|
||||
save(s);
|
||||
s.send(
|
||||
uid,
|
||||
|
|
|
|||
|
|
@ -138,6 +138,17 @@ pub struct Server {
|
|||
pub connflood: Option<(u32, u64)>, // (max, secs) connection throttle per IP
|
||||
pub conn_history: HashMap<IpAddr, Vec<u64>>, // recent connection times per IP (connflood)
|
||||
pub sec_groups: Vec<crate::config::SecGroup>, // UnrealIRCd-style security groups
|
||||
pub autojoin: Vec<String>, // conn_join: channels joined on connect
|
||||
pub auto_umodes: String, // conn_umodes: umodes set on connect
|
||||
pub conn_banner: Vec<String>, // connbanner: NOTICE lines on connect
|
||||
pub oper_autojoin: Vec<String>, // operjoin: channels opers join on /OPER
|
||||
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_scorecap: u32, // reputation: max score
|
||||
pub rep_bump_secs: u64, // reputation: seconds between bumps
|
||||
pub rep_minchanmembers: usize, // reputation: min channel size to bump
|
||||
pub rep_whois: bool, // reputation: show score in WHOIS
|
||||
// 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
|
||||
|
|
@ -195,6 +206,17 @@ impl Server {
|
|||
connflood: cfg.connflood,
|
||||
conn_history: HashMap::new(),
|
||||
sec_groups: cfg.sec_groups,
|
||||
autojoin: cfg.autojoin,
|
||||
auto_umodes: cfg.auto_umodes,
|
||||
conn_banner: cfg.conn_banner,
|
||||
oper_autojoin: cfg.oper_autojoin,
|
||||
oper_umodes: cfg.oper_umodes,
|
||||
seenicks: cfg.seenicks,
|
||||
announce_chan: cfg.announce_chan,
|
||||
rep_scorecap: cfg.rep_scorecap,
|
||||
rep_bump_secs: cfg.rep_bump_secs,
|
||||
rep_minchanmembers: cfg.rep_minchanmembers,
|
||||
rep_whois: cfg.rep_whois,
|
||||
label_capture: RefCell::new(None),
|
||||
event_tx,
|
||||
conn_counter,
|
||||
|
|
|
|||
27
src/users.rs
27
src/users.rs
|
|
@ -326,6 +326,15 @@ impl Server {
|
|||
self.numeric(uid, RPL_YOUREOPER, ":You are now an IRC operator");
|
||||
self.send(uid, format!(":{} MODE {nick} :+os", self.name));
|
||||
self.snotice(&format!("{nick} is now an IRC operator"));
|
||||
// opermodes: extra umodes on oper-up
|
||||
if !self.oper_umodes.is_empty() {
|
||||
let modes = self.oper_umodes.clone();
|
||||
crate::coremods::core_mode::svs_set_user_modes(self, uid, &modes);
|
||||
}
|
||||
// operjoin: auto-join configured oper channels
|
||||
for chan in self.oper_autojoin.clone() {
|
||||
self.join(uid, &chan, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a WALLOPS to every oper and every +w user.
|
||||
|
|
@ -386,6 +395,9 @@ impl Server {
|
|||
for t in targets {
|
||||
self.send(t, line.clone());
|
||||
}
|
||||
if self.seenicks {
|
||||
self.snotice(&format!("{old} is now known as {newnick}"));
|
||||
}
|
||||
// WATCH/MONITOR: the old nick is now gone, the new one is here
|
||||
self.watch_notify_offline(&old);
|
||||
self.watch_notify_online(newnick);
|
||||
|
|
@ -431,7 +443,7 @@ impl Server {
|
|||
uid,
|
||||
RPL_ISUPPORT,
|
||||
&format!(
|
||||
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,cgmn WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
||||
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIgX,k,lfjFLHBJdK,ACDGMNOPQRSTUcimnpstuz EXTBAN=,cgmny WATCH=128 MONITOR=128 SILENCE=32 CALLERID=g WHOX CHATHISTORY=256 MSGREFTYPES=timestamp,msgid UTF8ONLY CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
||||
self.network
|
||||
),
|
||||
);
|
||||
|
|
@ -441,8 +453,21 @@ impl Server {
|
|||
&format!(":There are {} users on 1 server", self.users.len()),
|
||||
);
|
||||
self.send_motd(uid);
|
||||
// connbanner: NOTICE lines to every connecting client
|
||||
for line in self.conn_banner.clone() {
|
||||
self.send(uid, format!(":{} NOTICE {nick} :{line}", self.name));
|
||||
}
|
||||
// conn_umodes: auto-set user modes on connect
|
||||
if !self.auto_umodes.is_empty() {
|
||||
let modes = self.auto_umodes.clone();
|
||||
crate::coremods::core_mode::svs_set_user_modes(self, uid, &modes);
|
||||
}
|
||||
self.watch_notify_online(&nick); // tell WATCH/MONITOR watchers
|
||||
self.events.push_back(Hook::Connect(uid));
|
||||
// conn_join: auto-join configured channels
|
||||
for chan in self.autojoin.clone() {
|
||||
self.join(uid, &chan, None);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn send_motd(&self, uid: Uid) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue