diff --git a/src/channels.rs b/src/channels.rs index 059da32..79c93dc 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -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..])); diff --git a/src/config.rs b/src/config.rs index 7050aa6..56dc744 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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, // UnrealIRCd-style security groups + pub autojoin: Vec, // 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, // connbanner: NOTICE lines sent on connect + pub oper_autojoin: Vec, // 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 = [public] [tls|insecure] [account|unregistered] // [oper|exclude-oper] [bot|exclude-bot] [webirc|exclude-webirc] diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index a59f138..76e7ce9 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -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( diff --git a/src/modules/reputation.rs b/src/modules/reputation.rs index 4c5bac2..c1293fd 100644 --- a/src/modules/reputation.rs +++ b/src/modules/reputation.rs @@ -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); -/// 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 = 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::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::() + .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)] } @@ -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::().ok()) { + let cap = s.rep_scorecap; s.ext .get_or_insert_with::(Reputation::default) .0 - .insert(ip, val.min(REP_CAP)); + .insert(ip, val.min(cap)); save(s); s.send( uid, diff --git a/src/server.rs b/src/server.rs index cb9368d..2aeb731 100644 --- a/src/server.rs +++ b/src/server.rs @@ -138,6 +138,17 @@ pub struct Server { pub connflood: Option<(u32, u64)>, // (max, secs) connection throttle per IP pub conn_history: HashMap>, // recent connection times per IP (connflood) pub sec_groups: Vec, // UnrealIRCd-style security groups + pub autojoin: Vec, // conn_join: channels joined on connect + pub auto_umodes: String, // conn_umodes: umodes set on connect + pub conn_banner: Vec, // connbanner: NOTICE lines on connect + pub oper_autojoin: Vec, // 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, diff --git a/src/users.rs b/src/users.rs index 8a368f6..6eb4897 100644 --- a/src/users.rs +++ b/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) {