From 131471e2455152f2a70f5cb2423460de717a4553 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 11:18:04 +0000 Subject: [PATCH] modules: move all per-module state/config out of server.rs/config.rs into own files --- src/channels.rs | 4 +- src/config.rs | 255 ++-------------------------------- src/coremods/core_info.rs | 31 ++--- src/coremods/core_oper.rs | 5 +- src/coremods/core_user.rs | 13 +- src/http.rs | 142 +++++++++++++++++++ src/ircd.rs | 18 +-- src/lib.rs | 1 + src/modules/connflood.rs | 60 ++++++++ src/modules/hidewhois.rs | 26 ++++ src/modules/mod.rs | 6 + src/modules/network_icon.rs | 13 ++ src/modules/profilelink.rs | 17 +++ src/modules/reputation.rs | 74 +++++++--- src/modules/securitygroups.rs | 79 ++++++++++- src/modules/whoisport.rs | 21 +++ src/server.rs | 150 ++++++-------------- src/users.rs | 35 +++-- 18 files changed, 525 insertions(+), 425 deletions(-) create mode 100644 src/http.rs create mode 100644 src/modules/connflood.rs create mode 100644 src/modules/hidewhois.rs create mode 100644 src/modules/network_icon.rs create mode 100644 src/modules/profilelink.rs create mode 100644 src/modules/whoisport.rs diff --git a/src/channels.rs b/src/channels.rs index 79c93dc..8e3070e 100644 --- a/src/channels.rs +++ b/src/channels.rs @@ -700,7 +700,9 @@ impl Server { u.channels.insert(key.clone()); } // 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 .users .get(&uid) diff --git a/src/config.rs b/src/config.rs index ed35a1f..b577e55 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,40 +8,16 @@ //! oper = god secret //! ``` +use std::collections::HashMap; + /// 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!( v.to_ascii_lowercase().as_str(), "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, // positive: match any one nick!user@host glob - pub exclude_masks: Vec, // 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, // reputation lower bound - pub score_max: Option, // reputation upper bound -} - /// A server-link block: how to authenticate a peer named `name` (and, if /// `autoconnect`, where to dial it). Passwords are the shared link secret. #[derive(Clone)] @@ -110,37 +86,9 @@ pub struct Config { pub dnsbl_reason: String, // ban reason for a DNSBL hit pub sasl_server: String, // linked services server that handles SASL ("" = none) pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask) - pub opermotd: Vec, // OPERMOTD text, one line per entry - pub vhosts: Vec<(String, String, String)>, // self-service vhosts: (user, pass, host) - 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_database: String, // reputation: db file (default .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 + /// Every `key = value` line, captured raw so modules read their own settings + /// via `Server::conf*` — no per-module field bloats this struct or `Server`. + pub raw: HashMap>, } impl Default for Config { @@ -169,37 +117,7 @@ impl Default for Config { dnsbl_reason: "Your host is listed in a DNS blocklist".to_string(), sasl_server: String::new(), webirc: Vec::new(), - opermotd: Vec::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, + raw: HashMap::new(), } } } @@ -244,6 +162,9 @@ impl Config { continue; }; 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 { "servername" | "server" => c.servername = v.to_string(), "network" => c.network = v.to_string(), @@ -344,162 +265,6 @@ impl Config { c.webirc.push((pass.to_string(), gw, mask)); } } - "opermotd" => c.opermotd.push(v.to_string()), - "vhost" => { - // vhost = - 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 = (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 = — 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::(), sc.parse::()) { - 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::() { - c.rep_ipv4prefix = n.clamp(1, 32); - } - } - "reputation_ipv6prefix" => { - if let Ok(n) = v.parse::() { - 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 = (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 = [public] [tls|insecure] [account|unregistered] - // [oper|exclude-oper] [bot|exclude-bot] [webirc|exclude-webirc] - // [mask=]... [exclude=]... [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); - } - } _ => {} } } diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs index abf12f0..ba98559 100644 --- a/src/coremods/core_info.rs +++ b/src/coremods/core_info.rs @@ -149,8 +149,7 @@ impl Command for Whois { let asker_oper = s.is_oper(uid); let is_self = tuid == uid; // hidewhois: hide sensitive lines from ordinary users (opers/self exempt per config) - let hide = - s.hidewhois && !(is_self && s.hidewhois_selfview) && !(asker_oper && s.hidewhois_opers); + let hide = crate::modules::hidewhois::hide(s, uid, tuid, asker_oper); let keys: Vec = s.users[&tuid].channels.iter().cloned().collect(); let ( nick, @@ -206,7 +205,7 @@ impl Command for Whois { if 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( uid, RPL_WHOISSERVER, @@ -251,25 +250,13 @@ impl Command for Whois { } } // profileLink: a profile URL for logged-in users (when configured) - if !s.profilelink_baseurl.is_empty() { - match &account { - 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.", - ), - } + if let Some(line) = crate::modules::profilelink::line(s, &account) { + s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); } - // whoisport: the listener port (+ TLS/plain) — opers only + // whoisport: the listener port — opers only if asker_oper { - let port = if secure { s.tls_port } else { s.plain_port }; - if port != 0 { - s.numeric(uid, RPL_WHOISSPECIAL, &format!(":is using port {port}")); + if let Some(line) = crate::modules::whoisport::line(s, tuid) { + s.numeric(uid, RPL_WHOISSPECIAL, &format!(":{line}")); } } // 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 - if secure && !(hide && s.hidewhois_secure) { + if secure && !(hide && crate::modules::hidewhois::hide_secure(s)) { s.numeric( uid, RPL_WHOISSECURE, @@ -307,7 +294,7 @@ impl Command for Whois { } } // 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); s.numeric( uid, diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs index 294ea70..015af98 100644 --- a/src/coremods/core_oper.rs +++ b/src/coremods/core_oper.rs @@ -68,7 +68,8 @@ impl Command for OperMotd { return CmdResult::Fail; } 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( uid, format!(":{} NOTICE {nick} :No OPERMOTD is set", s.name), @@ -82,7 +83,7 @@ impl Command for OperMotd { s.name ), ); - for line in s.opermotd.clone() { + for line in motd { s.send(uid, format!(":{} NOTICE {nick} :- {line}", s.name)); } s.send( diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index 5a46ee2..1b5e520 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -38,11 +38,14 @@ impl Command for Vhost { } fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { let (user, pass) = (¶ms[0], ¶ms[1]); - let host = s - .vhosts - .iter() - .find(|(u, p, _)| u == user && p == pass) - .map(|(_, _, h)| h.clone()); + // vhost blocks live in the config: `vhost = ` + let host = s.conf_all("vhost").iter().find_map(|line| { + let mut it = line.split_whitespace(); + match (it.next(), it.next(), it.next()) { + (Some(u), Some(p), Some(h)) if u == user && p == pass => Some(h.to_string()), + _ => None, + } + }); let nick = s .users .get(&uid) diff --git a/src/http.rs b/src/http.rs new file mode 100644 index 0000000..5fc48d6 --- /dev/null +++ b/src/http.rs @@ -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::().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 { + 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()) + } +} diff --git a/src/ircd.rs b/src/ircd.rs index 3769222..f057a56 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -230,14 +230,15 @@ impl Ircd { let Some(handler) = self.commands.get(cmd) else { if registered { - // command aliases (m_alias): `/NS help` -> PRIVMSG NickServ :help - if let Some(target) = self - .server - .aliases - .iter() - .find(|(n, _)| n == cmd) - .map(|(_, t)| t.clone()) - { + // command aliases (m_alias): config `alias = ` + // e.g. `alias = NS NickServ` makes `/NS help` -> PRIVMSG NickServ :help + if let Some(target) = self.server.conf_all("alias").iter().find_map(|line| { + let mut it = line.split_whitespace(); + match (it.next(), it.next()) { + (Some(n), Some(t)) if n.eq_ignore_ascii_case(cmd) => Some(t.to_string()), + _ => None, + } + }) { if !msg.params.is_empty() { let text = msg.params.join(" "); crate::coremods::core_message::deliver( @@ -377,7 +378,6 @@ impl Ircd { self.server.ping_links(); // keepalive on every server link self.server.purge_xlines(); // drop expired server bans self.server.purge_tbans(); // lift expired timed channel bans (TBAN) - self.server.prune_conn_history(); // connflood bookkeeping for m in &mut self.modules { m.on_tick(&mut self.server); // timer-driven modules (e.g. reputation) } diff --git a/src/lib.rs b/src/lib.rs index 03fa9dc..bb92d35 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ pub mod command; pub mod config; pub mod coremods; pub mod extensible; +pub mod http; pub mod ircd; pub mod link; pub mod message; diff --git a/src/modules/connflood.rs b/src/modules/connflood.rs new file mode 100644 index 0000000..0c003b0 --- /dev/null +++ b/src/modules/connflood.rs @@ -0,0 +1,60 @@ +//! connflood — InspIRCd `m_connflood`. Refuse connections from an IP opening too +//! many too fast. Config: `connflood = `. 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>); + +/// `(max, secs)` from `connflood = `, 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::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::() { + h.0.retain(|_, times| { + times.retain(|&t| n.saturating_sub(t) < secs); + !times.is_empty() + }); + } + } +} diff --git a/src/modules/hidewhois.rs b/src/modules/hidewhois.rs new file mode 100644 index 0000000..94b482c --- /dev/null +++ b/src/modules/hidewhois.rs @@ -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) +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index ba36066..e3df31a 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -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::new(markread::MarkRead), Box::new(multiline::Multiline), Box::new(reputation::ReputationMod::default()), + Box::new(connflood::ConnFlood), ] } diff --git a/src/modules/network_icon.rs b/src/modules/network_icon.rs new file mode 100644 index 0000000..8cc568c --- /dev/null +++ b/src/modules/network_icon.rs @@ -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 = `. Config-driven; +//! nothing lives on `Server`. + +use crate::server::Server; + +/// The `ICON=` ISUPPORT token, or `None` when unconfigured. +pub fn isupport(s: &Server) -> Option { + match s.conf("network_icon") { + Some(url) if !url.is_empty() => Some(format!("ICON={url}")), + _ => None, + } +} diff --git a/src/modules/profilelink.rs b/src/modules/profilelink.rs new file mode 100644 index 0000000..d3ce0de --- /dev/null +++ b/src/modules/profilelink.rs @@ -0,0 +1,17 @@ +//! profileLink — InspIRCd `m_profileLink`. Adds a profile URL to WHOIS for +//! logged-in users from `profilelink_baseurl = `. 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) -> Option { + 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(), + }) +} diff --git a/src/modules/reputation.rs b/src/modules/reputation.rs index 2c70bc7..37e0747 100644 --- a/src/modules/reputation.rs +++ b/src/modules/reputation.rs @@ -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::("reputation_ipv4prefix", 32).clamp(1, 32) +} +fn v6prefix(s: &Server) -> u8 { + s.conf_num::("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 { 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::() { 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::().ok()) { - let (cap, n) = (s.rep_scorecap, now()); + let (cap, n) = (scorecap(s), now()); let store = s.ext.get_or_insert_with::(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(); diff --git a/src/modules/securitygroups.rs b/src/modules/securitygroups.rs index 0fed47b..470f7be 100644 --- a/src/modules/securitygroups.rs +++ b/src/modules/securitygroups.rs @@ -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, + exclude_masks: Vec, + tls: Tri, + account: Tri, + oper: Tri, + bot: Tri, + webirc: Tri, + score_min: Option, + score_max: Option, +} + +/// Parse the `securitygroup = [criteria…]` config lines into groups. +fn parse_groups(s: &Server) -> Vec { + 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 { - 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() } diff --git a/src/modules/whoisport.rs b/src/modules/whoisport.rs new file mode 100644 index 0000000..3ba175a --- /dev/null +++ b/src/modules/whoisport.rs @@ -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 { + 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}")) +} diff --git a/src/server.rs b/src/server.rs index f7d845c..a7252eb 100644 --- a/src/server.rs +++ b/src/server.rs @@ -7,7 +7,7 @@ use std::cell::RefCell; 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::mpsc::Sender; use std::sync::Arc; @@ -35,14 +35,6 @@ pub const PING_AFTER: u64 = 90; pub const PING_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 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -140,40 +132,10 @@ pub struct Server { pub dnsbl_reason: String, // ban reason on a DNSBL hit pub sasl_server: String, // services server that handles SASL pub webirc: Vec<(String, String, String)>, // web gateways: (password, name, ip-mask) - pub opermotd: Vec, // OPERMOTD text - pub vhosts: Vec<(String, String, String)>, // self-service vhosts: (user, pass, host) - pub aliases: Vec<(String, String)>, // command aliases: (name, target-nick) - 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_database: String, // reputation: db path ("" = .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) + /// Every `key = value` line from the config, so each module reads its own + /// settings via [`Server::conf`] / [`conf_all`] / [`conf_bool`] / [`conf_num`] + /// — no per-module field lives on this struct (module-per-file rule). + pub raw_config: HashMap>, // 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 @@ -225,45 +187,7 @@ impl Server { dnsbl_reason: cfg.dnsbl_reason, sasl_server: cfg.sasl_server, webirc: cfg.webirc, - opermotd: cfg.opermotd, - 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 - }, + raw_config: cfg.raw, label_capture: RefCell::new(None), event_tx, 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(&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). pub fn push_whowas( &mut self, @@ -348,20 +301,14 @@ impl Server { }, ); - // connflood — refuse an IP that's opening connections too fast - if let Some((max, secs)) = self.connflood { - let n = now(); - let hist = self.conn_history.entry(ip).or_default(); - hist.retain(|&t| n.saturating_sub(t) < secs); - hist.push(n); - if hist.len() as u32 > max { - self.send( - uid, - "ERROR :Closing link: (Too many connections from your IP)".to_string(), - ); - self.remove_user(uid, "Connection throttled"); - return; - } + // connflood — refuse an IP that's opening connections too fast (see modules::connflood) + if crate::modules::connflood::over_limit(self, ip) { + self.send( + 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 @@ -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 * :*** ` line. pub(crate) fn notice_star(&self, uid: Uid, msg: &str) { self.send(uid, format!(":{} NOTICE * :*** {msg}", self.name)); diff --git a/src/users.rs b/src/users.rs index f36d520..7a23cfe 100644 --- a/src/users.rs +++ b/src/users.rs @@ -327,12 +327,18 @@ impl Server { 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(); + let om = self.conf("opermodes").or_else(|| self.conf("oper_umodes")); + if let Some(modes) = om.map(str::to_string) { crate::coremods::core_mode::svs_set_user_modes(self, uid, &modes); } // operjoin: auto-join configured oper channels - for chan in self.oper_autojoin.clone() { + let chans: Vec = 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); } } @@ -395,7 +401,7 @@ impl Server { for t in targets { self.send(t, line.clone()); } - if self.seenicks { + if self.conf_bool("seenicks", false) { self.snotice(&format!("{old} is now known as {newnick}")); } // 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 - if !self.network_icon.is_empty() { + if let Some(tok) = crate::modules::network_icon::isupport(self) { self.numeric( uid, RPL_ISUPPORT, - &format!("ICON={} :are supported by this server", self.network_icon), + &format!("{tok} :are supported by this server"), ); } self.numeric( @@ -462,18 +468,25 @@ impl Server { ); self.send_motd(uid); // 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)); } // conn_umodes: auto-set user modes on connect - if !self.auto_umodes.is_empty() { - let modes = self.auto_umodes.clone(); + let auto_umodes = self.conf("conn_umodes").or_else(|| self.conf("autoumodes")); + if let Some(modes) = auto_umodes.map(str::to_string) { 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() { + // conn_join: auto-join configured channels (comma/space separated, repeatable) + let chans: Vec = 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); } }