modules: move all per-module state/config out of server.rs/config.rs into own files
This commit is contained in:
parent
0d4c9297b5
commit
131471e245
18 changed files with 525 additions and 425 deletions
|
|
@ -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)
|
||||
|
|
|
|||
255
src/config.rs
255
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<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
|
||||
/// `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<String>, // 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<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
|
||||
/// 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<String, Vec<String>>,
|
||||
}
|
||||
|
||||
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 = <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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String> = 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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = <user> <pass> <host>`
|
||||
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)
|
||||
|
|
|
|||
142
src/http.rs
Normal file
142
src/http.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
18
src/ircd.rs
18
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 = <CMD> <target-nick>`
|
||||
// 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
60
src/modules/connflood.rs
Normal file
60
src/modules/connflood.rs
Normal 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
26
src/modules/hidewhois.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -6,15 +6,20 @@
|
|||
pub mod antimixedutf8;
|
||||
pub mod chathistory;
|
||||
pub mod cloak;
|
||||
pub mod connflood;
|
||||
pub mod dnsbl;
|
||||
pub mod filter;
|
||||
pub mod flood;
|
||||
pub mod hidewhois;
|
||||
pub mod markread;
|
||||
pub mod metadata;
|
||||
pub mod multiline;
|
||||
pub mod network_icon;
|
||||
pub mod profilelink;
|
||||
pub mod reputation;
|
||||
pub mod securitygroups;
|
||||
pub mod snoop;
|
||||
pub mod whoisport;
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::module::Module;
|
||||
|
|
@ -31,6 +36,7 @@ pub fn default_modules() -> Vec<Box<dyn Module>> {
|
|||
Box::new(markread::MarkRead),
|
||||
Box::new(multiline::Multiline),
|
||||
Box::new(reputation::ReputationMod::default()),
|
||||
Box::new(connflood::ConnFlood),
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
13
src/modules/network_icon.rs
Normal file
13
src/modules/network_icon.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
17
src/modules/profilelink.rs
Normal file
17
src/modules/profilelink.rs
Normal 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(),
|
||||
})
|
||||
}
|
||||
|
|
@ -49,10 +49,56 @@ fn mask_ip(ip: IpAddr, v4: u8, v6: u8) -> IpAddr {
|
|||
}
|
||||
}
|
||||
|
||||
// --- config, read straight from the config file (no fields on Server) ----------
|
||||
fn v4prefix(s: &Server) -> u8 {
|
||||
s.conf_num::<u8>("reputation_ipv4prefix", 32).clamp(1, 32)
|
||||
}
|
||||
fn v6prefix(s: &Server) -> u8 {
|
||||
s.conf_num::<u8>("reputation_ipv6prefix", 64).clamp(1, 128)
|
||||
}
|
||||
fn scorecap(s: &Server) -> u32 {
|
||||
s.conf_num("reputation_scorecap", 10000)
|
||||
}
|
||||
fn minchan(s: &Server) -> usize {
|
||||
s.conf_num("reputation_minchanmembers", 3)
|
||||
}
|
||||
fn dur(s: &Server, key: &str, def: u64) -> u64 {
|
||||
s.conf(key)
|
||||
.and_then(crate::xline::parse_duration)
|
||||
.filter(|&d| d > 0)
|
||||
.unwrap_or(def)
|
||||
}
|
||||
fn expire_rules(s: &Server) -> Vec<(i32, u64)> {
|
||||
let rules: Vec<(i32, u64)> = s
|
||||
.conf_all("reputationexpire")
|
||||
.iter()
|
||||
.filter_map(|line| {
|
||||
let mut it = line.split_whitespace();
|
||||
let sc = it.next()?;
|
||||
let age = it.next()?;
|
||||
let score = if sc == "*" { -1 } else { sc.parse().ok()? };
|
||||
let age = crate::xline::parse_duration(age).filter(|&a| a > 0)?;
|
||||
Some((score, age))
|
||||
})
|
||||
.collect();
|
||||
if rules.is_empty() {
|
||||
// Unreal defaults: score<=2 after 1h, <=6 after 7d, <=12 after 30d, any after 90d
|
||||
vec![(2, 3600), (6, 604800), (12, 2592000), (-1, 7776000)]
|
||||
} else {
|
||||
rules
|
||||
}
|
||||
}
|
||||
fn db_path(s: &Server) -> String {
|
||||
match s.conf("reputation_database") {
|
||||
Some(p) if !p.is_empty() => p.to_string(),
|
||||
_ => format!("{}.reputation", s.conf_path),
|
||||
}
|
||||
}
|
||||
|
||||
/// The masked key for `uid`'s address.
|
||||
fn key_of(s: &Server, uid: Uid) -> Option<IpAddr> {
|
||||
let ip = s.users.get(&uid).map(|u| u.addr.ip())?;
|
||||
Some(mask_ip(ip, s.rep_ipv4prefix, s.rep_ipv6prefix))
|
||||
Some(mask_ip(ip, v4prefix(s), v6prefix(s)))
|
||||
}
|
||||
|
||||
/// Whether `uid` is in at least one channel with `min` or more members (the
|
||||
|
|
@ -90,15 +136,15 @@ impl Module for ReputationMod {
|
|||
self.since_bump += t;
|
||||
self.since_expire += t;
|
||||
self.since_save += t;
|
||||
if self.since_bump >= s.rep_bump_secs {
|
||||
if self.since_bump >= dur(s, "reputation_bumpinterval", 300) {
|
||||
self.since_bump = 0;
|
||||
bump_scores(s);
|
||||
}
|
||||
if self.since_expire >= s.rep_expire_secs {
|
||||
if self.since_expire >= dur(s, "reputation_expireinterval", 605) {
|
||||
self.since_expire = 0;
|
||||
expire_old(s);
|
||||
}
|
||||
if self.since_save >= s.rep_save_secs {
|
||||
if self.since_save >= dur(s, "reputation_saveinterval", 902) {
|
||||
self.since_save = 0;
|
||||
save(s);
|
||||
}
|
||||
|
|
@ -109,9 +155,9 @@ impl Module for ReputationMod {
|
|||
/// refresh their last_seen so active addresses don't decay.
|
||||
fn bump_scores(s: &mut Server) {
|
||||
let n = now();
|
||||
let cap = s.rep_scorecap;
|
||||
let min = s.rep_minchanmembers;
|
||||
let (v4, v6) = (s.rep_ipv4prefix, s.rep_ipv6prefix);
|
||||
let cap = scorecap(s);
|
||||
let min = minchan(s);
|
||||
let (v4, v6) = (v4prefix(s), v6prefix(s));
|
||||
let bumps: Vec<(IpAddr, u32)> = s
|
||||
.users
|
||||
.values()
|
||||
|
|
@ -135,7 +181,7 @@ fn bump_scores(s: &mut Server) {
|
|||
/// Drop entries that have aged out under any matching `reputationexpire` rule.
|
||||
fn expire_old(s: &mut Server) {
|
||||
let n = now();
|
||||
let rules = s.rep_expire_rules.clone();
|
||||
let rules = expire_rules(s);
|
||||
if let Some(store) = s.ext.get_mut::<Reputation>() {
|
||||
store.0.retain(|_, e| {
|
||||
let expired = rules.iter().any(|&(score, age)| {
|
||||
|
|
@ -179,7 +225,7 @@ pub fn score_ban_match(s: &Server, uid: Uid, spec: &str) -> bool {
|
|||
|
||||
/// Whether the WHOIS `source` may see `target`'s reputation, per the `whois` mode.
|
||||
pub fn whois_visible(s: &Server, source: Uid, target: Uid) -> bool {
|
||||
match s.rep_whois.as_str() {
|
||||
match s.conf("reputation_whois").unwrap_or("all") {
|
||||
"none" => false,
|
||||
"self" => source == target,
|
||||
"opers" => source == target || s.is_oper(source),
|
||||
|
|
@ -228,7 +274,7 @@ impl Command for ReputationCmd {
|
|||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
if let Some(val) = params.get(1).and_then(|v| v.parse::<u32>().ok()) {
|
||||
let (cap, n) = (s.rep_scorecap, now());
|
||||
let (cap, n) = (scorecap(s), now());
|
||||
let store = s.ext.get_or_insert_with::<Reputation>(Reputation::default);
|
||||
let e = store.0.entry(k).or_default();
|
||||
e.score = val.min(cap);
|
||||
|
|
@ -255,14 +301,6 @@ impl Command for ReputationCmd {
|
|||
}
|
||||
}
|
||||
|
||||
fn db_path(s: &Server) -> String {
|
||||
if s.rep_database.is_empty() {
|
||||
format!("{}.reputation", s.conf_path)
|
||||
} else {
|
||||
s.rep_database.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist reputation (masked-ip score last_seen per line) so it survives a restart.
|
||||
pub fn save(s: &Server) {
|
||||
let mut out = String::new();
|
||||
|
|
|
|||
|
|
@ -6,11 +6,80 @@
|
|||
|
||||
use crate::channels::glob_match;
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::config::{SecGroup, Tri};
|
||||
use crate::numeric::ERR_NOSUCHNICK;
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
/// Tri-state for a criterion: don't-care / must-be / must-not-be.
|
||||
#[derive(Clone, Copy, PartialEq, Default)]
|
||||
enum Tri {
|
||||
#[default]
|
||||
Ignore,
|
||||
Yes,
|
||||
No,
|
||||
}
|
||||
|
||||
/// A UnrealIRCd-style security group — all criteria AND-ed.
|
||||
#[derive(Clone, Default)]
|
||||
struct SecGroup {
|
||||
name: String,
|
||||
public: bool,
|
||||
masks: Vec<String>,
|
||||
exclude_masks: Vec<String>,
|
||||
tls: Tri,
|
||||
account: Tri,
|
||||
oper: Tri,
|
||||
bot: Tri,
|
||||
webirc: Tri,
|
||||
score_min: Option<u32>,
|
||||
score_max: Option<u32>,
|
||||
}
|
||||
|
||||
/// Parse the `securitygroup = <name> [criteria…]` config lines into groups.
|
||||
fn parse_groups(s: &Server) -> Vec<SecGroup> {
|
||||
let mut out = Vec::new();
|
||||
for line in s
|
||||
.conf_all("securitygroup")
|
||||
.iter()
|
||||
.chain(s.conf_all("secgroup"))
|
||||
{
|
||||
let mut it = line.split_whitespace();
|
||||
let Some(name) = it.next() else { continue };
|
||||
let mut g = SecGroup {
|
||||
name: name.to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
for tok in it {
|
||||
let (k, val) = match tok.split_once('=') {
|
||||
Some((a, b)) => (a, Some(b)),
|
||||
None => (tok, None),
|
||||
};
|
||||
match (k, val) {
|
||||
("public", _) => g.public = true,
|
||||
("mask", Some(m)) => g.masks.push(m.to_string()),
|
||||
("exclude", Some(m)) | ("exclude-mask", Some(m)) => {
|
||||
g.exclude_masks.push(m.to_string())
|
||||
}
|
||||
("tls", _) | ("tls-users", _) => g.tls = Tri::Yes,
|
||||
("insecure", _) | ("exclude-tls", _) => g.tls = Tri::No,
|
||||
("account", _) | ("registered", _) => g.account = Tri::Yes,
|
||||
("unregistered", _) | ("exclude-account", _) => g.account = Tri::No,
|
||||
("oper", _) => g.oper = Tri::Yes,
|
||||
("exclude-oper", _) => g.oper = Tri::No,
|
||||
("bot", _) | ("bmode", _) => g.bot = Tri::Yes,
|
||||
("exclude-bot", _) | ("exclude-bmode", _) => g.bot = Tri::No,
|
||||
("webirc", _) => g.webirc = Tri::Yes,
|
||||
("exclude-webirc", _) => g.webirc = Tri::No,
|
||||
("scoremin", Some(n)) => g.score_min = n.parse().ok(),
|
||||
("scoremax", Some(n)) => g.score_max = n.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.push(g);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Does `uid`'s identity match `mask` (glob against nick!user@{display,real,ip})?
|
||||
fn mask_matches(s: &Server, uid: Uid, mask: &str) -> bool {
|
||||
let Some(u) = s.users.get(&uid) else {
|
||||
|
|
@ -64,17 +133,17 @@ fn matches(s: &Server, uid: Uid, g: &SecGroup) -> bool {
|
|||
|
||||
/// Whether `uid` is a member of the named security group (case-insensitive).
|
||||
pub fn in_group(s: &Server, uid: Uid, name: &str) -> bool {
|
||||
s.sec_groups
|
||||
parse_groups(s)
|
||||
.iter()
|
||||
.any(|g| g.name.eq_ignore_ascii_case(name) && matches(s, uid, g))
|
||||
}
|
||||
|
||||
/// The names of the groups `uid` is in (only public ones unless `include_private`).
|
||||
pub fn user_groups(s: &Server, uid: Uid, include_private: bool) -> Vec<String> {
|
||||
s.sec_groups
|
||||
.iter()
|
||||
parse_groups(s)
|
||||
.into_iter()
|
||||
.filter(|g| (include_private || g.public) && matches(s, uid, g))
|
||||
.map(|g| g.name.clone())
|
||||
.map(|g| g.name)
|
||||
.collect()
|
||||
}
|
||||
|
||||
|
|
|
|||
21
src/modules/whoisport.rs
Normal file
21
src/modules/whoisport.rs
Normal 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}"))
|
||||
}
|
||||
150
src/server.rs
150
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<String>, // 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<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)
|
||||
/// 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<String, Vec<String>>,
|
||||
// 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<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).
|
||||
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 * :*** <msg>` line.
|
||||
pub(crate) fn notice_star(&self, uid: Uid, msg: &str) {
|
||||
self.send(uid, format!(":{} NOTICE * :*** {msg}", self.name));
|
||||
|
|
|
|||
35
src/users.rs
35
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<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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue