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

View file

@ -700,7 +700,9 @@ impl Server {
u.channels.insert(key.clone()); u.channels.insert(key.clone());
} }
// chancreate: snotice when a brand-new channel comes into being // chancreate: snotice when a brand-new channel comes into being
if is_new && self.announce_chan { if is_new
&& (self.conf_bool("chancreate", false) || self.conf_bool("announce_channels", false))
{
let who = self let who = self
.users .users
.get(&uid) .get(&uid)

View file

@ -8,40 +8,16 @@
//! oper = god secret //! oper = god secret
//! ``` //! ```
use std::collections::HashMap;
/// Parse a boolean config value (`yes`/`no`/`true`/`false`/`on`/`off`/`1`/`0`). /// Parse a boolean config value (`yes`/`no`/`true`/`false`/`on`/`off`/`1`/`0`).
fn yesish(v: &str) -> bool { pub fn yesish(v: &str) -> bool {
!matches!( !matches!(
v.to_ascii_lowercase().as_str(), v.to_ascii_lowercase().as_str(),
"off" | "no" | "false" | "0" "off" | "no" | "false" | "0"
) )
} }
/// Tri-state for a security-group criterion: don't-care / must-be / must-not-be.
#[derive(Clone, Copy, PartialEq, Default)]
pub enum Tri {
#[default]
Ignore,
Yes,
No,
}
/// A UnrealIRCd-style security group (InspIRCd `m_securitygroups`). All criteria
/// are AND-ed: a user is a member iff every set criterion matches.
#[derive(Clone, Default)]
pub struct SecGroup {
pub name: String,
pub public: bool, // shown to non-opers
pub masks: Vec<String>, // positive: match any one nick!user@host glob
pub exclude_masks: Vec<String>, // negative: matching any one vetoes membership
pub tls: Tri,
pub account: Tri,
pub oper: Tri,
pub bot: Tri,
pub webirc: Tri,
pub score_min: Option<u32>, // reputation lower bound
pub score_max: Option<u32>, // reputation upper bound
}
/// A server-link block: how to authenticate a peer named `name` (and, if /// A server-link block: how to authenticate a peer named `name` (and, if
/// `autoconnect`, where to dial it). Passwords are the shared link secret. /// `autoconnect`, where to dial it). Passwords are the shared link secret.
#[derive(Clone)] #[derive(Clone)]
@ -110,37 +86,9 @@ pub struct Config {
pub dnsbl_reason: String, // ban reason for a DNSBL hit pub dnsbl_reason: String, // ban reason for a DNSBL hit
pub sasl_server: String, // linked services server that handles SASL ("" = none) pub sasl_server: String, // linked services server that handles SASL ("" = none)
pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask) pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask)
pub opermotd: Vec<String>, // OPERMOTD text, one line per entry /// Every `key = value` line, captured raw so modules read their own settings
pub vhosts: Vec<(String, String, String)>, // self-service vhosts: (user, pass, host) /// via `Server::conf*` — no per-module field bloats this struct or `Server`.
pub aliases: Vec<(String, String)>, // command aliases: (name, target-nick) pub raw: HashMap<String, Vec<String>>,
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_database: String, // reputation: db file (default <conf>.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
pub network_icon: String, // ircv3_network_icon: draft/ICON ISUPPORT url
pub profilelink_baseurl: String, // profileLink: WHOIS profile url base
pub hidewhois: bool, // hidewhois: hide sensitive WHOIS lines from users
pub hidewhois_opers: bool, // hidewhois: opers still see everything (default yes)
pub hidewhois_selfview: bool, // hidewhois: a user sees their own full WHOIS (yes)
pub hidewhois_server: bool, // hidewhois: hide 312 server line
pub hidewhois_idle: bool, // hidewhois: hide 317 idle line
pub hidewhois_away: bool, // hidewhois: hide 301 away line
pub hidewhois_secure: bool, // hidewhois: hide 671 secure line
} }
impl Default for Config { impl Default for Config {
@ -169,37 +117,7 @@ impl Default for Config {
dnsbl_reason: "Your host is listed in a DNS blocklist".to_string(), dnsbl_reason: "Your host is listed in a DNS blocklist".to_string(),
sasl_server: String::new(), sasl_server: String::new(),
webirc: Vec::new(), webirc: Vec::new(),
opermotd: Vec::new(), raw: HashMap::new(),
vhosts: Vec::new(),
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_database: String::new(),
rep_ipv4prefix: 32,
rep_ipv6prefix: 64,
rep_scorecap: 10000,
rep_bump_secs: 300,
rep_expire_secs: 605,
rep_save_secs: 902,
rep_minchanmembers: 3,
rep_whois: "all".to_string(),
rep_expire_rules: Vec::new(),
network_icon: String::new(),
profilelink_baseurl: String::new(),
hidewhois: false,
hidewhois_opers: true,
hidewhois_selfview: true,
hidewhois_server: true,
hidewhois_idle: true,
hidewhois_away: true,
hidewhois_secure: true,
} }
} }
} }
@ -244,6 +162,9 @@ impl Config {
continue; continue;
}; };
let (k, v) = (k.trim(), v.trim()); let (k, v) = (k.trim(), v.trim());
// Every line is captured raw so a module can read its own settings via
// `Server::conf*` without a typed field bloating config.rs / server.rs.
c.raw.entry(k.to_string()).or_default().push(v.to_string());
match k { match k {
"servername" | "server" => c.servername = v.to_string(), "servername" | "server" => c.servername = v.to_string(),
"network" => c.network = v.to_string(), "network" => c.network = v.to_string(),
@ -344,162 +265,6 @@ impl Config {
c.webirc.push((pass.to_string(), gw, mask)); c.webirc.push((pass.to_string(), gw, mask));
} }
} }
"opermotd" => c.opermotd.push(v.to_string()),
"vhost" => {
// vhost = <user> <pass> <host>
let mut it = v.split_whitespace();
if let (Some(u), Some(p), Some(h)) = (it.next(), it.next(), it.next()) {
c.vhosts.push((u.to_string(), p.to_string(), h.to_string()));
}
}
"alias" => {
// alias = <command> <target-nick> (e.g. `alias = NS NickServ`)
let mut it = v.split_whitespace();
if let (Some(name), Some(target)) = (it.next(), it.next()) {
c.aliases
.push((name.to_ascii_uppercase(), target.to_string()));
}
}
"connflood" => {
// connflood = <max> <secs> — refuse >max connections/secs from one IP
let mut it = v.split_whitespace();
if let (Some(mx), Some(sc)) = (it.next(), it.next()) {
if let (Ok(mx), Ok(sc)) = (mx.parse::<u32>(), sc.parse::<u64>()) {
if mx > 0 && sc > 0 {
c.connflood = Some((mx, sc));
}
}
}
}
"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_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" => {
if let Ok(n) = v.parse() {
c.rep_scorecap = n;
}
}
"reputation_bumpinterval" => {
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" => {
if let Ok(n) = v.parse() {
c.rep_minchanmembers = n;
}
}
"reputation_whois" => c.rep_whois = v.to_ascii_lowercase(),
"reputationexpire" => {
// reputationexpire = <score|*> <age> (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));
}
}
}
"network_icon" | "networkicon" => c.network_icon = v.to_string(),
"profilelink" | "profilelink_baseurl" => c.profilelink_baseurl = v.to_string(),
"hidewhois" => {
c.hidewhois = !matches!(
v.to_ascii_lowercase().as_str(),
"off" | "no" | "false" | "0"
)
}
"hidewhois_opers" => c.hidewhois_opers = yesish(v),
"hidewhois_selfview" => c.hidewhois_selfview = yesish(v),
"hidewhois_hide_server" => c.hidewhois_server = yesish(v),
"hidewhois_hide_idle" => c.hidewhois_idle = yesish(v),
"hidewhois_hide_away" => c.hidewhois_away = yesish(v),
"hidewhois_hide_secure" => c.hidewhois_secure = yesish(v),
"securitygroup" | "secgroup" => {
// securitygroup = <name> [public] [tls|insecure] [account|unregistered]
// [oper|exclude-oper] [bot|exclude-bot] [webirc|exclude-webirc]
// [mask=<glob>]... [exclude=<glob>]... [scoremin=N] [scoremax=N]
let mut it = v.split_whitespace();
if let Some(name) = it.next() {
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(),
_ => {}
}
}
c.sec_groups.push(g);
}
}
_ => {} _ => {}
} }
} }

View file

@ -149,8 +149,7 @@ impl Command for Whois {
let asker_oper = s.is_oper(uid); let asker_oper = s.is_oper(uid);
let is_self = tuid == uid; let is_self = tuid == uid;
// hidewhois: hide sensitive lines from ordinary users (opers/self exempt per config) // hidewhois: hide sensitive lines from ordinary users (opers/self exempt per config)
let hide = let hide = crate::modules::hidewhois::hide(s, uid, tuid, asker_oper);
s.hidewhois && !(is_self && s.hidewhois_selfview) && !(asker_oper && s.hidewhois_opers);
let keys: Vec<String> = s.users[&tuid].channels.iter().cloned().collect(); let keys: Vec<String> = s.users[&tuid].channels.iter().cloned().collect();
let ( let (
nick, nick,
@ -206,7 +205,7 @@ impl Command for Whois {
if bot { if bot {
s.numeric(uid, RPL_WHOISBOT, &format!("{nick} :is a bot")); s.numeric(uid, RPL_WHOISBOT, &format!("{nick} :is a bot"));
} }
if !(hide && s.hidewhois_server) { if !(hide && crate::modules::hidewhois::hide_server(s)) {
s.numeric( s.numeric(
uid, uid,
RPL_WHOISSERVER, RPL_WHOISSERVER,
@ -251,25 +250,13 @@ impl Command for Whois {
} }
} }
// profileLink: a profile URL for logged-in users (when configured) // profileLink: a profile URL for logged-in users (when configured)
if !s.profilelink_baseurl.is_empty() { if let Some(line) = crate::modules::profilelink::line(s, &account) {
match &account { s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}"));
Some(acct) => s.numeric(
uid,
RPL_WHOISSPECIAL,
&format!(":Profil: {}{acct}", s.profilelink_baseurl),
),
None => s.numeric(
uid,
RPL_WHOISSPECIAL,
":Profile: The user is not logged in or the account is not registered.",
),
}
} }
// whoisport: the listener port (+ TLS/plain) — opers only // whoisport: the listener port — opers only
if asker_oper { if asker_oper {
let port = if secure { s.tls_port } else { s.plain_port }; if let Some(line) = crate::modules::whoisport::line(s, tuid) {
if port != 0 { s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}"));
s.numeric(uid, RPL_WHOISSPECIAL, &format!(":is using port {port}"));
} }
} }
// opers can see through the cloak to the real host/ip // opers can see through the cloak to the real host/ip
@ -289,7 +276,7 @@ impl Command for Whois {
); );
} }
// sslinfo: advertise a secure (TLS) connection // sslinfo: advertise a secure (TLS) connection
if secure && !(hide && s.hidewhois_secure) { if secure && !(hide && crate::modules::hidewhois::hide_secure(s)) {
s.numeric( s.numeric(
uid, uid,
RPL_WHOISSECURE, RPL_WHOISSECURE,
@ -307,7 +294,7 @@ impl Command for Whois {
} }
} }
// 317: idle time + signon time (hidewhois may suppress it) // 317: idle time + signon time (hidewhois may suppress it)
if !(hide && s.hidewhois_idle) { if !(hide && crate::modules::hidewhois::hide_idle(s)) {
let idle = crate::server::now().saturating_sub(last_active); let idle = crate::server::now().saturating_sub(last_active);
s.numeric( s.numeric(
uid, uid,

View file

@ -68,7 +68,8 @@ impl Command for OperMotd {
return CmdResult::Fail; return CmdResult::Fail;
} }
let nick = oper_nick(s, uid); let nick = oper_nick(s, uid);
if s.opermotd.is_empty() { let motd = s.conf_all("opermotd").to_vec();
if motd.is_empty() {
s.send( s.send(
uid, uid,
format!(":{} NOTICE {nick} :No OPERMOTD is set", s.name), format!(":{} NOTICE {nick} :No OPERMOTD is set", s.name),
@ -82,7 +83,7 @@ impl Command for OperMotd {
s.name s.name
), ),
); );
for line in s.opermotd.clone() { for line in motd {
s.send(uid, format!(":{} NOTICE {nick} :- {line}", s.name)); s.send(uid, format!(":{} NOTICE {nick} :- {line}", s.name));
} }
s.send( s.send(

View file

@ -38,11 +38,14 @@ impl Command for Vhost {
} }
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
let (user, pass) = (&params[0], &params[1]); let (user, pass) = (&params[0], &params[1]);
let host = s // vhost blocks live in the config: `vhost = <user> <pass> <host>`
.vhosts let host = s.conf_all("vhost").iter().find_map(|line| {
.iter() let mut it = line.split_whitespace();
.find(|(u, p, _)| u == user && p == pass) match (it.next(), it.next(), it.next()) {
.map(|(_, _, h)| h.clone()); (Some(u), Some(p), Some(h)) if u == user && p == pass => Some(h.to_string()),
_ => None,
}
});
let nick = s let nick = s
.users .users
.get(&uid) .get(&uid)

142
src/http.rs Normal file
View file

@ -0,0 +1,142 @@
//! Minimal blocking HTTP/HTTPS client — `std::net::TcpStream` + openssl for TLS.
//! No new crate, no `unsafe`. Modules that talk to external APIs (account
//! registration, captcha verification, …) use this from a **worker thread** and
//! deliver the result back to the core as an [`crate::ircd::Event`], exactly like
//! the DNS/DNSBL lookups — so a slow or hung endpoint never blocks the main loop.
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::Duration;
use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode};
/// POST `body` to `url` with `content_type` and extra `headers`. Blocking.
/// Returns `(status_code, response_body)` or an error string.
pub fn post(
url: &str,
content_type: &str,
body: &str,
headers: &[(String, String)],
timeout: Duration,
) -> Result<(u16, String), String> {
let (scheme, rest) = url.split_once("://").ok_or("bad url (no scheme)")?;
let (hostport, path) = match rest.split_once('/') {
Some((hp, p)) => (hp, format!("/{p}")),
None => (rest, "/".to_string()),
};
let https = scheme.eq_ignore_ascii_case("https");
let (host, port): (&str, u16) = match hostport.rsplit_once(':') {
Some((h, p)) => (h, p.parse().unwrap_or(if https { 443 } else { 80 })),
None => (hostport, if https { 443 } else { 80 }),
};
let mut req = format!(
"POST {path} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: echoIRCd\r\nAccept: */*\r\n\
Content-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n",
body.len()
);
for (k, v) in headers {
req.push_str(&format!("{k}: {v}\r\n"));
}
req.push_str("\r\n");
req.push_str(body);
let stream = TcpStream::connect((host, port)).map_err(|e| e.to_string())?;
stream.set_read_timeout(Some(timeout)).ok();
stream.set_write_timeout(Some(timeout)).ok();
let raw = if https {
let mut b = SslConnector::builder(SslMethod::tls()).map_err(|e| e.to_string())?;
b.set_verify(SslVerifyMode::NONE); // APIs are usually behind a trusted reverse proxy
let connector = b.build();
let mut tls = connector.connect(host, stream).map_err(|e| e.to_string())?;
tls.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
let mut buf = Vec::new();
let _ = tls.read_to_end(&mut buf); // close => EOF (Connection: close)
buf
} else {
let mut s = stream;
s.write_all(req.as_bytes()).map_err(|e| e.to_string())?;
let mut buf = Vec::new();
let _ = s.read_to_end(&mut buf);
buf
};
let resp = String::from_utf8_lossy(&raw).into_owned();
let (head, rbody) = resp.split_once("\r\n\r\n").unwrap_or((resp.as_str(), ""));
let status = head
.lines()
.next()
.and_then(|l| l.split_whitespace().nth(1))
.and_then(|c| c.parse::<u16>().ok())
.unwrap_or(0);
let chunked = head
.to_ascii_lowercase()
.contains("transfer-encoding: chunked");
let out = if chunked {
dechunk(rbody)
} else {
rbody.to_string()
};
Ok((status, out))
}
/// Decode an HTTP/1.1 chunked body (best effort).
fn dechunk(body: &str) -> String {
let mut out = String::new();
let mut rest = body;
while let Some((size_line, after)) = rest.split_once("\r\n") {
let size = usize::from_str_radix(size_line.trim().split(';').next().unwrap_or("0"), 16)
.unwrap_or(0);
if size == 0 || after.len() < size {
out.push_str(&after[..after.len().min(size)]);
break;
}
out.push_str(&after[..size]);
rest = after[size..].strip_prefix("\r\n").unwrap_or(&after[size..]);
}
out
}
/// application/x-www-form-urlencoded escape of a single value.
pub fn urlencode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char)
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// Pull a top-level `"key": value` out of a flat JSON object — dependency-free,
/// good enough for the small, known API responses these modules parse.
pub fn json_str(body: &str, key: &str) -> Option<String> {
let pat = format!("\"{key}\"");
let start = body.find(&pat)? + pat.len();
let after_colon = body[start..].find(':')? + start + 1;
let rest = body[after_colon..].trim_start();
if let Some(r) = rest.strip_prefix('"') {
let mut out = String::new();
let mut chars = r.chars();
while let Some(ch) = chars.next() {
match ch {
'\\' => {
if let Some(n) = chars.next() {
out.push(n);
}
}
'"' => return Some(out),
_ => out.push(ch),
}
}
None
} else {
let end = rest.find([',', '}', '\n']).unwrap_or(rest.len());
Some(rest[..end].trim().to_string())
}
}

View file

@ -230,14 +230,15 @@ impl Ircd {
let Some(handler) = self.commands.get(cmd) else { let Some(handler) = self.commands.get(cmd) else {
if registered { if registered {
// command aliases (m_alias): `/NS help` -> PRIVMSG NickServ :help // command aliases (m_alias): config `alias = <CMD> <target-nick>`
if let Some(target) = self // e.g. `alias = NS NickServ` makes `/NS help` -> PRIVMSG NickServ :help
.server if let Some(target) = self.server.conf_all("alias").iter().find_map(|line| {
.aliases let mut it = line.split_whitespace();
.iter() match (it.next(), it.next()) {
.find(|(n, _)| n == cmd) (Some(n), Some(t)) if n.eq_ignore_ascii_case(cmd) => Some(t.to_string()),
.map(|(_, t)| t.clone()) _ => None,
{ }
}) {
if !msg.params.is_empty() { if !msg.params.is_empty() {
let text = msg.params.join(" "); let text = msg.params.join(" ");
crate::coremods::core_message::deliver( crate::coremods::core_message::deliver(
@ -377,7 +378,6 @@ impl Ircd {
self.server.ping_links(); // keepalive on every server link self.server.ping_links(); // keepalive on every server link
self.server.purge_xlines(); // drop expired server bans self.server.purge_xlines(); // drop expired server bans
self.server.purge_tbans(); // lift expired timed channel bans (TBAN) self.server.purge_tbans(); // lift expired timed channel bans (TBAN)
self.server.prune_conn_history(); // connflood bookkeeping
for m in &mut self.modules { for m in &mut self.modules {
m.on_tick(&mut self.server); // timer-driven modules (e.g. reputation) m.on_tick(&mut self.server); // timer-driven modules (e.g. reputation)
} }

View file

@ -18,6 +18,7 @@ pub mod command;
pub mod config; pub mod config;
pub mod coremods; pub mod coremods;
pub mod extensible; pub mod extensible;
pub mod http;
pub mod ircd; pub mod ircd;
pub mod link; pub mod link;
pub mod message; pub mod message;

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 antimixedutf8;
pub mod chathistory; pub mod chathistory;
pub mod cloak; pub mod cloak;
pub mod connflood;
pub mod dnsbl; pub mod dnsbl;
pub mod filter; pub mod filter;
pub mod flood; pub mod flood;
pub mod hidewhois;
pub mod markread; pub mod markread;
pub mod metadata; pub mod metadata;
pub mod multiline; pub mod multiline;
pub mod network_icon;
pub mod profilelink;
pub mod reputation; pub mod reputation;
pub mod securitygroups; pub mod securitygroups;
pub mod snoop; pub mod snoop;
pub mod whoisport;
use crate::command::Command; use crate::command::Command;
use crate::module::Module; use crate::module::Module;
@ -31,6 +36,7 @@ pub fn default_modules() -> Vec<Box<dyn Module>> {
Box::new(markread::MarkRead), Box::new(markread::MarkRead),
Box::new(multiline::Multiline), Box::new(multiline::Multiline),
Box::new(reputation::ReputationMod::default()), 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. /// The masked key for `uid`'s address.
fn key_of(s: &Server, uid: Uid) -> Option<IpAddr> { fn key_of(s: &Server, uid: Uid) -> Option<IpAddr> {
let ip = s.users.get(&uid).map(|u| u.addr.ip())?; 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 /// 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_bump += t;
self.since_expire += t; self.since_expire += t;
self.since_save += 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; self.since_bump = 0;
bump_scores(s); bump_scores(s);
} }
if self.since_expire >= s.rep_expire_secs { if self.since_expire >= dur(s, "reputation_expireinterval", 605) {
self.since_expire = 0; self.since_expire = 0;
expire_old(s); expire_old(s);
} }
if self.since_save >= s.rep_save_secs { if self.since_save >= dur(s, "reputation_saveinterval", 902) {
self.since_save = 0; self.since_save = 0;
save(s); save(s);
} }
@ -109,9 +155,9 @@ impl Module for ReputationMod {
/// refresh their last_seen so active addresses don't decay. /// refresh their last_seen so active addresses don't decay.
fn bump_scores(s: &mut Server) { fn bump_scores(s: &mut Server) {
let n = now(); let n = now();
let cap = s.rep_scorecap; let cap = scorecap(s);
let min = s.rep_minchanmembers; let min = minchan(s);
let (v4, v6) = (s.rep_ipv4prefix, s.rep_ipv6prefix); let (v4, v6) = (v4prefix(s), v6prefix(s));
let bumps: Vec<(IpAddr, u32)> = s let bumps: Vec<(IpAddr, u32)> = s
.users .users
.values() .values()
@ -135,7 +181,7 @@ fn bump_scores(s: &mut Server) {
/// Drop entries that have aged out under any matching `reputationexpire` rule. /// Drop entries that have aged out under any matching `reputationexpire` rule.
fn expire_old(s: &mut Server) { fn expire_old(s: &mut Server) {
let n = now(); let n = now();
let rules = s.rep_expire_rules.clone(); let rules = expire_rules(s);
if let Some(store) = s.ext.get_mut::<Reputation>() { if let Some(store) = s.ext.get_mut::<Reputation>() {
store.0.retain(|_, e| { store.0.retain(|_, e| {
let expired = rules.iter().any(|&(score, age)| { 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. /// Whether the WHOIS `source` may see `target`'s reputation, per the `whois` mode.
pub fn whois_visible(s: &Server, source: Uid, target: Uid) -> bool { 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, "none" => false,
"self" => source == target, "self" => source == target,
"opers" => source == target || s.is_oper(source), "opers" => source == target || s.is_oper(source),
@ -228,7 +274,7 @@ 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, n) = (s.rep_scorecap, now()); let (cap, n) = (scorecap(s), now());
let store = s.ext.get_or_insert_with::<Reputation>(Reputation::default); let store = s.ext.get_or_insert_with::<Reputation>(Reputation::default);
let e = store.0.entry(k).or_default(); let e = store.0.entry(k).or_default();
e.score = val.min(cap); 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. /// 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();

View file

@ -6,11 +6,80 @@
use crate::channels::glob_match; use crate::channels::glob_match;
use crate::command::{CmdResult, Command}; use crate::command::{CmdResult, Command};
use crate::config::{SecGroup, Tri};
use crate::numeric::ERR_NOSUCHNICK; use crate::numeric::ERR_NOSUCHNICK;
use crate::server::Server; use crate::server::Server;
use crate::Uid; 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})? /// Does `uid`'s identity match `mask` (glob against nick!user@{display,real,ip})?
fn mask_matches(s: &Server, uid: Uid, mask: &str) -> bool { fn mask_matches(s: &Server, uid: Uid, mask: &str) -> bool {
let Some(u) = s.users.get(&uid) else { 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). /// Whether `uid` is a member of the named security group (case-insensitive).
pub fn in_group(s: &Server, uid: Uid, name: &str) -> bool { pub fn in_group(s: &Server, uid: Uid, name: &str) -> bool {
s.sec_groups parse_groups(s)
.iter() .iter()
.any(|g| g.name.eq_ignore_ascii_case(name) && matches(s, uid, g)) .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`). /// 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> { pub fn user_groups(s: &Server, uid: Uid, include_private: bool) -> Vec<String> {
s.sec_groups parse_groups(s)
.iter() .into_iter()
.filter(|g| (include_private || g.public) && matches(s, uid, g)) .filter(|g| (include_private || g.public) && matches(s, uid, g))
.map(|g| g.name.clone()) .map(|g| g.name)
.collect() .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}"))
}

View file

@ -7,7 +7,7 @@
use std::cell::RefCell; use std::cell::RefCell;
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::net::{IpAddr, SocketAddr, TcpStream}; use std::net::{SocketAddr, TcpStream};
use std::sync::atomic::AtomicU64; use std::sync::atomic::AtomicU64;
use std::sync::mpsc::Sender; use std::sync::mpsc::Sender;
use std::sync::Arc; use std::sync::Arc;
@ -35,14 +35,6 @@ pub const PING_AFTER: u64 = 90;
pub const PING_TIMEOUT: u64 = 60; pub const PING_TIMEOUT: u64 = 60;
pub const REG_TIMEOUT: u64 = 60; pub const REG_TIMEOUT: u64 = 60;
/// The port from a `host:port` bind string (0 if unparseable) — for whoisport.
fn port_of(addr: &str) -> u16 {
addr.rsplit(':')
.next()
.and_then(|p| p.parse().ok())
.unwrap_or(0)
}
pub fn now() -> u64 { pub fn now() -> u64 {
SystemTime::now() SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@ -140,40 +132,10 @@ pub struct Server {
pub dnsbl_reason: String, // ban reason on a DNSBL hit pub dnsbl_reason: String, // ban reason on a DNSBL hit
pub sasl_server: String, // services server that handles SASL pub sasl_server: String, // services server that handles SASL
pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask) pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask)
pub opermotd: Vec<String>, // OPERMOTD text /// Every `key = value` line from the config, so each module reads its own
pub vhosts: Vec<(String, String, String)>, // self-service vhosts: (user, pass, host) /// settings via [`Server::conf`] / [`conf_all`] / [`conf_bool`] / [`conf_num`]
pub aliases: Vec<(String, String)>, // command aliases: (name, target-nick) /// — no per-module field lives on this struct (module-per-file rule).
pub connflood: Option<(u32, u64)>, // (max, secs) connection throttle per IP pub raw_config: HashMap<String, Vec<String>>,
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_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_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: String, // reputation: whois visibility mode
pub rep_expire_rules: Vec<(i32, u64)>, // reputation: (score, age) decay rules
pub network_icon: String, // ircv3_network_icon: draft/ICON url
pub profilelink_baseurl: String, // profileLink: WHOIS profile url base
pub hidewhois: bool, // hidewhois: enabled
pub hidewhois_opers: bool, // hidewhois: opers exempt
pub hidewhois_selfview: bool, // hidewhois: self exempt
pub hidewhois_server: bool, // hidewhois: hide 312
pub hidewhois_idle: bool, // hidewhois: hide 317
pub hidewhois_away: bool, // hidewhois: hide 301
pub hidewhois_secure: bool, // hidewhois: hide 671
pub plain_port: u16, // whoisport: the plaintext listener port
pub tls_port: u16, // whoisport: the TLS listener port (0 = none)
// 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
@ -225,45 +187,7 @@ impl Server {
dnsbl_reason: cfg.dnsbl_reason, dnsbl_reason: cfg.dnsbl_reason,
sasl_server: cfg.sasl_server, sasl_server: cfg.sasl_server,
webirc: cfg.webirc, webirc: cfg.webirc,
opermotd: cfg.opermotd, raw_config: cfg.raw,
vhosts: cfg.vhosts,
aliases: cfg.aliases,
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_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,
network_icon: cfg.network_icon,
profilelink_baseurl: cfg.profilelink_baseurl,
hidewhois: cfg.hidewhois,
hidewhois_opers: cfg.hidewhois_opers,
hidewhois_selfview: cfg.hidewhois_selfview,
hidewhois_server: cfg.hidewhois_server,
hidewhois_idle: cfg.hidewhois_idle,
hidewhois_away: cfg.hidewhois_away,
hidewhois_secure: cfg.hidewhois_secure,
plain_port: port_of(&cfg.bind),
tls_port: cfg.bind_tls.as_deref().map(port_of).unwrap_or(0),
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,
@ -271,6 +195,35 @@ impl Server {
} }
} }
/// The last value set for config `key` (`None` if unset). Modules read their
/// own settings through here so no per-module field bloats `Server`/`Config`.
pub fn conf(&self, key: &str) -> Option<&str> {
self.raw_config
.get(key)
.and_then(|v| v.last())
.map(|s| s.as_str())
}
/// Every value set for `key` (repeated lines, e.g. `securitygroup`, `motd`).
pub fn conf_all(&self, key: &str) -> &[String] {
self.raw_config
.get(key)
.map(|v| v.as_slice())
.unwrap_or(&[])
}
/// A boolean config value (`yes`/`no`/…); `default` when the key is unset.
pub fn conf_bool(&self, key: &str, default: bool) -> bool {
self.conf(key).map(crate::config::yesish).unwrap_or(default)
}
/// A parsed config value; `default` when unset or unparseable.
pub fn conf_num<T: std::str::FromStr>(&self, key: &str, default: T) -> T {
self.conf(key)
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
/// Remember an identity for WHOWAS (capped ring, newest first). /// Remember an identity for WHOWAS (capped ring, newest first).
pub fn push_whowas( pub fn push_whowas(
&mut self, &mut self,
@ -348,20 +301,14 @@ impl Server {
}, },
); );
// connflood — refuse an IP that's opening connections too fast // connflood — refuse an IP that's opening connections too fast (see modules::connflood)
if let Some((max, secs)) = self.connflood { if crate::modules::connflood::over_limit(self, ip) {
let n = now(); self.send(
let hist = self.conn_history.entry(ip).or_default(); uid,
hist.retain(|&t| n.saturating_sub(t) < secs); "ERROR :Closing link: (Too many connections from your IP)".to_string(),
hist.push(n); );
if hist.len() as u32 > max { self.remove_user(uid, "Connection throttled");
self.send( return;
uid,
"ERROR :Closing link: (Too many connections from your IP)".to_string(),
);
self.remove_user(uid, "Connection throttled");
return;
}
} }
// Pre-registration connection notices, InspIRCd / solanum style. Ident-113 // Pre-registration connection notices, InspIRCd / solanum style. Ident-113
@ -408,17 +355,6 @@ impl Server {
} }
} }
/// Drop stale per-IP connflood bookkeeping (called on the background tick).
pub fn prune_conn_history(&mut self) {
if let Some((_, secs)) = self.connflood {
let n = now();
self.conn_history.retain(|_, times| {
times.retain(|&t| n.saturating_sub(t) < secs);
!times.is_empty()
});
}
}
/// A pre-registration `:server NOTICE * :*** <msg>` line. /// A pre-registration `:server NOTICE * :*** <msg>` line.
pub(crate) fn notice_star(&self, uid: Uid, msg: &str) { pub(crate) fn notice_star(&self, uid: Uid, msg: &str) {
self.send(uid, format!(":{} NOTICE * :*** {msg}", self.name)); self.send(uid, format!(":{} NOTICE * :*** {msg}", self.name));

View file

@ -327,12 +327,18 @@ impl Server {
self.send(uid, format!(":{} MODE {nick} :+os", self.name)); self.send(uid, format!(":{} MODE {nick} :+os", self.name));
self.snotice(&format!("{nick} is now an IRC operator")); self.snotice(&format!("{nick} is now an IRC operator"));
// opermodes: extra umodes on oper-up // opermodes: extra umodes on oper-up
if !self.oper_umodes.is_empty() { let om = self.conf("opermodes").or_else(|| self.conf("oper_umodes"));
let modes = self.oper_umodes.clone(); if let Some(modes) = om.map(str::to_string) {
crate::coremods::core_mode::svs_set_user_modes(self, uid, &modes); crate::coremods::core_mode::svs_set_user_modes(self, uid, &modes);
} }
// operjoin: auto-join configured oper channels // operjoin: auto-join configured oper channels
for chan in self.oper_autojoin.clone() { let chans: Vec<String> = self
.conf_all("operjoin")
.iter()
.flat_map(|v| v.split([',', ' ']).map(str::to_string))
.filter(|c| !c.is_empty())
.collect();
for chan in chans {
self.join(uid, &chan, None); self.join(uid, &chan, None);
} }
} }
@ -395,7 +401,7 @@ impl Server {
for t in targets { for t in targets {
self.send(t, line.clone()); self.send(t, line.clone());
} }
if self.seenicks { if self.conf_bool("seenicks", false) {
self.snotice(&format!("{old} is now known as {newnick}")); self.snotice(&format!("{old} is now known as {newnick}"));
} }
// WATCH/MONITOR: the old nick is now gone, the new one is here // WATCH/MONITOR: the old nick is now gone, the new one is here
@ -448,11 +454,11 @@ impl Server {
), ),
); );
// ircv3_network_icon: advertise draft/ICON when configured // ircv3_network_icon: advertise draft/ICON when configured
if !self.network_icon.is_empty() { if let Some(tok) = crate::modules::network_icon::isupport(self) {
self.numeric( self.numeric(
uid, uid,
RPL_ISUPPORT, RPL_ISUPPORT,
&format!("ICON={} :are supported by this server", self.network_icon), &format!("{tok} :are supported by this server"),
); );
} }
self.numeric( self.numeric(
@ -462,18 +468,25 @@ impl Server {
); );
self.send_motd(uid); self.send_motd(uid);
// connbanner: NOTICE lines to every connecting client // connbanner: NOTICE lines to every connecting client
for line in self.conn_banner.clone() { for line in self.conf_all("connbanner").to_vec() {
self.send(uid, format!(":{} NOTICE {nick} :{line}", self.name)); self.send(uid, format!(":{} NOTICE {nick} :{line}", self.name));
} }
// conn_umodes: auto-set user modes on connect // conn_umodes: auto-set user modes on connect
if !self.auto_umodes.is_empty() { let auto_umodes = self.conf("conn_umodes").or_else(|| self.conf("autoumodes"));
let modes = self.auto_umodes.clone(); if let Some(modes) = auto_umodes.map(str::to_string) {
crate::coremods::core_mode::svs_set_user_modes(self, uid, &modes); crate::coremods::core_mode::svs_set_user_modes(self, uid, &modes);
} }
self.watch_notify_online(&nick); // tell WATCH/MONITOR watchers self.watch_notify_online(&nick); // tell WATCH/MONITOR watchers
self.events.push_back(Hook::Connect(uid)); self.events.push_back(Hook::Connect(uid));
// conn_join: auto-join configured channels // conn_join: auto-join configured channels (comma/space separated, repeatable)
for chan in self.autojoin.clone() { let chans: Vec<String> = self
.conf_all("autojoin")
.iter()
.chain(self.conf_all("conn_join"))
.flat_map(|v| v.split([',', ' ']).map(str::to_string))
.filter(|c| !c.is_empty())
.collect();
for chan in chans {
self.join(uid, &chan, None); self.join(uid, &chan, None);
} }
} }