modules: move all per-module state/config out of server.rs/config.rs into own files

This commit is contained in:
Jean Chevronnet 2026-08-09 11:18:04 +00:00
parent 0d4c9297b5
commit 131471e245
18 changed files with 525 additions and 425 deletions

60
src/modules/connflood.rs Normal file
View file

@ -0,0 +1,60 @@
//! connflood — InspIRCd `m_connflood`. Refuse connections from an IP opening too
//! many too fast. Config: `connflood = <max> <secs>`. Per-IP recent-connect times
//! live in `Server.ext`, pruned on the tick — nothing lives on `Server`.
use std::collections::HashMap;
use std::net::IpAddr;
use crate::module::Module;
use crate::server::{now, Server};
/// per-IP recent connection timestamps. Stored in `Server.ext`.
#[derive(Default)]
pub struct ConnHistory(pub HashMap<IpAddr, Vec<u64>>);
/// `(max, secs)` from `connflood = <max> <secs>`, or `None` when disabled.
fn cfg(s: &Server) -> Option<(u32, u64)> {
let v = s.conf("connflood")?;
let mut it = v.split_whitespace();
let mx: u32 = it.next()?.parse().ok()?;
let sc: u64 = it.next()?.parse().ok()?;
(mx > 0 && sc > 0).then_some((mx, sc))
}
/// Record a connection from `ip`; returns true when it exceeds the limit (the
/// caller should refuse it). No-op → false when connflood is unconfigured.
pub fn over_limit(s: &mut Server, ip: IpAddr) -> bool {
let Some((max, secs)) = cfg(s) else {
return false;
};
let n = now();
let hist = s
.ext
.get_or_insert_with::<ConnHistory>(ConnHistory::default)
.0
.entry(ip)
.or_default();
hist.retain(|&t| n.saturating_sub(t) < secs);
hist.push(n);
hist.len() as u32 > max
}
/// Prunes stale per-IP bookkeeping on the tick.
pub struct ConnFlood;
impl Module for ConnFlood {
fn name(&self) -> &'static str {
"connflood"
}
fn on_tick(&mut self, s: &mut Server) {
let Some((_, secs)) = cfg(s) else {
return;
};
let n = now();
if let Some(h) = s.ext.get_mut::<ConnHistory>() {
h.0.retain(|_, times| {
times.retain(|&t| n.saturating_sub(t) < secs);
!times.is_empty()
});
}
}
}

26
src/modules/hidewhois.rs Normal file
View file

@ -0,0 +1,26 @@
//! hidewhois — InspIRCd `m_hidewhois`. Hides sensitive WHOIS lines (server, idle,
//! secure, …) from ordinary users. Opers and the user themselves are exempt when
//! the matching config toggle is on. All config-driven; nothing lives on `Server`.
use crate::server::Server;
use crate::Uid;
/// Whether sensitive WHOIS lines should be hidden for this (viewer, target) pair.
pub fn hide(s: &Server, viewer: Uid, target: Uid, viewer_oper: bool) -> bool {
if !s.conf_bool("hidewhois", false) {
return false;
}
let selfview = s.conf_bool("hidewhois_selfview", true);
let opers = s.conf_bool("hidewhois_opers", true);
!(viewer == target && selfview) && !(viewer_oper && opers)
}
pub fn hide_server(s: &Server) -> bool {
s.conf_bool("hidewhois_hide_server", true)
}
pub fn hide_idle(s: &Server) -> bool {
s.conf_bool("hidewhois_hide_idle", true)
}
pub fn hide_secure(s: &Server) -> bool {
s.conf_bool("hidewhois_hide_secure", true)
}

View file

@ -6,15 +6,20 @@
pub mod antimixedutf8;
pub mod chathistory;
pub mod cloak;
pub mod connflood;
pub mod dnsbl;
pub mod filter;
pub mod flood;
pub mod hidewhois;
pub mod markread;
pub mod metadata;
pub mod multiline;
pub mod network_icon;
pub mod profilelink;
pub mod reputation;
pub mod securitygroups;
pub mod snoop;
pub mod whoisport;
use crate::command::Command;
use crate::module::Module;
@ -31,6 +36,7 @@ pub fn default_modules() -> Vec<Box<dyn Module>> {
Box::new(markread::MarkRead),
Box::new(multiline::Multiline),
Box::new(reputation::ReputationMod::default()),
Box::new(connflood::ConnFlood),
]
}

View file

@ -0,0 +1,13 @@
//! ircv3_network_icon — InspIRCd `m_ircv3_network_icon`. Advertises a network icon
//! via the `draft/ICON` ISUPPORT token from `network_icon = <url>`. Config-driven;
//! nothing lives on `Server`.
use crate::server::Server;
/// The `ICON=<url>` ISUPPORT token, or `None` when unconfigured.
pub fn isupport(s: &Server) -> Option<String> {
match s.conf("network_icon") {
Some(url) if !url.is_empty() => Some(format!("ICON={url}")),
_ => None,
}
}

View file

@ -0,0 +1,17 @@
//! profileLink — InspIRCd `m_profileLink`. Adds a profile URL to WHOIS for
//! logged-in users from `profilelink_baseurl = <url>`. Config-driven; nothing
//! lives on `Server`.
use crate::server::Server;
/// The WHOIS profile line for `account`, or `None` when unconfigured.
pub fn line(s: &Server, account: &Option<String>) -> Option<String> {
let base = s.conf("profilelink_baseurl")?;
if base.is_empty() {
return None;
}
Some(match account {
Some(acct) => format!("Profil: {base}{acct}"),
None => "Profile: The user is not logged in or the account is not registered.".to_string(),
})
}

View file

@ -49,10 +49,56 @@ fn mask_ip(ip: IpAddr, v4: u8, v6: u8) -> IpAddr {
}
}
// --- config, read straight from the config file (no fields on Server) ----------
fn v4prefix(s: &Server) -> u8 {
s.conf_num::<u8>("reputation_ipv4prefix", 32).clamp(1, 32)
}
fn v6prefix(s: &Server) -> u8 {
s.conf_num::<u8>("reputation_ipv6prefix", 64).clamp(1, 128)
}
fn scorecap(s: &Server) -> u32 {
s.conf_num("reputation_scorecap", 10000)
}
fn minchan(s: &Server) -> usize {
s.conf_num("reputation_minchanmembers", 3)
}
fn dur(s: &Server, key: &str, def: u64) -> u64 {
s.conf(key)
.and_then(crate::xline::parse_duration)
.filter(|&d| d > 0)
.unwrap_or(def)
}
fn expire_rules(s: &Server) -> Vec<(i32, u64)> {
let rules: Vec<(i32, u64)> = s
.conf_all("reputationexpire")
.iter()
.filter_map(|line| {
let mut it = line.split_whitespace();
let sc = it.next()?;
let age = it.next()?;
let score = if sc == "*" { -1 } else { sc.parse().ok()? };
let age = crate::xline::parse_duration(age).filter(|&a| a > 0)?;
Some((score, age))
})
.collect();
if 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 {
rules
}
}
fn db_path(s: &Server) -> String {
match s.conf("reputation_database") {
Some(p) if !p.is_empty() => p.to_string(),
_ => format!("{}.reputation", s.conf_path),
}
}
/// 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))
Some(mask_ip(ip, v4prefix(s), v6prefix(s)))
}
/// Whether `uid` is in at least one channel with `min` or more members (the
@ -90,15 +136,15 @@ impl Module for ReputationMod {
self.since_bump += t;
self.since_expire += t;
self.since_save += t;
if self.since_bump >= s.rep_bump_secs {
if self.since_bump >= dur(s, "reputation_bumpinterval", 300) {
self.since_bump = 0;
bump_scores(s);
}
if self.since_expire >= s.rep_expire_secs {
if self.since_expire >= dur(s, "reputation_expireinterval", 605) {
self.since_expire = 0;
expire_old(s);
}
if self.since_save >= s.rep_save_secs {
if self.since_save >= dur(s, "reputation_saveinterval", 902) {
self.since_save = 0;
save(s);
}
@ -109,9 +155,9 @@ impl Module for ReputationMod {
/// 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 cap = scorecap(s);
let min = minchan(s);
let (v4, v6) = (v4prefix(s), v6prefix(s));
let bumps: Vec<(IpAddr, u32)> = s
.users
.values()
@ -135,7 +181,7 @@ fn bump_scores(s: &mut Server) {
/// 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();
let rules = expire_rules(s);
if let Some(store) = s.ext.get_mut::<Reputation>() {
store.0.retain(|_, e| {
let expired = rules.iter().any(|&(score, age)| {
@ -179,7 +225,7 @@ 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() {
match s.conf("reputation_whois").unwrap_or("all") {
"none" => false,
"self" => source == target,
"opers" => source == target || s.is_oper(source),
@ -228,7 +274,7 @@ 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, n) = (s.rep_scorecap, now());
let (cap, n) = (scorecap(s), now());
let store = s.ext.get_or_insert_with::<Reputation>(Reputation::default);
let e = store.0.entry(k).or_default();
e.score = val.min(cap);
@ -255,14 +301,6 @@ impl Command for ReputationCmd {
}
}
fn db_path(s: &Server) -> String {
if s.rep_database.is_empty() {
format!("{}.reputation", s.conf_path)
} else {
s.rep_database.clone()
}
}
/// Persist reputation (masked-ip score last_seen per line) so it survives a restart.
pub fn save(s: &Server) {
let mut out = String::new();

View file

@ -6,11 +6,80 @@
use crate::channels::glob_match;
use crate::command::{CmdResult, Command};
use crate::config::{SecGroup, Tri};
use crate::numeric::ERR_NOSUCHNICK;
use crate::server::Server;
use crate::Uid;
/// Tri-state for a criterion: don't-care / must-be / must-not-be.
#[derive(Clone, Copy, PartialEq, Default)]
enum Tri {
#[default]
Ignore,
Yes,
No,
}
/// A UnrealIRCd-style security group — all criteria AND-ed.
#[derive(Clone, Default)]
struct SecGroup {
name: String,
public: bool,
masks: Vec<String>,
exclude_masks: Vec<String>,
tls: Tri,
account: Tri,
oper: Tri,
bot: Tri,
webirc: Tri,
score_min: Option<u32>,
score_max: Option<u32>,
}
/// Parse the `securitygroup = <name> [criteria…]` config lines into groups.
fn parse_groups(s: &Server) -> Vec<SecGroup> {
let mut out = Vec::new();
for line in s
.conf_all("securitygroup")
.iter()
.chain(s.conf_all("secgroup"))
{
let mut it = line.split_whitespace();
let Some(name) = it.next() else { continue };
let mut g = SecGroup {
name: name.to_string(),
..Default::default()
};
for tok in it {
let (k, val) = match tok.split_once('=') {
Some((a, b)) => (a, Some(b)),
None => (tok, None),
};
match (k, val) {
("public", _) => g.public = true,
("mask", Some(m)) => g.masks.push(m.to_string()),
("exclude", Some(m)) | ("exclude-mask", Some(m)) => {
g.exclude_masks.push(m.to_string())
}
("tls", _) | ("tls-users", _) => g.tls = Tri::Yes,
("insecure", _) | ("exclude-tls", _) => g.tls = Tri::No,
("account", _) | ("registered", _) => g.account = Tri::Yes,
("unregistered", _) | ("exclude-account", _) => g.account = Tri::No,
("oper", _) => g.oper = Tri::Yes,
("exclude-oper", _) => g.oper = Tri::No,
("bot", _) | ("bmode", _) => g.bot = Tri::Yes,
("exclude-bot", _) | ("exclude-bmode", _) => g.bot = Tri::No,
("webirc", _) => g.webirc = Tri::Yes,
("exclude-webirc", _) => g.webirc = Tri::No,
("scoremin", Some(n)) => g.score_min = n.parse().ok(),
("scoremax", Some(n)) => g.score_max = n.parse().ok(),
_ => {}
}
}
out.push(g);
}
out
}
/// Does `uid`'s identity match `mask` (glob against nick!user@{display,real,ip})?
fn mask_matches(s: &Server, uid: Uid, mask: &str) -> bool {
let Some(u) = s.users.get(&uid) else {
@ -64,17 +133,17 @@ fn matches(s: &Server, uid: Uid, g: &SecGroup) -> bool {
/// Whether `uid` is a member of the named security group (case-insensitive).
pub fn in_group(s: &Server, uid: Uid, name: &str) -> bool {
s.sec_groups
parse_groups(s)
.iter()
.any(|g| g.name.eq_ignore_ascii_case(name) && matches(s, uid, g))
}
/// The names of the groups `uid` is in (only public ones unless `include_private`).
pub fn user_groups(s: &Server, uid: Uid, include_private: bool) -> Vec<String> {
s.sec_groups
.iter()
parse_groups(s)
.into_iter()
.filter(|g| (include_private || g.public) && matches(s, uid, g))
.map(|g| g.name.clone())
.map(|g| g.name)
.collect()
}

21
src/modules/whoisport.rs Normal file
View file

@ -0,0 +1,21 @@
//! whoisport — InspIRCd `m_whoisport`. Shows an IRC operator, in WHOIS, the
//! listener port the target connected to. Config-free (derives the port from the
//! `bind` / `bind_tls` listeners); nothing lives on `Server`.
use crate::server::Server;
use crate::Uid;
fn port_of(addr: &str) -> u16 {
addr.rsplit(':')
.next()
.and_then(|p| p.parse().ok())
.unwrap_or(0)
}
/// The `is using port N` WHOIS line for opers, or `None` if the port is unknown.
pub fn line(s: &Server, target: Uid) -> Option<String> {
let secure = s.users.get(&target).map(|u| u.secure).unwrap_or(false);
let bind = if secure { "bind_tls" } else { "bind" };
let port = s.conf(bind).map(port_of).unwrap_or(0);
(port != 0).then(|| format!("is using port {port}"))
}