import echoircd — from-scratch irc daemon in native rust
This commit is contained in:
commit
9b12791774
38 changed files with 9757 additions and 0 deletions
59
src/accounts.rs
Normal file
59
src/accounts.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//! Account layer — the ircd's *services-ready* account support, modelled on
|
||||
//! InspIRCd's `m_services_account`. **This is NOT a services daemon.**
|
||||
//!
|
||||
//! echoIRCd stores no passwords and runs no NickServ — registering nicks/channels
|
||||
//! is a **services package**'s job (Anope/Atheme), linked in over S2S. What the
|
||||
//! ircd owns is only the plumbing a service plugs into:
|
||||
//! * a per-user **account name** (`User.account`) — the extension a service sets
|
||||
//! or clears (InspIRCd's `accountname` metadata), which flips user mode `+r`;
|
||||
//! * the account-gated **modes** (chan `+R`/`+M`, user `+r`/`+R`) that key off it
|
||||
//! and live in [`crate::mode`];
|
||||
//! * the **interface** a service drives it through: [`Server::set_login`] /
|
||||
//! [`Server::logout`], reached today via the oper/`SVSLOGIN` command and, once
|
||||
//! S2S + SASL land, by a linked services pseudoserver.
|
||||
//!
|
||||
//! So a real network runs Anope *beside* echoIRCd; the ircd just has to be ready.
|
||||
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
impl Server {
|
||||
pub fn is_logged_in(&self, uid: Uid) -> bool {
|
||||
self.users
|
||||
.get(&uid)
|
||||
.map(|u| u.account.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Log `uid` into `account` (services-driven): sets the account name, flips
|
||||
/// `+r`, and reflects the mode back to the user.
|
||||
pub fn set_login(&mut self, uid: Uid, account: &str) {
|
||||
let (nick, prefix) = match self.users.get_mut(&uid) {
|
||||
Some(u) => {
|
||||
u.account = Some(account.to_string());
|
||||
u.flags.logged_in = true;
|
||||
(u.nick.clone(), u.prefix())
|
||||
}
|
||||
None => return,
|
||||
};
|
||||
self.send(uid, format!(":{} MODE {nick} :+r", self.name));
|
||||
// account-notify
|
||||
self.notify_peers(uid, &format!(":{prefix} ACCOUNT {account}"), |c| {
|
||||
c.account_notify
|
||||
});
|
||||
}
|
||||
|
||||
/// Log `uid` out of any account (services-driven): clears `+r`.
|
||||
pub fn logout(&mut self, uid: Uid) {
|
||||
let (nick, prefix) = match self.users.get_mut(&uid) {
|
||||
Some(u) if u.account.is_some() => {
|
||||
u.account = None;
|
||||
u.flags.logged_in = false;
|
||||
(u.nick.clone(), u.prefix())
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
self.send(uid, format!(":{} MODE {nick} :-r", self.name));
|
||||
self.notify_peers(uid, &format!(":{prefix} ACCOUNT *"), |c| c.account_notify);
|
||||
}
|
||||
}
|
||||
792
src/channels.rs
Normal file
792
src/channels.rs
Normal file
|
|
@ -0,0 +1,792 @@
|
|||
//! Channels: the `Channel` record, membership, channel modes, bans, invites and
|
||||
//! JOIN/NAMES — the same job InspIRCd splits across channels/channelmanager, but
|
||||
//! written from scratch in Rust (InspIRCd is a behaviour reference, not a source).
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::module::Hook;
|
||||
use crate::numeric::*;
|
||||
use crate::server::{now, Server};
|
||||
use crate::Uid;
|
||||
|
||||
/// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`].
|
||||
#[derive(Default)]
|
||||
pub struct Member {
|
||||
pub owner: bool, // +q (~)
|
||||
pub admin: bool, // +a (&)
|
||||
pub op: bool, // +o (@)
|
||||
pub halfop: bool, // +h (%)
|
||||
pub voice: bool, // +v (+)
|
||||
}
|
||||
|
||||
/// Prefix ranks, high→low — gate who may grant a prefix / kick whom.
|
||||
pub const RANK_OWNER: u8 = 5;
|
||||
pub const RANK_ADMIN: u8 = 4;
|
||||
pub const RANK_OP: u8 = 3;
|
||||
pub const RANK_HALFOP: u8 = 2;
|
||||
pub const RANK_VOICE: u8 = 1;
|
||||
|
||||
impl Member {
|
||||
/// This member's numeric rank (0 = plain member).
|
||||
pub fn rank(&self) -> u8 {
|
||||
if self.owner {
|
||||
RANK_OWNER
|
||||
} else if self.admin {
|
||||
RANK_ADMIN
|
||||
} else if self.op {
|
||||
RANK_OP
|
||||
} else if self.halfop {
|
||||
RANK_HALFOP
|
||||
} else if self.voice {
|
||||
RANK_VOICE
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest prefix char for NAMES (`""` for a plain member).
|
||||
pub fn prefix_char(&self) -> &'static str {
|
||||
if self.owner {
|
||||
"~"
|
||||
} else if self.admin {
|
||||
"&"
|
||||
} else if self.op {
|
||||
"@"
|
||||
} else if self.halfop {
|
||||
"%"
|
||||
} else if self.voice {
|
||||
"+"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/// Set/clear a prefix mode by its letter (used by the S2S mode applier).
|
||||
pub fn set_prefix(&mut self, letter: char, on: bool) {
|
||||
match letter {
|
||||
'q' => self.owner = on,
|
||||
'a' => self.admin = on,
|
||||
'o' => self.op = on,
|
||||
'h' => self.halfop = on,
|
||||
'v' => self.voice = on,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every prefix char this member holds, high→low (for the `multi-prefix` cap).
|
||||
pub fn all_prefixes(&self) -> String {
|
||||
let mut s = String::new();
|
||||
for (on, c) in [
|
||||
(self.owner, '~'),
|
||||
(self.admin, '&'),
|
||||
(self.op, '@'),
|
||||
(self.halfop, '%'),
|
||||
(self.voice, '+'),
|
||||
] {
|
||||
if on {
|
||||
s.push(c);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Topic {
|
||||
pub text: String,
|
||||
pub setter: String,
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
/// A +b ban: a `nick!user@host` glob, who set it and when.
|
||||
pub struct Ban {
|
||||
pub mask: String,
|
||||
pub setter: String,
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
/// +f message flood: `[*]lines:secs` — kick past `lines` msgs in `secs` (and set
|
||||
/// a +b ban too when `ban`, from the leading `*`).
|
||||
#[derive(Clone)]
|
||||
pub struct MsgFlood {
|
||||
pub lines: u32,
|
||||
pub secs: u64,
|
||||
pub ban: bool,
|
||||
}
|
||||
|
||||
/// A `count:secs` rate, shared by +j (join flood) and +F (nick-change flood).
|
||||
#[derive(Clone)]
|
||||
pub struct Rate {
|
||||
pub count: u32,
|
||||
pub secs: u64,
|
||||
}
|
||||
|
||||
/// Channel modes other than the per-member prefixes.
|
||||
#[derive(Default)]
|
||||
pub struct ChanModes {
|
||||
pub moderated: bool, // +m — only +o/+v may speak
|
||||
pub topic_ops: bool, // +t — only ops may set the topic
|
||||
pub no_external: bool, // +n — must be a member to message it
|
||||
pub invite_only: bool, // +i
|
||||
pub secret: bool, // +s
|
||||
pub key: Option<String>, // +k <key>
|
||||
pub limit: Option<u32>, // +l <n>
|
||||
pub secure_only: bool, // +z — only TLS-connected users may join
|
||||
pub private: bool, // +p — private (hidden from WHOIS channel list)
|
||||
pub oper_only: bool, // +O — only IRC operators may join
|
||||
pub no_nick: bool, // +N — members can't change nick while here
|
||||
pub no_ctcp: bool, // +C — block CTCP to the channel
|
||||
pub no_notice: bool, // +T — block NOTICEs to the channel
|
||||
pub no_color: bool, // +c — reject messages with formatting/colour
|
||||
pub strip_color: bool, // +S — strip formatting/colour from messages
|
||||
pub reg_only: bool, // +R — only logged-in (account) users may join
|
||||
pub reg_moderated: bool, // +M — only logged-in users may speak
|
||||
pub censor: bool, // +G — replace configured bad words
|
||||
pub auditorium: bool, // +u — hide non-ops from non-ops
|
||||
pub flood: Option<MsgFlood>, // +f
|
||||
pub joinflood: Option<Rate>, // +j
|
||||
pub nickflood: Option<Rate>, // +F
|
||||
pub redirect: Option<String>, // +L <#target> — when full, send there
|
||||
}
|
||||
|
||||
impl ChanModes {
|
||||
/// Set/clear a no-parameter flag mode by its letter (the S2S mode applier).
|
||||
pub fn set_by_letter(&mut self, c: char, on: bool) {
|
||||
match c {
|
||||
'm' => self.moderated = on,
|
||||
'n' => self.no_external = on,
|
||||
't' => self.topic_ops = on,
|
||||
'i' => self.invite_only = on,
|
||||
's' => self.secret = on,
|
||||
'z' => self.secure_only = on,
|
||||
'p' => self.private = on,
|
||||
'O' => self.oper_only = on,
|
||||
'N' => self.no_nick = on,
|
||||
'C' => self.no_ctcp = on,
|
||||
'T' => self.no_notice = on,
|
||||
'c' => self.no_color = on,
|
||||
'S' => self.strip_color = on,
|
||||
'R' => self.reg_only = on,
|
||||
'M' => self.reg_moderated = on,
|
||||
'G' => self.censor = on,
|
||||
'u' => self.auditorium = on,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `+mnt`, or with `params` the +k/+l arguments too: `+ntkl secret 20`.
|
||||
pub fn render(&self, params: bool) -> String {
|
||||
let mut s = String::from("+");
|
||||
for (on, ch) in [
|
||||
(self.invite_only, 'i'),
|
||||
(self.moderated, 'm'),
|
||||
(self.no_external, 'n'),
|
||||
(self.secret, 's'),
|
||||
(self.topic_ops, 't'),
|
||||
(self.secure_only, 'z'),
|
||||
(self.private, 'p'),
|
||||
(self.oper_only, 'O'),
|
||||
(self.no_nick, 'N'),
|
||||
(self.no_ctcp, 'C'),
|
||||
(self.no_notice, 'T'),
|
||||
(self.no_color, 'c'),
|
||||
(self.strip_color, 'S'),
|
||||
(self.reg_only, 'R'),
|
||||
(self.reg_moderated, 'M'),
|
||||
(self.censor, 'G'),
|
||||
(self.auditorium, 'u'),
|
||||
] {
|
||||
if on {
|
||||
s.push(ch);
|
||||
}
|
||||
}
|
||||
if self.key.is_some() {
|
||||
s.push('k');
|
||||
}
|
||||
if self.limit.is_some() {
|
||||
s.push('l');
|
||||
}
|
||||
if self.flood.is_some() {
|
||||
s.push('f');
|
||||
}
|
||||
if self.joinflood.is_some() {
|
||||
s.push('j');
|
||||
}
|
||||
if self.nickflood.is_some() {
|
||||
s.push('F');
|
||||
}
|
||||
if self.redirect.is_some() {
|
||||
s.push('L');
|
||||
}
|
||||
if params {
|
||||
if let Some(k) = &self.key {
|
||||
s.push(' ');
|
||||
s.push_str(k);
|
||||
}
|
||||
if let Some(l) = self.limit {
|
||||
s.push(' ');
|
||||
s.push_str(&l.to_string());
|
||||
}
|
||||
if let Some(f) = &self.flood {
|
||||
let star = if f.ban { "*" } else { "" };
|
||||
s.push_str(&format!(" {star}{}:{}", f.lines, f.secs));
|
||||
}
|
||||
if let Some(j) = &self.joinflood {
|
||||
s.push_str(&format!(" {}:{}", j.count, j.secs));
|
||||
}
|
||||
if let Some(n) = &self.nickflood {
|
||||
s.push_str(&format!(" {}:{}", n.count, n.secs));
|
||||
}
|
||||
if let Some(t) = &self.redirect {
|
||||
s.push(' ');
|
||||
s.push_str(t);
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Channel {
|
||||
pub name: String, // display casing
|
||||
pub topic: Option<Topic>,
|
||||
pub members: HashMap<Uid, Member>,
|
||||
pub rmembers: HashMap<String, Member>, // remote members, by network uuid (S2S)
|
||||
pub modes: ChanModes,
|
||||
pub bans: Vec<Ban>,
|
||||
pub excepts: Vec<Ban>, // +e ban exceptions
|
||||
pub invex: Vec<Ban>, // +I invite exceptions
|
||||
pub filters: Vec<Ban>, // +g word/glob message filters (mask = the glob)
|
||||
pub invites: HashSet<Uid>, // uids allowed past +i
|
||||
pub created: u64,
|
||||
// --- ephemeral flood counters (not modes; never rendered or synced) -------
|
||||
pub msgflood_hits: HashMap<Uid, Vec<u64>>, // +f per-user message times
|
||||
pub joinflood_hits: Vec<u64>, // +j recent join times
|
||||
pub joinflood_until: u64, // +j locked out until this unix ts
|
||||
pub nickflood_hits: Vec<u64>, // +F recent nick-change times
|
||||
pub nickflood_until: u64, // +F locked out until this unix ts
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
/// A fresh channel with the default `+nt` modes.
|
||||
pub fn new(name: &str) -> Channel {
|
||||
Channel {
|
||||
name: name.to_string(),
|
||||
topic: None,
|
||||
members: HashMap::new(),
|
||||
rmembers: HashMap::new(),
|
||||
modes: ChanModes {
|
||||
no_external: true,
|
||||
topic_ops: true,
|
||||
..Default::default()
|
||||
},
|
||||
bans: Vec::new(),
|
||||
excepts: Vec::new(),
|
||||
invex: Vec::new(),
|
||||
filters: Vec::new(),
|
||||
invites: HashSet::new(),
|
||||
created: now(),
|
||||
msgflood_hits: HashMap::new(),
|
||||
joinflood_hits: Vec::new(),
|
||||
joinflood_until: 0,
|
||||
nickflood_hits: Vec::new(),
|
||||
nickflood_until: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// True once no local *and* no remote members remain (safe to drop).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.members.is_empty() && self.rmembers.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// A member's channel rank (0 if not a member).
|
||||
pub fn rank(&self, uid: Uid, key: &str) -> u8 {
|
||||
// SAMODE/SAKICK run as the server: every access check keys off rank(),
|
||||
// so a transient sudo makes them bypass the ladder cleanly.
|
||||
if self.mode_sudo {
|
||||
return RANK_OWNER;
|
||||
}
|
||||
self.channels
|
||||
.get(key)
|
||||
.and_then(|c| c.members.get(&uid))
|
||||
.map(|m| m.rank())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn is_op(&self, uid: Uid, key: &str) -> bool {
|
||||
self.rank(uid, key) >= RANK_OP
|
||||
}
|
||||
|
||||
pub fn is_member(&self, uid: Uid, key: &str) -> bool {
|
||||
self.channels
|
||||
.get(key)
|
||||
.map(|c| c.members.contains_key(&uid))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Join a user to a channel (creating it if new, giving the creator +o),
|
||||
/// then broadcast JOIN and send TOPIC + NAMES. Queues the join hook.
|
||||
pub fn join(&mut self, uid: Uid, name: &str, key_arg: Option<&str>) {
|
||||
if !valid_chan(name) {
|
||||
self.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{name} :No such channel"));
|
||||
return;
|
||||
}
|
||||
let key = name.to_ascii_lowercase();
|
||||
if self
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.contains(&key))
|
||||
.unwrap_or(true)
|
||||
{
|
||||
return; // unknown user, or already joined
|
||||
}
|
||||
// an existing channel can refuse the join (+k / +b / +i / +l)
|
||||
if let Some(ch) = self.channels.get(&key) {
|
||||
if let Some(k) = &ch.modes.key {
|
||||
if key_arg != Some(k.as_str()) {
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_BADCHANNELKEY,
|
||||
&format!("{name} :Cannot join channel (+k)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// +b — bans block even an invited user, unless a +e exception matches
|
||||
let mask = self.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
||||
if ch.bans.iter().any(|b| glob_match(&b.mask, &mask))
|
||||
&& !ch.excepts.iter().any(|e| glob_match(&e.mask, &mask))
|
||||
{
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_BANNEDFROMCHAN,
|
||||
&format!("{name} :Cannot join channel (+b)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// +i — unless invited or matched by a +I invite exception
|
||||
if ch.modes.invite_only
|
||||
&& !ch.invites.contains(&uid)
|
||||
&& !ch.invex.iter().any(|e| glob_match(&e.mask, &mask))
|
||||
{
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_INVITEONLYCHAN,
|
||||
&format!("{name} :Cannot join channel (+i)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// +z — TLS-connected users only
|
||||
if ch.modes.secure_only && !self.users.get(&uid).map(|u| u.secure).unwrap_or(false) {
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_SECUREONLYCHAN,
|
||||
&format!("{name} :Cannot join channel; TLS users only (+z is set)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// +O — IRC operators only
|
||||
if ch.modes.oper_only && !self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false) {
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_CANTJOINOPERSONLY,
|
||||
&format!("{name} :Cannot join channel; IRC operators only (+O is set)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// +R — must be logged into an account (services-registered)
|
||||
if ch.modes.reg_only
|
||||
&& self
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.account.is_none())
|
||||
.unwrap_or(true)
|
||||
{
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_NEEDREGGEDNICK,
|
||||
&format!("{name} :Cannot join channel; you must be logged in (+R is set)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// +l full — with +L redirect, bounce the user to the target instead
|
||||
if let Some(ch) = self.channels.get(&key) {
|
||||
let full = ch.modes.limit.is_some_and(|l| ch.members.len() as u32 >= l);
|
||||
let redirect = ch.modes.redirect.clone();
|
||||
if full {
|
||||
match redirect {
|
||||
Some(t)
|
||||
if !self.in_redirect
|
||||
&& t.to_ascii_lowercase() != key
|
||||
&& !self.is_member(uid, &t.to_ascii_lowercase()) =>
|
||||
{
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_LINKCHANNEL,
|
||||
&format!("{name} {t} :Cannot join channel (+l), redirecting"),
|
||||
);
|
||||
self.in_redirect = true;
|
||||
self.join(uid, &t, None);
|
||||
self.in_redirect = false;
|
||||
return;
|
||||
}
|
||||
_ => {
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_CHANNELISFULL,
|
||||
&format!("{name} :Cannot join channel (+l)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// +j join flood — once tripped, the channel locks new joins out for 60s
|
||||
if self.channels.contains_key(&key) && self.joinflood_check(&key) {
|
||||
self.numeric(
|
||||
uid,
|
||||
ERR_UNAVAILRESOURCE,
|
||||
&format!("{name} :This channel is temporarily unavailable (+j join flood)"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let is_new = !self.channels.contains_key(&key);
|
||||
let ch = self
|
||||
.channels
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| Channel::new(name));
|
||||
ch.members.insert(
|
||||
uid,
|
||||
Member {
|
||||
op: is_new,
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
ch.invites.remove(&uid); // consume any pending invite
|
||||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.channels.insert(key.clone());
|
||||
}
|
||||
|
||||
// JOIN broadcast — extended-join clients also get the account + realname
|
||||
let (prefix, acct, realname) = {
|
||||
let u = &self.users[&uid];
|
||||
(
|
||||
u.prefix(),
|
||||
u.account.clone().unwrap_or_else(|| "*".to_string()),
|
||||
u.realname.clone(),
|
||||
)
|
||||
};
|
||||
let plain = format!(":{prefix} JOIN {name}");
|
||||
let extended = format!(":{prefix} JOIN {name} {acct} :{realname}");
|
||||
let aud = self.channels[&key].modes.auditorium;
|
||||
let members: Vec<Uid> = self.channels[&key].members.keys().copied().collect();
|
||||
for m in members {
|
||||
// +u auditorium: non-op members don't see other users join
|
||||
if aud && m != uid && self.rank(m, &key) < RANK_OP {
|
||||
continue;
|
||||
}
|
||||
let ext = self
|
||||
.users
|
||||
.get(&m)
|
||||
.map(|u| u.caps.extended_join)
|
||||
.unwrap_or(false);
|
||||
self.send(m, if ext { extended.clone() } else { plain.clone() });
|
||||
}
|
||||
if let Some(t) = self.channels[&key].topic.as_ref() {
|
||||
let text = t.text.clone();
|
||||
self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}"));
|
||||
}
|
||||
self.send_names(uid, &key);
|
||||
self.propagate_join(uid, name); // tell linked servers this user joined
|
||||
self.events.push_back(Hook::Join(uid, key));
|
||||
}
|
||||
|
||||
pub fn send_names(&self, uid: Uid, key: &str) {
|
||||
let Some(ch) = self.channels.get(key) else {
|
||||
self.numeric(uid, RPL_ENDOFNAMES, &format!("{key} :End of /NAMES list"));
|
||||
return;
|
||||
};
|
||||
// multi-prefix → all prefixes; userhost-in-names → full nick!user@host
|
||||
let (multi, uhost) = self
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| (u.caps.multi_prefix, u.caps.userhost_in_names))
|
||||
.unwrap_or((false, false));
|
||||
// +u auditorium: a non-op viewer only sees ops (plus themselves)
|
||||
let hide =
|
||||
ch.modes.auditorium && ch.members.get(&uid).map(|m| m.rank()).unwrap_or(0) < RANK_OP;
|
||||
let mut names = String::new();
|
||||
for (m, flags) in &ch.members {
|
||||
if hide && *m != uid && flags.rank() < RANK_OP {
|
||||
continue;
|
||||
}
|
||||
let p = if multi {
|
||||
flags.all_prefixes()
|
||||
} else {
|
||||
flags.prefix_char().to_string()
|
||||
};
|
||||
if let Some(u) = self.users.get(m) {
|
||||
names.push_str(&p);
|
||||
let shown = if uhost { u.prefix() } else { u.nick.clone() };
|
||||
names.push_str(&shown);
|
||||
names.push(' ');
|
||||
}
|
||||
}
|
||||
// remote members (users on linked servers)
|
||||
for (ruuid, mem) in &ch.rmembers {
|
||||
if hide && mem.rank() < RANK_OP {
|
||||
continue;
|
||||
}
|
||||
if let Some(ru) = self.remote_users.get(ruuid) {
|
||||
let p = if multi {
|
||||
mem.all_prefixes()
|
||||
} else {
|
||||
mem.prefix_char().to_string()
|
||||
};
|
||||
names.push_str(&p);
|
||||
let shown = if uhost { ru.prefix() } else { ru.nick.clone() };
|
||||
names.push_str(&shown);
|
||||
names.push(' ');
|
||||
}
|
||||
}
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_NAMREPLY,
|
||||
&format!("= {} :{}", ch.name, names.trim_end()),
|
||||
);
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_ENDOFNAMES,
|
||||
&format!("{} :End of /NAMES list", ch.name),
|
||||
);
|
||||
}
|
||||
|
||||
/// True if `uid` is caught by an acting extban of type `kind` (`m`/`c`/`n`) on
|
||||
/// `key` with no matching `kind:` exception in +e. The stored mask is
|
||||
/// `kind:<hostmask>`; we glob the hostmask part against the user's prefix.
|
||||
pub fn extban_active(&self, uid: Uid, key: &str, kind: char) -> bool {
|
||||
let Some(ch) = self.channels.get(key) else {
|
||||
return false;
|
||||
};
|
||||
let Some(who) = self.users.get(&uid).map(|u| u.prefix()) else {
|
||||
return false;
|
||||
};
|
||||
let pfx = format!("{kind}:");
|
||||
let hit = |list: &Vec<Ban>| {
|
||||
list.iter()
|
||||
.filter(|b| b.mask.starts_with(&pfx))
|
||||
.any(|b| glob_match(&b.mask[2..], &who))
|
||||
};
|
||||
hit(&ch.bans) && !hit(&ch.excepts)
|
||||
}
|
||||
|
||||
/// Broadcast a membership line, honouring +u auditorium: when set, non-op
|
||||
/// members other than `actor` don't see it. `actor` always receives it.
|
||||
pub fn to_channel_vis(&self, key: &str, line: &str, actor: Uid) {
|
||||
if let Some(ch) = self.channels.get(key) {
|
||||
let aud = ch.modes.auditorium;
|
||||
for (&uid, m) in &ch.members {
|
||||
if aud && uid != actor && m.rank() < RANK_OP {
|
||||
continue;
|
||||
}
|
||||
self.send(uid, line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// +f: record a channel message from `uid`; `Some(ban)` when they just went
|
||||
/// over the limit (ban ⇒ also set a +b). Members at half-op+ and opers are
|
||||
/// exempt (checked by the caller).
|
||||
pub fn messageflood_hit(&mut self, uid: Uid, key: &str) -> Option<bool> {
|
||||
let n = now();
|
||||
let ch = self.channels.get_mut(key)?;
|
||||
let f = ch.modes.flood.clone()?;
|
||||
let v = ch.msgflood_hits.entry(uid).or_default();
|
||||
v.retain(|&t| n.saturating_sub(t) < f.secs);
|
||||
v.push(n);
|
||||
if v.len() as u32 > f.lines {
|
||||
ch.msgflood_hits.remove(&uid);
|
||||
Some(f.ban)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Kick `uid` from `key` for flooding (optionally banning `*!*@host` first),
|
||||
/// broadcasting the KICK and queuing the part hook.
|
||||
pub fn flood_kick(&mut self, uid: Uid, key: &str, ban: bool) {
|
||||
let (nick, host) = match self.users.get(&uid) {
|
||||
Some(u) => (u.nick.clone(), u.host_display().to_string()),
|
||||
None => return,
|
||||
};
|
||||
let cname = self
|
||||
.channels
|
||||
.get(key)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_else(|| key.to_string());
|
||||
if ban {
|
||||
let mask = format!("*!*@{host}");
|
||||
if let Some(c) = self.channels.get_mut(key) {
|
||||
if !c.bans.iter().any(|b| b.mask == mask) {
|
||||
c.bans.push(Ban {
|
||||
mask,
|
||||
setter: self.name.clone(),
|
||||
ts: now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.to_channel(
|
||||
key,
|
||||
&format!(":{} KICK {cname} {nick} :Flood", self.name),
|
||||
None,
|
||||
);
|
||||
self.propagate_from_user(uid, &format!("KICK {cname} {nick} :Flood"));
|
||||
if let Some(c) = self.channels.get_mut(key) {
|
||||
c.members.remove(&uid);
|
||||
}
|
||||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.channels.remove(key);
|
||||
}
|
||||
self.channels.retain(|_, c| !c.is_empty());
|
||||
self.events
|
||||
.push_back(Hook::Part(uid, key.to_string(), "flood".to_string()));
|
||||
}
|
||||
|
||||
/// +j: record a join attempt on `key`; true if joins are (now) locked out.
|
||||
pub fn joinflood_check(&mut self, key: &str) -> bool {
|
||||
let n = now();
|
||||
let Some(ch) = self.channels.get_mut(key) else {
|
||||
return false;
|
||||
};
|
||||
let Some(f) = ch.modes.joinflood.clone() else {
|
||||
return false;
|
||||
};
|
||||
if n < ch.joinflood_until {
|
||||
return true; // still locked out
|
||||
}
|
||||
ch.joinflood_hits.retain(|&t| n.saturating_sub(t) < f.secs);
|
||||
ch.joinflood_hits.push(n);
|
||||
if ch.joinflood_hits.len() as u32 > f.count {
|
||||
ch.joinflood_until = n + 60; // lock the channel for 60s
|
||||
ch.joinflood_hits.clear();
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// +F: record a nick change and return the display name of any channel that
|
||||
/// is (now) locked out — the caller denies the change if so. Opers exempt.
|
||||
pub fn nickflood_blocked(&mut self, uid: Uid) -> Option<String> {
|
||||
let n = now();
|
||||
let keys: Vec<String> = self
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.iter().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
let mut blocked = None;
|
||||
for key in keys {
|
||||
let Some(ch) = self.channels.get_mut(&key) else {
|
||||
continue;
|
||||
};
|
||||
let Some(f) = ch.modes.nickflood.clone() else {
|
||||
continue;
|
||||
};
|
||||
if n < ch.nickflood_until {
|
||||
blocked.get_or_insert_with(|| ch.name.clone());
|
||||
continue;
|
||||
}
|
||||
ch.nickflood_hits.retain(|&t| n.saturating_sub(t) < f.secs);
|
||||
ch.nickflood_hits.push(n);
|
||||
if ch.nickflood_hits.len() as u32 > f.count {
|
||||
ch.nickflood_until = n + 60;
|
||||
ch.nickflood_hits.clear();
|
||||
blocked.get_or_insert_with(|| ch.name.clone());
|
||||
}
|
||||
}
|
||||
blocked
|
||||
}
|
||||
}
|
||||
|
||||
/// A channel name starts with `#`, is ≤ 50 chars, and has no space/comma/control.
|
||||
pub fn valid_chan(name: &str) -> bool {
|
||||
name.starts_with('#')
|
||||
&& name.len() > 1
|
||||
&& name.len() <= 50
|
||||
&& !name
|
||||
.chars()
|
||||
.any(|c| c == ' ' || c == ',' || (c as u32) < 0x20)
|
||||
}
|
||||
|
||||
/// Case-insensitive glob (`*` = any run, `?` = one char) — for +b mask matching.
|
||||
pub fn glob_match(pat: &str, s: &str) -> bool {
|
||||
let p: Vec<char> = pat.to_lowercase().chars().collect();
|
||||
let t: Vec<char> = s.to_lowercase().chars().collect();
|
||||
let (mut pi, mut ti) = (0usize, 0usize);
|
||||
let mut star: Option<usize> = None;
|
||||
let mut mark = 0usize;
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if pi < p.len() && p[pi] == '*' {
|
||||
star = Some(pi);
|
||||
mark = ti;
|
||||
pi += 1;
|
||||
} else if let Some(sp) = star {
|
||||
pi = sp + 1;
|
||||
mark += 1;
|
||||
ti = mark;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while pi < p.len() && p[pi] == '*' {
|
||||
pi += 1;
|
||||
}
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
/// Fill out a ban mask to `nick!user@host` form (`bob` → `bob!*@*`).
|
||||
pub fn normalize_mask(m: &str) -> String {
|
||||
match (m.contains('!'), m.contains('@')) {
|
||||
(true, true) => m.to_string(),
|
||||
(false, true) => format!("*!{m}"),
|
||||
(true, false) => format!("{m}@*"),
|
||||
(false, false) => format!("{m}!*@*"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`normalize_mask`] but aware of extbans: `m:bob` normalises only the
|
||||
/// value after the `X:` prefix, so acting bans (`m:` mute, `c:` nocolor,
|
||||
/// `n:` nonick) keep their type while their hostmask is filled out.
|
||||
pub fn normalize_ban_mask(m: &str) -> String {
|
||||
let b = m.as_bytes();
|
||||
if b.len() >= 2 && b[1] == b':' && (b[0] as char).is_ascii_alphabetic() {
|
||||
return format!("{}:{}", &m[..1], normalize_mask(&m[2..]));
|
||||
}
|
||||
normalize_mask(m)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn member_rank_and_prefix_char_take_the_highest() {
|
||||
let mut m = Member::default();
|
||||
assert_eq!(m.rank(), 0);
|
||||
assert_eq!(m.prefix_char(), "");
|
||||
m.voice = true;
|
||||
assert_eq!((m.rank(), m.prefix_char()), (RANK_VOICE, "+"));
|
||||
m.halfop = true;
|
||||
assert_eq!((m.rank(), m.prefix_char()), (RANK_HALFOP, "%"));
|
||||
m.op = true;
|
||||
assert_eq!((m.rank(), m.prefix_char()), (RANK_OP, "@"));
|
||||
m.admin = true;
|
||||
assert_eq!((m.rank(), m.prefix_char()), (RANK_ADMIN, "&"));
|
||||
m.owner = true;
|
||||
assert_eq!((m.rank(), m.prefix_char()), (RANK_OWNER, "~"));
|
||||
}
|
||||
}
|
||||
29
src/command.rs
Normal file
29
src/command.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
//! The command API — echoIRCd's answer to InspIRCd's `Command` class.
|
||||
//!
|
||||
//! A command is a stateless handler registered by name. The core validates
|
||||
//! `min_params` and the registration gate (`before_reg`) before calling
|
||||
//! [`Command::handle`], which gets `&mut Server` and does the work.
|
||||
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
/// Outcome of a command (mirrors InspIRCd's `CmdResult`, minus server-only bits).
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum CmdResult {
|
||||
Ok,
|
||||
Fail,
|
||||
}
|
||||
|
||||
pub trait Command: Send {
|
||||
/// The command name, upper-case (also its registry key).
|
||||
fn name(&self) -> &'static str;
|
||||
/// Minimum parameters; fewer ⇒ the core replies `461` and skips the handler.
|
||||
fn min_params(&self) -> usize {
|
||||
0
|
||||
}
|
||||
/// May this run before the client has registered (NICK/USER/CAP/PING/QUIT)?
|
||||
fn before_reg(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn handle(&self, srv: &mut Server, uid: Uid, params: &[String]) -> CmdResult;
|
||||
}
|
||||
193
src/config.rs
Normal file
193
src/config.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
//! Tiny `key = value` config, same spirit as rubot.conf (no XML, no deps).
|
||||
//!
|
||||
//! ```text
|
||||
//! servername = echo.devtronic.pro
|
||||
//! network = echoNet
|
||||
//! bind = 127.0.0.1:6767
|
||||
//! motd = Welcome to echoIRCd
|
||||
//! oper = god secret
|
||||
//! ```
|
||||
|
||||
/// 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)]
|
||||
pub struct LinkBlock {
|
||||
pub name: String,
|
||||
pub ip: String,
|
||||
pub port: u16,
|
||||
pub password: String,
|
||||
pub autoconnect: bool,
|
||||
}
|
||||
|
||||
/// Config for the `antimixedutf8` module (blocks mixed-script look-alike spam).
|
||||
#[derive(Clone)]
|
||||
pub struct AntiMixedCfg {
|
||||
pub enable: bool,
|
||||
pub threshold: u32,
|
||||
pub minlen: usize,
|
||||
pub action: String, // block | kill | gline | kline | zline
|
||||
pub duration: u64, // seconds, for the *line actions
|
||||
pub reason: String,
|
||||
pub block_msg: String, // notice text sent on the "block" action
|
||||
pub check_channel: bool,
|
||||
pub check_private: bool,
|
||||
}
|
||||
|
||||
impl Default for AntiMixedCfg {
|
||||
fn default() -> AntiMixedCfg {
|
||||
AntiMixedCfg {
|
||||
enable: false,
|
||||
threshold: 8,
|
||||
minlen: 10,
|
||||
action: "block".to_string(),
|
||||
duration: 3600,
|
||||
reason: "Mixed-script text (spam).".to_string(),
|
||||
block_msg: "Your message contains mixed look-alike characters often used by \
|
||||
spam. Please rewrite it and try again."
|
||||
.to_string(),
|
||||
check_channel: true,
|
||||
check_private: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
pub servername: String,
|
||||
pub network: String,
|
||||
pub bind: String,
|
||||
pub bind_tls: Option<String>, // e.g. 0.0.0.0:6697 — the TLS listener
|
||||
pub tls_cert: Option<String>, // PEM certificate chain
|
||||
pub tls_key: Option<String>, // PEM private key
|
||||
pub motd: Vec<String>,
|
||||
pub opers: Vec<(String, String)>, // (name, password)
|
||||
pub cloak_key: Option<String>, // secret key for host cloaking (+x); None = off
|
||||
pub sid: String, // this server's 3-char server id (S2S)
|
||||
pub serverdesc: String, // this server's description
|
||||
pub bind_server: Option<String>, // the server-to-server link listener
|
||||
pub links: Vec<LinkBlock>, // peers we accept / dial
|
||||
pub conf_path: String, // where this was loaded from (for REHASH)
|
||||
pub censor: Vec<(String, String)>, // +G bad words: (find, replace); empty replace = block
|
||||
pub amu: AntiMixedCfg, // antimixedutf8 module config
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Config {
|
||||
Config {
|
||||
servername: "echo.local".to_string(),
|
||||
network: "echoNet".to_string(),
|
||||
bind: "127.0.0.1:6767".to_string(),
|
||||
bind_tls: None,
|
||||
tls_cert: None,
|
||||
tls_key: None,
|
||||
motd: Vec::new(),
|
||||
opers: Vec::new(),
|
||||
cloak_key: None,
|
||||
sid: "0AA".to_string(),
|
||||
serverdesc: "echoIRCd server".to_string(),
|
||||
bind_server: None,
|
||||
links: Vec::new(),
|
||||
conf_path: "echoircd.conf".to_string(),
|
||||
censor: Vec::new(),
|
||||
amu: AntiMixedCfg::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Read a config file, falling back to defaults for anything missing. A
|
||||
/// missing file is not an error — you get the defaults.
|
||||
pub fn load(path: &str) -> Config {
|
||||
let mut c = Config {
|
||||
conf_path: path.to_string(),
|
||||
..Config::default()
|
||||
};
|
||||
let Ok(text) = std::fs::read_to_string(path) else {
|
||||
return c;
|
||||
};
|
||||
for line in text.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||
continue;
|
||||
}
|
||||
let Some((k, v)) = line.split_once('=') else {
|
||||
continue;
|
||||
};
|
||||
let (k, v) = (k.trim(), v.trim());
|
||||
match k {
|
||||
"servername" | "server" => c.servername = v.to_string(),
|
||||
"network" => c.network = v.to_string(),
|
||||
"bind" => c.bind = v.to_string(),
|
||||
"bind_tls" => c.bind_tls = Some(v.to_string()),
|
||||
"tls_cert" => c.tls_cert = Some(v.to_string()),
|
||||
"tls_key" => c.tls_key = Some(v.to_string()),
|
||||
"cloak_key" => c.cloak_key = Some(v.to_string()),
|
||||
"sid" => c.sid = v.to_string(),
|
||||
"serverdesc" | "description" => c.serverdesc = v.to_string(),
|
||||
"bind_server" => c.bind_server = Some(v.to_string()),
|
||||
"link" => {
|
||||
// link = <name> <ip> <port> <password> [autoconnect]
|
||||
let t: Vec<&str> = v.split_whitespace().collect();
|
||||
if t.len() >= 4 {
|
||||
if let Ok(port) = t[2].parse::<u16>() {
|
||||
c.links.push(LinkBlock {
|
||||
name: t[0].to_string(),
|
||||
ip: t[1].to_string(),
|
||||
port,
|
||||
password: t[3].to_string(),
|
||||
autoconnect: t.get(4).is_some_and(|a| {
|
||||
a.eq_ignore_ascii_case("autoconnect")
|
||||
|| a.eq_ignore_ascii_case("connect")
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
"motd" => c.motd.push(v.to_string()),
|
||||
"oper" => {
|
||||
let mut it = v.split_whitespace();
|
||||
if let (Some(n), Some(p)) = (it.next(), it.next()) {
|
||||
c.opers.push((n.to_string(), p.to_string()));
|
||||
}
|
||||
}
|
||||
// +G censor word: `badword = <find> [replace]` (no replace ⇒ block)
|
||||
"badword" => {
|
||||
let mut it = v.splitn(2, char::is_whitespace);
|
||||
if let Some(find) = it.next().filter(|f| !f.is_empty()) {
|
||||
let replace = it.next().unwrap_or("").trim().to_string();
|
||||
c.censor.push((find.to_string(), replace));
|
||||
}
|
||||
}
|
||||
"antimixedutf8" | "amu" => {
|
||||
c.amu.enable =
|
||||
matches!(v.to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1")
|
||||
}
|
||||
"amu_threshold" => {
|
||||
if let Ok(n) = v.parse() {
|
||||
c.amu.threshold = n;
|
||||
}
|
||||
}
|
||||
"amu_minlen" => {
|
||||
if let Ok(n) = v.parse() {
|
||||
c.amu.minlen = n;
|
||||
}
|
||||
}
|
||||
"amu_action" => c.amu.action = v.to_string(),
|
||||
"amu_duration" => {
|
||||
if let Some(d) = crate::xline::parse_duration(v) {
|
||||
c.amu.duration = d;
|
||||
}
|
||||
}
|
||||
"amu_reason" => c.amu.reason = v.to_string(),
|
||||
"amu_message" => c.amu.block_msg = v.to_string(),
|
||||
"amu_target" => {
|
||||
let t = v.to_ascii_lowercase();
|
||||
c.amu.check_channel = t == "both" || t == "channel";
|
||||
c.amu.check_private = t == "both" || t == "private";
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
471
src/coremods/core_channel.rs
Normal file
471
src/coremods/core_channel.rs
Normal file
|
|
@ -0,0 +1,471 @@
|
|||
//! core_channel — channel membership commands: JOIN, PART, KICK, TOPIC, NAMES.
|
||||
|
||||
use crate::channels::{Topic, RANK_HALFOP};
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::module::Hook;
|
||||
use crate::numeric::*;
|
||||
use crate::server::{now, Server};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(Join),
|
||||
Box::new(Part),
|
||||
Box::new(Kick),
|
||||
Box::new(TopicCmd),
|
||||
Box::new(Names),
|
||||
Box::new(Invite),
|
||||
Box::new(Knock),
|
||||
Box::new(Cycle),
|
||||
Box::new(Remove),
|
||||
]
|
||||
}
|
||||
|
||||
/// KNOCK — ask for an invite to an invite-only channel.
|
||||
struct Knock;
|
||||
impl Command for Knock {
|
||||
fn name(&self) -> &'static str {
|
||||
"KNOCK"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let chan = ¶ms[0];
|
||||
let key = chan.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let can = s.channels[&key].modes.invite_only && !s.is_member(uid, &key);
|
||||
if !can {
|
||||
let nick = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
s.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :Can't KNOCK on {chan} (not invite-only, or you're on it)",
|
||||
s.name
|
||||
),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let reason = params
|
||||
.get(1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "requesting an invite".to_string());
|
||||
let who = s.users[&uid].prefix();
|
||||
s.to_channel(
|
||||
&key,
|
||||
&format!(
|
||||
":{} NOTICE {chan} :[Knock] {who} is knocking: {reason}",
|
||||
s.name
|
||||
),
|
||||
None,
|
||||
);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_KNOCKDLVR,
|
||||
&format!("{chan} :Your KNOCK has been delivered"),
|
||||
);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// CYCLE — part and immediately rejoin a channel.
|
||||
struct Cycle;
|
||||
impl Command for Cycle {
|
||||
fn name(&self) -> &'static str {
|
||||
"CYCLE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let chan = ¶ms[0];
|
||||
let key = chan.to_ascii_lowercase();
|
||||
if !s.is_member(uid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{chan} :You're not on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let prefix = s.users[&uid].prefix();
|
||||
s.to_channel(&key, &format!(":{prefix} PART {chan} :cycling"), None);
|
||||
s.propagate_part(uid, chan, "cycling");
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.members.remove(&uid);
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.channels.remove(&key);
|
||||
}
|
||||
s.channels.retain(|_, c| !c.is_empty());
|
||||
s.join(uid, chan, None);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// REMOVE — like KICK, but the target sees a PART (a softer removal).
|
||||
struct Remove;
|
||||
impl Command for Remove {
|
||||
fn name(&self) -> &'static str {
|
||||
"REMOVE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (chan, victim) = (¶ms[0], ¶ms[1]);
|
||||
let key = chan.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if s.rank(uid, &key) < RANK_HALFOP {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{chan} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(victim) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{victim} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if !s.channels[&key].members.contains_key(&tuid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERNOTINCHANNEL,
|
||||
&format!("{victim} {chan} :They aren't on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if s.rank(uid, &key) < s.rank(tuid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{chan} :You cannot remove a user of higher rank"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let by = s.users[&uid].nick.clone();
|
||||
let reason = match params.get(2) {
|
||||
Some(r) => format!("Removed by {by}: {r}"),
|
||||
None => format!("Removed by {by}"),
|
||||
};
|
||||
let prefix = s.users[&tuid].prefix();
|
||||
s.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), None);
|
||||
s.propagate_part(tuid, chan, &reason);
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.members.remove(&tuid);
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&tuid) {
|
||||
u.channels.remove(&key);
|
||||
}
|
||||
s.channels.retain(|_, c| !c.is_empty());
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Invite;
|
||||
impl Command for Invite {
|
||||
fn name(&self) -> &'static str {
|
||||
"INVITE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (tnick, chan) = (¶ms[0], ¶ms[1]);
|
||||
let key = chan.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !s.is_member(uid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{chan} :You're not on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// only ops may invite into an +i channel
|
||||
if s.channels[&key].modes.invite_only && !s.is_op(uid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{chan} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(tnick) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{tnick} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if s.channels[&key].members.contains_key(&tuid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERONCHANNEL,
|
||||
&format!("{tnick} {chan} :is already on channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.invites.insert(tuid);
|
||||
}
|
||||
let who = s.users[&tuid].nick.clone();
|
||||
s.numeric(uid, RPL_INVITING, &format!("{who} {chan}"));
|
||||
let prefix = s.users[&uid].prefix();
|
||||
s.send(tuid, format!(":{prefix} INVITE {who} :{chan}"));
|
||||
// invite-notify: tell capable channel members about the invite
|
||||
let notify = format!(":{prefix} INVITE {who} {chan}");
|
||||
let members: Vec<Uid> = s.channels[&key].members.keys().copied().collect();
|
||||
for m in members {
|
||||
if m != uid
|
||||
&& s.users
|
||||
.get(&m)
|
||||
.map(|u| u.caps.invite_notify)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send(m, notify.clone());
|
||||
}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Join;
|
||||
impl Command for Join {
|
||||
fn name(&self) -> &'static str {
|
||||
"JOIN"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let keys: Vec<&str> = params
|
||||
.get(1)
|
||||
.map(|k| k.split(',').collect())
|
||||
.unwrap_or_default();
|
||||
for (i, name) in params[0].split(',').filter(|x| !x.is_empty()).enumerate() {
|
||||
s.join(uid, name, keys.get(i).copied());
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Part;
|
||||
impl Command for Part {
|
||||
fn name(&self) -> &'static str {
|
||||
"PART"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let reason = params.get(1).cloned().unwrap_or_default();
|
||||
for target in params[0].split(',').filter(|x| !x.is_empty()) {
|
||||
let key = target.to_ascii_lowercase();
|
||||
let on = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.contains(&key))
|
||||
.unwrap_or(false);
|
||||
if !on {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{target} :You're not on that channel"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let prefix = s.users[&uid].prefix();
|
||||
let line = if reason.is_empty() {
|
||||
format!(":{prefix} PART {target}")
|
||||
} else {
|
||||
format!(":{prefix} PART {target} :{reason}")
|
||||
};
|
||||
s.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part
|
||||
s.propagate_part(uid, target, &reason); // tell linked servers
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.members.remove(&uid);
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.channels.remove(&key);
|
||||
}
|
||||
s.channels.retain(|_, c| !c.is_empty());
|
||||
s.events.push_back(Hook::Part(uid, key, reason.clone()));
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Kick;
|
||||
impl Command for Kick {
|
||||
fn name(&self) -> &'static str {
|
||||
"KICK"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (chan, victim) = (¶ms[0], ¶ms[1]);
|
||||
let key = chan.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if s.rank(uid, &key) < RANK_HALFOP {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{chan} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(victim) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{victim} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if !s.channels[&key].members.contains_key(&tuid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERNOTINCHANNEL,
|
||||
&format!("{victim} {chan} :They aren't on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// can't kick someone who out-ranks you
|
||||
if s.rank(uid, &key) < s.rank(tuid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{chan} :You cannot kick a user of higher rank"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let kicker = s.users[&uid].nick.clone();
|
||||
let reason = params.get(2).cloned().unwrap_or(kicker);
|
||||
let prefix = s.users[&uid].prefix();
|
||||
s.to_channel(
|
||||
&key,
|
||||
&format!(":{prefix} KICK {chan} {victim} :{reason}"),
|
||||
None,
|
||||
);
|
||||
s.propagate_from_user(uid, &format!("KICK {chan} {victim} :{reason}")); // tell links
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.members.remove(&tuid);
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&tuid) {
|
||||
u.channels.remove(&key);
|
||||
}
|
||||
s.channels.retain(|_, c| !c.is_empty());
|
||||
s.events
|
||||
.push_back(Hook::Part(tuid, key, "kicked".to_string()));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct TopicCmd;
|
||||
impl Command for TopicCmd {
|
||||
fn name(&self) -> &'static str {
|
||||
"TOPIC"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let target = ¶ms[0];
|
||||
let key = target.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHCHANNEL,
|
||||
&format!("{target} :No such channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if params.len() < 2 {
|
||||
match s.channels[&key].topic.as_ref() {
|
||||
Some(t) => {
|
||||
let text = t.text.clone();
|
||||
s.numeric(uid, RPL_TOPIC, &format!("{target} :{text}"));
|
||||
}
|
||||
None => s.numeric(uid, RPL_NOTOPIC, &format!("{target} :No topic is set")),
|
||||
}
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
let on = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.contains(&key))
|
||||
.unwrap_or(false);
|
||||
if !on {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOTONCHANNEL,
|
||||
&format!("{target} :You're not on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +t: only ops may set the topic
|
||||
if s.channels[&key].modes.topic_ops && !s.is_op(uid, &key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{target} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let text = params[1].clone();
|
||||
let (prefix, setter) = {
|
||||
let u = &s.users[&uid];
|
||||
(u.prefix(), u.nick.clone())
|
||||
};
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.topic = Some(Topic {
|
||||
text: text.clone(),
|
||||
setter,
|
||||
ts: now(),
|
||||
});
|
||||
}
|
||||
s.to_channel(&key, &format!(":{prefix} TOPIC {target} :{text}"), None);
|
||||
s.propagate_from_user(uid, &format!("TOPIC {target} :{text}")); // tell links
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Names;
|
||||
impl Command for Names {
|
||||
fn name(&self) -> &'static str {
|
||||
"NAMES"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
for target in params[0].split(',').filter(|x| !x.is_empty()) {
|
||||
s.send_names(uid, &target.to_ascii_lowercase());
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
287
src/coremods/core_extra.rs
Normal file
287
src/coremods/core_extra.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
//! core_extra — the standard informational / utility commands a full ircd is
|
||||
//! expected to answer: LIST, WHOWAS, USERHOST, ISON, TIME, ADMIN, INFO, STATS, MAP.
|
||||
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::{iso_time, now, Server, VERSION};
|
||||
use crate::xline::XKind;
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(List),
|
||||
Box::new(Whowas),
|
||||
Box::new(UserHost),
|
||||
Box::new(IsOn),
|
||||
Box::new(Time),
|
||||
Box::new(Admin),
|
||||
Box::new(Info),
|
||||
Box::new(Stats),
|
||||
Box::new(Map),
|
||||
]
|
||||
}
|
||||
|
||||
struct List;
|
||||
impl Command for List {
|
||||
fn name(&self) -> &'static str {
|
||||
"LIST"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(uid, RPL_LISTSTART, "Channel :Users Name");
|
||||
let keys: Vec<String> = s.channels.keys().cloned().collect();
|
||||
for key in keys {
|
||||
let ch = &s.channels[&key];
|
||||
// hide secret / private channels from non-members
|
||||
if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) {
|
||||
continue;
|
||||
}
|
||||
let count = ch.members.len() + ch.rmembers.len();
|
||||
let topic = ch
|
||||
.topic
|
||||
.as_ref()
|
||||
.map(|t| t.text.clone())
|
||||
.unwrap_or_default();
|
||||
let name = ch.name.clone();
|
||||
s.numeric(uid, RPL_LIST, &format!("{name} {count} :{topic}"));
|
||||
}
|
||||
s.numeric(uid, RPL_LISTEND, ":End of /LIST");
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Whowas;
|
||||
impl Command for Whowas {
|
||||
fn name(&self) -> &'static str {
|
||||
"WHOWAS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let target = ¶ms[0];
|
||||
let want = target.to_ascii_lowercase();
|
||||
let limit = params
|
||||
.get(1)
|
||||
.and_then(|c| c.parse::<usize>().ok())
|
||||
.unwrap_or(8);
|
||||
let hits: Vec<(String, String, String, String, u64)> = s
|
||||
.whowas
|
||||
.iter()
|
||||
.filter(|e| e.nick.to_ascii_lowercase() == want)
|
||||
.take(limit)
|
||||
.map(|e| {
|
||||
(
|
||||
e.nick.clone(),
|
||||
e.ident.clone(),
|
||||
e.host.clone(),
|
||||
e.realname.clone(),
|
||||
e.ts,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
if hits.is_empty() {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_WASNOSUCHNICK,
|
||||
&format!("{target} :There was no such nickname"),
|
||||
);
|
||||
}
|
||||
for (nick, ident, host, realname, ts) in hits {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOWASUSER,
|
||||
&format!("{nick} {ident} {host} * :{realname}"),
|
||||
);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSERVER,
|
||||
&format!("{nick} {} :{}", s.name, iso_time(ts)),
|
||||
);
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWHOWAS, &format!("{target} :End of WHOWAS"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct UserHost;
|
||||
impl Command for UserHost {
|
||||
fn name(&self) -> &'static str {
|
||||
"USERHOST"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
for nick in params.iter().take(5) {
|
||||
if let Some(tuid) = s.find_nick(nick) {
|
||||
if let Some(u) = s.users.get(&tuid) {
|
||||
let star = if u.flags.oper { "*" } else { "" };
|
||||
let here = if u.flags.away.is_some() { "-" } else { "+" };
|
||||
parts.push(format!(
|
||||
"{}{star}={here}{}@{}",
|
||||
u.nick,
|
||||
u.ident,
|
||||
u.host_display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
s.numeric(uid, RPL_USERHOST, &format!(":{}", parts.join(" ")));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct IsOn;
|
||||
impl Command for IsOn {
|
||||
fn name(&self) -> &'static str {
|
||||
"ISON"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let on: Vec<String> = params
|
||||
.iter()
|
||||
.flat_map(|p| p.split_whitespace())
|
||||
.filter(|n| s.find_nick(n).is_some() || s.find_remote(n).is_some())
|
||||
.map(|n| n.to_string())
|
||||
.collect();
|
||||
s.numeric(uid, RPL_ISON, &format!(":{}", on.join(" ")));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Time;
|
||||
impl Command for Time {
|
||||
fn name(&self) -> &'static str {
|
||||
"TIME"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(uid, RPL_TIME, &format!("{} :{}", s.name, iso_time(now())));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Admin;
|
||||
impl Command for Admin {
|
||||
fn name(&self) -> &'static str {
|
||||
"ADMIN"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_ADMINME,
|
||||
&format!("{} :Administrative info", s.name),
|
||||
);
|
||||
s.numeric(uid, RPL_ADMINLOC1, &format!(":{} IRC network", s.network));
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_ADMINLOC2,
|
||||
":echoIRCd — a from-scratch ircd in Rust",
|
||||
);
|
||||
s.numeric(uid, RPL_ADMINEMAIL, &format!(":admin@{}", s.name));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Info;
|
||||
impl Command for Info {
|
||||
fn name(&self) -> &'static str {
|
||||
"INFO"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
for line in [
|
||||
format!("echoircd-{VERSION} — a from-scratch IRC daemon in Rust"),
|
||||
"Modeled on InspIRCd's API; #![forbid(unsafe_code)]".to_string(),
|
||||
format!("Running the {} network", s.network),
|
||||
] {
|
||||
s.numeric(uid, RPL_INFO, &format!(":{line}"));
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFINFO, ":End of /INFO list");
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Stats;
|
||||
impl Command for Stats {
|
||||
fn name(&self) -> &'static str {
|
||||
"STATS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let letter = params[0].chars().next().unwrap_or(' ');
|
||||
match letter {
|
||||
'u' => {
|
||||
let up = now().saturating_sub(s.created);
|
||||
let (d, h, m, sec) = (up / 86400, (up % 86400) / 3600, (up % 3600) / 60, up % 60);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_STATSUPTIME,
|
||||
&format!(":Server Up {d} days {h:02}:{m:02}:{sec:02}"),
|
||||
);
|
||||
}
|
||||
'o' => {
|
||||
let opers: Vec<String> = s.opers.iter().map(|(n, _)| n.clone()).collect();
|
||||
for n in opers {
|
||||
s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper"));
|
||||
}
|
||||
}
|
||||
'k' | 'g' | 'z' => {
|
||||
let kind = match letter {
|
||||
'k' => XKind::Kline,
|
||||
'g' => XKind::Gline,
|
||||
_ => XKind::Zline,
|
||||
};
|
||||
let rows: Vec<String> = s
|
||||
.xlines
|
||||
.iter()
|
||||
.filter(|x| x.kind == kind)
|
||||
.map(|x| {
|
||||
format!(
|
||||
"{} {} {} {} :{}",
|
||||
x.kind.tag(),
|
||||
x.mask,
|
||||
x.expires,
|
||||
x.setter,
|
||||
x.reason
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
for r in rows {
|
||||
s.numeric(uid, RPL_STATSXLINE, &r);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_ENDOFSTATS,
|
||||
&format!("{letter} :End of /STATS report"),
|
||||
);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Map;
|
||||
impl Command for Map {
|
||||
fn name(&self) -> &'static str {
|
||||
"MAP"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_MAP,
|
||||
&format!("{} ({} users)", s.name, s.users.len()),
|
||||
);
|
||||
let mut peers: Vec<String> = s.servers.values().map(|sv| sv.name.clone()).collect();
|
||||
peers.sort();
|
||||
for name in peers {
|
||||
s.numeric(uid, RPL_MAP, &format!("`- {name}"));
|
||||
}
|
||||
s.numeric(uid, RPL_MAPEND, ":End of /MAP");
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
312
src/coremods/core_info.rs
Normal file
312
src/coremods/core_info.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
//! core_info — informational commands: WHOIS, WHO, LUSERS, MOTD, VERSION.
|
||||
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::{Server, VERSION};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(Whois),
|
||||
Box::new(Who),
|
||||
Box::new(Lusers),
|
||||
Box::new(Motd),
|
||||
Box::new(VersionCmd),
|
||||
Box::new(Links),
|
||||
]
|
||||
}
|
||||
|
||||
/// LINKS — the servers this one knows about (itself + every linked peer).
|
||||
struct Links;
|
||||
impl Command for Links {
|
||||
fn name(&self) -> &'static str {
|
||||
"LINKS"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_LINKS,
|
||||
&format!("{} {} :0 {}", s.name, s.name, s.server_desc),
|
||||
);
|
||||
let mut rows: Vec<(String, String)> = s
|
||||
.servers
|
||||
.values()
|
||||
.map(|sv| (sv.name.clone(), sv.desc.clone()))
|
||||
.collect();
|
||||
rows.sort();
|
||||
for (name, desc) in rows {
|
||||
s.numeric(uid, RPL_LINKS, &format!("{name} {} :1 {desc}", s.name));
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFLINKS, "* :End of /LINKS list");
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Whois;
|
||||
impl Command for Whois {
|
||||
fn name(&self) -> &'static str {
|
||||
"WHOIS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let tnick = params[0]
|
||||
.split(',')
|
||||
.next()
|
||||
.unwrap_or(¶ms[0])
|
||||
.to_string();
|
||||
let Some(tuid) = s.find_nick(&tnick) else {
|
||||
// maybe they're on another server
|
||||
if let Some((uuid, _)) = s.find_remote(&tnick) {
|
||||
if let Some(ru) = s.remote_users.get(&uuid) {
|
||||
let srv = s
|
||||
.servers
|
||||
.get(&ru.sid)
|
||||
.map(|sv| sv.name.clone())
|
||||
.unwrap_or_else(|| ru.sid.clone());
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISUSER,
|
||||
&format!("{} {} {} * :{}", ru.nick, ru.ident, ru.host, ru.realname),
|
||||
);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSERVER,
|
||||
&format!("{} {srv} :remote user", ru.nick),
|
||||
);
|
||||
if let Some(a) = &ru.account {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISACCOUNT,
|
||||
&format!("{} {a} :is logged in as", ru.nick),
|
||||
);
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_ENDOFWHOIS,
|
||||
&format!("{} :End of /WHOIS list", ru.nick),
|
||||
);
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{tnick} :No such nick/channel"),
|
||||
);
|
||||
s.numeric(uid, RPL_ENDOFWHOIS, &format!("{tnick} :End of /WHOIS list"));
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
let asker_oper = s.is_oper(uid);
|
||||
let is_self = tuid == uid;
|
||||
let keys: Vec<String> = s.users[&tuid].channels.iter().cloned().collect();
|
||||
let (
|
||||
nick,
|
||||
ident,
|
||||
disp,
|
||||
realname,
|
||||
realhost,
|
||||
realip,
|
||||
secure,
|
||||
oper,
|
||||
bot,
|
||||
hideoper,
|
||||
hidechans,
|
||||
account,
|
||||
last_active,
|
||||
signon,
|
||||
) = {
|
||||
let u = &s.users[&tuid];
|
||||
(
|
||||
u.nick.clone(),
|
||||
u.ident.clone(),
|
||||
u.host_display().to_string(),
|
||||
u.realname.clone(),
|
||||
u.host.clone(),
|
||||
u.addr.ip().to_string(),
|
||||
u.secure,
|
||||
u.flags.oper,
|
||||
u.flags.bot,
|
||||
u.flags.hideoper,
|
||||
u.flags.hidechans,
|
||||
u.account.clone(),
|
||||
u.last_active,
|
||||
u.signon,
|
||||
)
|
||||
};
|
||||
let chans: Vec<String> = keys
|
||||
.iter()
|
||||
.filter_map(|k| s.channels.get(k).map(|c| c.name.clone()))
|
||||
.collect();
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISUSER,
|
||||
&format!("{nick} {ident} {disp} * :{realname}"),
|
||||
);
|
||||
if bot {
|
||||
s.numeric(uid, RPL_WHOISBOT, &format!("{nick} :is a bot"));
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSERVER,
|
||||
&format!("{nick} {} :echoIRCd", s.name),
|
||||
);
|
||||
// +I hides the channel list from everyone but the user themselves + opers
|
||||
if !chans.is_empty() && (is_self || asker_oper || !hidechans) {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISCHANNELS,
|
||||
&format!("{nick} :{}", chans.join(" ")),
|
||||
);
|
||||
}
|
||||
// 313: is an IRC operator (hidden by +H unless the asker is an oper)
|
||||
if oper && (!hideoper || asker_oper) {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISOPERATOR,
|
||||
&format!("{nick} :is an IRC operator"),
|
||||
);
|
||||
}
|
||||
// opers can see through the cloak to the real host/ip
|
||||
if asker_oper && disp != realhost {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISHOST,
|
||||
&format!("{nick} :is connecting from {ident}@{realhost} {realip}"),
|
||||
);
|
||||
}
|
||||
// 330: logged in to a services account
|
||||
if let Some(acct) = &account {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISACCOUNT,
|
||||
&format!("{nick} {acct} :is logged in as"),
|
||||
);
|
||||
}
|
||||
// sslinfo: advertise a secure (TLS) connection
|
||||
if secure {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISSECURE,
|
||||
&format!("{nick} :is using a secure connection"),
|
||||
);
|
||||
}
|
||||
// 317: idle time + signon time
|
||||
let idle = crate::server::now().saturating_sub(last_active);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WHOISIDLE,
|
||||
&format!("{nick} {idle} {signon} :seconds idle, signon time"),
|
||||
);
|
||||
s.numeric(uid, RPL_ENDOFWHOIS, &format!("{nick} :End of /WHOIS list"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Who;
|
||||
impl Command for Who {
|
||||
fn name(&self) -> &'static str {
|
||||
"WHO"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let target = ¶ms[0];
|
||||
if target.starts_with('#') {
|
||||
let key = target.to_ascii_lowercase();
|
||||
let multi = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.multi_prefix)
|
||||
.unwrap_or(false);
|
||||
let rows: Vec<(Uid, String, String)> = match s.channels.get(&key) {
|
||||
Some(ch) => {
|
||||
let name = ch.name.clone();
|
||||
ch.members
|
||||
.iter()
|
||||
.map(|(&m, mem)| {
|
||||
let p = if multi {
|
||||
mem.all_prefixes()
|
||||
} else {
|
||||
mem.prefix_char().to_string()
|
||||
};
|
||||
(m, name.clone(), p)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
None => Vec::new(),
|
||||
};
|
||||
for (m, name, pfx) in rows {
|
||||
if let Some(u) = s.users.get(&m) {
|
||||
let row = format!(
|
||||
"{name} {} {} {} {} H{pfx} :0 {}",
|
||||
u.ident,
|
||||
u.host_display(),
|
||||
s.name,
|
||||
u.nick,
|
||||
u.realname
|
||||
);
|
||||
s.numeric(uid, RPL_WHOREPLY, &row);
|
||||
}
|
||||
}
|
||||
} else if let Some(tuid) = s.find_nick(target) {
|
||||
let u = &s.users[&tuid];
|
||||
let row = format!(
|
||||
"* {} {} {} {} H :0 {}",
|
||||
u.ident,
|
||||
u.host_display(),
|
||||
s.name,
|
||||
u.nick,
|
||||
u.realname
|
||||
);
|
||||
s.numeric(uid, RPL_WHOREPLY, &row);
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWHO, &format!("{target} :End of /WHO list"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Lusers;
|
||||
impl Command for Lusers {
|
||||
fn name(&self) -> &'static str {
|
||||
"LUSERS"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_LUSERCLIENT,
|
||||
&format!(":There are {} users on 1 server", s.users.len()),
|
||||
);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Motd;
|
||||
impl Command for Motd {
|
||||
fn name(&self) -> &'static str {
|
||||
"MOTD"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.send_motd(uid);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct VersionCmd;
|
||||
impl Command for VersionCmd {
|
||||
fn name(&self) -> &'static str {
|
||||
"VERSION"
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
s.send(
|
||||
uid,
|
||||
format!(":{} 351 * echoircd-{VERSION} {} :", s.name, s.name),
|
||||
);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
546
src/coremods/core_message.rs
Normal file
546
src/coremods/core_message.rs
Normal file
|
|
@ -0,0 +1,546 @@
|
|||
//! core_message — PRIVMSG and NOTICE (channel + user targets).
|
||||
|
||||
use crate::channels::{glob_match, RANK_HALFOP, RANK_VOICE};
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
/// mIRC/IRC formatting control bytes (bold, colour, hex-colour, reset, …).
|
||||
const FMT: [char; 9] = [
|
||||
'\u{02}', '\u{03}', '\u{04}', '\u{0F}', '\u{11}', '\u{16}', '\u{1D}', '\u{1E}', '\u{1F}',
|
||||
];
|
||||
|
||||
fn is_ctcp(t: &str) -> bool {
|
||||
t.starts_with('\u{01}')
|
||||
}
|
||||
fn is_action(t: &str) -> bool {
|
||||
t.starts_with("\u{01}ACTION")
|
||||
}
|
||||
fn has_formatting(t: &str) -> bool {
|
||||
t.chars().any(|c| FMT.contains(&c))
|
||||
}
|
||||
/// Strip formatting/colour codes (drops \x03 colour specs and \x04 hex specs).
|
||||
fn strip_formatting(t: &str) -> String {
|
||||
let cs: Vec<char> = t.chars().collect();
|
||||
let mut out = String::with_capacity(cs.len());
|
||||
let mut i = 0;
|
||||
while i < cs.len() {
|
||||
match cs[i] {
|
||||
'\u{02}' | '\u{0F}' | '\u{11}' | '\u{16}' | '\u{1D}' | '\u{1E}' | '\u{1F}' => i += 1,
|
||||
'\u{03}' => {
|
||||
i += 1;
|
||||
let mut n = 0;
|
||||
while n < 2 && i < cs.len() && cs[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
n += 1;
|
||||
}
|
||||
if n > 0 && i + 1 < cs.len() && cs[i] == ',' && cs[i + 1].is_ascii_digit() {
|
||||
i += 1;
|
||||
let mut m = 0;
|
||||
while m < 2 && i < cs.len() && cs[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
m += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
'\u{04}' => {
|
||||
i += 1;
|
||||
let mut n = 0;
|
||||
while n < 6 && i < cs.len() && cs[i].is_ascii_hexdigit() {
|
||||
i += 1;
|
||||
n += 1;
|
||||
}
|
||||
if n == 6 && i + 1 < cs.len() && cs[i] == ',' && cs[i + 1].is_ascii_hexdigit() {
|
||||
i += 1;
|
||||
let mut m = 0;
|
||||
while m < 6 && i < cs.len() && cs[i].is_ascii_hexdigit() {
|
||||
i += 1;
|
||||
m += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Case-insensitive substring test over chars (Unicode-safe, no byte slicing).
|
||||
fn ci_contains(hay: &str, find: &str) -> bool {
|
||||
let h: Vec<char> = hay.chars().collect();
|
||||
let f: Vec<char> = find.chars().collect();
|
||||
if f.is_empty() || f.len() > h.len() {
|
||||
return false;
|
||||
}
|
||||
(0..=h.len() - f.len()).any(|i| (0..f.len()).all(|k| h[i + k].eq_ignore_ascii_case(&f[k])))
|
||||
}
|
||||
|
||||
/// Case-insensitive replace-all over chars (Unicode-safe, no byte slicing).
|
||||
fn ci_replace(hay: &str, find: &str, rep: &str) -> String {
|
||||
let h: Vec<char> = hay.chars().collect();
|
||||
let f: Vec<char> = find.chars().collect();
|
||||
if f.is_empty() {
|
||||
return hay.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(hay.len());
|
||||
let mut i = 0;
|
||||
while i < h.len() {
|
||||
let hit =
|
||||
i + f.len() <= h.len() && (0..f.len()).all(|k| h[i + k].eq_ignore_ascii_case(&f[k]));
|
||||
if hit {
|
||||
out.push_str(rep);
|
||||
i += f.len();
|
||||
} else {
|
||||
out.push(h[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// +G censor: replace each configured bad word in `body`. Returns `None` when a
|
||||
/// matched word has an empty replacement (⇒ the message must be blocked).
|
||||
fn apply_censor(body: &str, censor: &[(String, String)]) -> Option<String> {
|
||||
let mut out = body.to_string();
|
||||
for (find, replace) in censor {
|
||||
if find.is_empty() || !ci_contains(&out, find) {
|
||||
continue;
|
||||
}
|
||||
if replace.is_empty() {
|
||||
return None; // no replacement ⇒ block
|
||||
}
|
||||
out = ci_replace(&out, find, replace);
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![Box::new(PrivMsg), Box::new(Notice), Box::new(TagMsg)]
|
||||
}
|
||||
|
||||
/// Shared PRIVMSG/NOTICE delivery. NOTICE never generates automatic replies.
|
||||
fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResult {
|
||||
let cmd = if notice { "NOTICE" } else { "PRIVMSG" };
|
||||
if params.is_empty() {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NORECIPIENT,
|
||||
&format!(":No recipient given ({cmd})"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if params.len() < 2 || params[1].is_empty() {
|
||||
if !notice {
|
||||
s.numeric(uid, ERR_NOTEXTTOSEND, ":No text to send");
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let (target, text) = (¶ms[0], ¶ms[1]);
|
||||
let Some(prefix) = s.users.get(&uid).map(|u| u.prefix()) else {
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if target.starts_with('#') {
|
||||
let key = target.to_ascii_lowercase();
|
||||
let member = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.members.contains_key(&uid))
|
||||
.unwrap_or(false);
|
||||
if !member {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +m: only voiced-or-above may speak
|
||||
let moderated = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.modes.moderated)
|
||||
.unwrap_or(false);
|
||||
if moderated && s.rank(uid, &key) < RANK_VOICE {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel (+m)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +M: only logged-in (account) users may speak (voiced-or-above exempt)
|
||||
let reg_moderated = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.modes.reg_moderated)
|
||||
.unwrap_or(false);
|
||||
if reg_moderated && s.rank(uid, &key) < RANK_VOICE && !s.is_logged_in(uid) {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NEEDREGGEDNICK,
|
||||
&format!("{target} :You must be logged into an account to speak here (+M)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// extban `m:` mute — matched users can't speak unless voiced-or-above
|
||||
if s.extban_active(uid, &key, 'm') && s.rank(uid, &key) < RANK_VOICE {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel (you're muted, +b m:)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +f message flood — ops/half-ops and opers are exempt; others get kicked
|
||||
let flood_exempt = s.rank(uid, &key) >= RANK_HALFOP
|
||||
|| s.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false);
|
||||
if !flood_exempt {
|
||||
if let Some(ban) = s.messageflood_hit(uid, &key) {
|
||||
s.flood_kick(uid, &key, ban);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
}
|
||||
// content-based modes: +C no CTCP, +T no notices, +c no colour, +S strip
|
||||
let (no_ctcp, no_notice, no_color, strip) = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| {
|
||||
(
|
||||
c.modes.no_ctcp,
|
||||
c.modes.no_notice,
|
||||
c.modes.no_color,
|
||||
c.modes.strip_color,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if notice && no_notice {
|
||||
return CmdResult::Fail; // +T — NOTICEs are silently dropped
|
||||
}
|
||||
if no_ctcp && is_ctcp(text) && !is_action(text) {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :CTCP is disabled (+C)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if no_color && has_formatting(text) {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Formatting/colour is disabled (+c)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// extban `c:` no-colour — matched users can't send formatting
|
||||
if s.extban_active(uid, &key, 'c') && has_formatting(text) {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Formatting/colour is disabled for you (+b c:)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +g channel filter — block messages matching any word/glob (use *word*)
|
||||
let filtered = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.filters.iter().any(|f| glob_match(&f.mask, text)))
|
||||
.unwrap_or(false);
|
||||
if filtered {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel (blocked by +g filter)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let mut body = if strip {
|
||||
strip_formatting(text)
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
// +G censor — replace configured bad words (empty replacement ⇒ block)
|
||||
let censor_on = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.modes.censor)
|
||||
.unwrap_or(false);
|
||||
if censor_on && !s.censor.is_empty() {
|
||||
match apply_censor(&body, &s.censor) {
|
||||
Some(b) => body = b,
|
||||
None => {
|
||||
if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Cannot send to channel (+G censor)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
}
|
||||
}
|
||||
// deliver to every member except the sender and +D (deaf) users, tagging
|
||||
// per-recipient (server-time + any client-only tags on the line)
|
||||
let line = format!(":{prefix} {cmd} {target} :{body}");
|
||||
let ctags = s.line_ctags.clone();
|
||||
let msgid = s.next_msgid(); // one id shared by every recipient of this message
|
||||
let members: Vec<Uid> = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.members.keys().copied().collect())
|
||||
.unwrap_or_default();
|
||||
for m in members {
|
||||
if m == uid || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
s.send_tagged(m, &ctags, &msgid, &line);
|
||||
}
|
||||
// echo-message: give the sender their own copy if they asked for one
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(uid, &ctags, &msgid, &line);
|
||||
}
|
||||
// propagate to linked servers that have members in this channel
|
||||
s.send_channel_to_links(uid, &key, target, cmd, &body);
|
||||
} else if let Some(tuid) = s.find_nick(target) {
|
||||
// user +R (regdeaf): drop messages from users not logged into an account
|
||||
if s.users
|
||||
.get(&tuid)
|
||||
.map(|u| u.flags.reg_only_pm)
|
||||
.unwrap_or(false)
|
||||
&& !s.is_logged_in(uid)
|
||||
{
|
||||
if !notice {
|
||||
let tn = s
|
||||
.users
|
||||
.get(&tuid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NEEDREGGEDNICK,
|
||||
&format!("{tn} :You must be logged into an account to message this user (+R)"),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// user +z (sslqueries): only TLS users may PM them
|
||||
if s.users.get(&tuid).map(|u| u.flags.ssl_pm).unwrap_or(false)
|
||||
&& !s.users.get(&uid).map(|u| u.secure).unwrap_or(false)
|
||||
{
|
||||
if !notice {
|
||||
let (tn, sn) = (
|
||||
s.users
|
||||
.get(&tuid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default(),
|
||||
s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
s.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {sn} :Cannot message {tn}: a TLS connection is required (+z)",
|
||||
s.name
|
||||
),
|
||||
);
|
||||
}
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// SILENCE: if the recipient silenced the sender, drop it silently — the
|
||||
// sender is never told (that's the point), but still gets their own echo.
|
||||
let silenced = s.is_silenced(tuid, &prefix);
|
||||
let pm = format!(":{prefix} {cmd} {target} :{text}");
|
||||
let ctags = s.line_ctags.clone();
|
||||
let msgid = s.next_msgid();
|
||||
if !silenced {
|
||||
s.send_tagged(tuid, &ctags, &msgid, &pm);
|
||||
}
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(uid, &ctags, &msgid, &pm);
|
||||
}
|
||||
// if the recipient is away, tell the sender (PRIVMSG only, not if silenced)
|
||||
if !notice && !silenced {
|
||||
if let Some(msg) = s.users.get(&tuid).and_then(|u| u.flags.away.clone()) {
|
||||
s.numeric(uid, RPL_AWAY, &format!("{target} :{msg}"));
|
||||
}
|
||||
}
|
||||
} else if let Some((uuid, via)) = s.find_remote(target) {
|
||||
// the target is a user on another server — route it across the link
|
||||
s.send_to_remote(uid, &uuid, via, cmd, text);
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
let ctags = s.line_ctags.clone();
|
||||
let msgid = s.next_msgid();
|
||||
s.send_tagged(
|
||||
uid,
|
||||
&ctags,
|
||||
&msgid,
|
||||
&format!(":{prefix} {cmd} {target} :{text}"),
|
||||
);
|
||||
}
|
||||
} else if !notice {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{target} :No such nick/channel"),
|
||||
);
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
|
||||
struct PrivMsg;
|
||||
impl Command for PrivMsg {
|
||||
fn name(&self) -> &'static str {
|
||||
"PRIVMSG"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
deliver(s, uid, params, false)
|
||||
}
|
||||
}
|
||||
|
||||
struct Notice;
|
||||
impl Command for Notice {
|
||||
fn name(&self) -> &'static str {
|
||||
"NOTICE"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
deliver(s, uid, params, true)
|
||||
}
|
||||
}
|
||||
|
||||
/// TAGMSG — an IRCv3 message that carries only client tags (typing, reactions, …)
|
||||
/// and no text. Relayed to targets whose clients enabled `message-tags`; clients
|
||||
/// without it never see it. Mirrors PRIVMSG's target / membership / +m rules.
|
||||
struct TagMsg;
|
||||
impl Command for TagMsg {
|
||||
fn name(&self) -> &'static str {
|
||||
"TAGMSG"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let ctags = s.line_ctags.clone();
|
||||
if ctags.is_empty() {
|
||||
return CmdResult::Ok; // no client tags -> nothing to relay
|
||||
}
|
||||
let target = ¶ms[0];
|
||||
let Some(prefix) = s.users.get(&uid).map(|u| u.prefix()) else {
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
let body = format!(":{prefix} TAGMSG {target}");
|
||||
let msgid = s.next_msgid(); // shared across this TAGMSG's recipients
|
||||
if target.starts_with('#') {
|
||||
let key = target.to_ascii_lowercase();
|
||||
if !s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.members.contains_key(&uid))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +m: only voiced-or-above may emit tags
|
||||
let moderated = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.modes.moderated)
|
||||
.unwrap_or(false);
|
||||
if moderated && s.rank(uid, &key) < RANK_VOICE {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let echo = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false);
|
||||
let members: Vec<Uid> = s
|
||||
.channels
|
||||
.get(&key)
|
||||
.map(|c| c.members.keys().copied().collect())
|
||||
.unwrap_or_default();
|
||||
for m in members {
|
||||
if (m == uid && !echo) || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
// only message-tags clients receive a TAGMSG
|
||||
if s.users
|
||||
.get(&m)
|
||||
.map(|u| u.caps.message_tags)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(m, &ctags, &msgid, &body);
|
||||
}
|
||||
}
|
||||
} else if let Some(tuid) = s.find_nick(target) {
|
||||
if s.users
|
||||
.get(&tuid)
|
||||
.map(|u| u.caps.message_tags)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(tuid, &ctags, &msgid, &body);
|
||||
}
|
||||
if s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.echo_message)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
s.send_tagged(uid, &ctags, &msgid, &body);
|
||||
}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn strip_drops_codes_but_keeps_text_and_bare_commas() {
|
||||
assert_eq!(strip_formatting("\u{03}04red\u{03} text"), "red text");
|
||||
assert_eq!(strip_formatting("\u{02}bold\u{02}"), "bold");
|
||||
assert_eq!(strip_formatting("\u{03}04,08fg"), "fg"); // colour,bg spec
|
||||
assert_eq!(strip_formatting("\u{03}4, hi"), ", hi"); // bare comma survives
|
||||
assert!(has_formatting("\u{03}4x") && !has_formatting("plain"));
|
||||
assert!(is_ctcp("\u{01}PING\u{01}") && !is_ctcp("hi"));
|
||||
assert!(is_action("\u{01}ACTION waves"));
|
||||
}
|
||||
}
|
||||
179
src/coremods/core_mode.rs
Normal file
179
src/coremods/core_mode.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
//! core_mode — the MODE command. Both channel and user modes are dispatched to
|
||||
//! the handler objects in [`crate::mode`] (InspIRCd-style `ModeHandler`s); this
|
||||
//! file just parses the modestring and orchestrates.
|
||||
|
||||
use crate::channels::RANK_HALFOP;
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::mode::{chan_mode, user_mode, Applied};
|
||||
use crate::numeric::*;
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![Box::new(Mode)]
|
||||
}
|
||||
|
||||
/// Append one mode change to the echo string, emitting the +/- only when it flips.
|
||||
fn emit(applied: &mut String, last: &mut char, sign: char, c: char) {
|
||||
if *last != sign {
|
||||
applied.push(sign);
|
||||
*last = sign;
|
||||
}
|
||||
applied.push(c);
|
||||
}
|
||||
|
||||
struct Mode;
|
||||
impl Command for Mode {
|
||||
fn name(&self) -> &'static str {
|
||||
"MODE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
apply_mode(s, uid, params)
|
||||
}
|
||||
}
|
||||
|
||||
/// The MODE body, shared with SAMODE (which wraps it in `Server::mode_sudo` so
|
||||
/// the rank gates below all pass — see `core_oper::SaMode`).
|
||||
pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let target = ¶ms[0];
|
||||
if !target.starts_with('#') {
|
||||
return apply_user_modes(s, uid, target, params);
|
||||
}
|
||||
let key = target.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHCHANNEL,
|
||||
&format!("{target} :No such channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// query: `MODE #c`
|
||||
if params.len() < 2 {
|
||||
let modestr = s.channels[&key].modes.render(s.is_member(uid, &key));
|
||||
s.numeric(uid, RPL_CHANNELMODEIS, &format!("{target} {modestr}"));
|
||||
let created = s.channels[&key].created;
|
||||
s.numeric(uid, RPL_CREATIONTIME, &format!("{target} {created}"));
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
// setting modes needs at least half-op; each handler then enforces its
|
||||
// own finer rule (prefixes need enough rank, +z needs all-secure, …)
|
||||
if s.rank(uid, &key) < RANK_HALFOP {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CHANOPRIVSNEEDED,
|
||||
&format!("{target} :You're not a channel operator"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
|
||||
// dispatch each mode letter to its handler
|
||||
let modestring = params[1].clone();
|
||||
let args = ¶ms[2..];
|
||||
let mut argi = 0usize;
|
||||
let mut sign = '+';
|
||||
let mut applied = String::new();
|
||||
let mut last = ' ';
|
||||
let mut echoed: Vec<String> = Vec::new();
|
||||
for c in modestring.chars() {
|
||||
if c == '+' || c == '-' {
|
||||
sign = c;
|
||||
continue;
|
||||
}
|
||||
let adding = sign == '+';
|
||||
let Some(handler) = chan_mode(c) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_UNKNOWNMODE,
|
||||
&format!("{c} :is unknown mode char to me"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let param = if handler.wants_param(adding) {
|
||||
let p = args.get(argi).cloned();
|
||||
if p.is_some() {
|
||||
argi += 1;
|
||||
}
|
||||
p
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Applied::Yes(echo) = handler.apply(s, target, &key, uid, adding, param.as_deref()) {
|
||||
emit(&mut applied, &mut last, sign, c);
|
||||
if let Some(p) = echo {
|
||||
echoed.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !applied.is_empty() {
|
||||
let prefix = s.users[&uid].prefix();
|
||||
let pstr = if echoed.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", echoed.join(" "))
|
||||
};
|
||||
s.to_channel(
|
||||
&key,
|
||||
&format!(":{prefix} MODE {target} {applied}{pstr}"),
|
||||
None,
|
||||
);
|
||||
s.propagate_from_user(uid, &format!("MODE {target} {applied}{pstr}"));
|
||||
// links
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
|
||||
/// User modes: dispatched to the [`crate::mode`] `UserMode` handler objects.
|
||||
fn apply_user_modes(s: &mut Server, uid: Uid, target: &str, params: &[String]) -> CmdResult {
|
||||
let me = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
if !target.eq_ignore_ascii_case(&me) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERSDONTMATCH,
|
||||
":Can't change mode for other users",
|
||||
);
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
if params.len() < 2 {
|
||||
let um = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.flags.umodes())
|
||||
.unwrap_or_else(|| "+".to_string());
|
||||
s.numeric(uid, RPL_UMODEIS, &um);
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
let modestring = params[1].clone();
|
||||
let mut sign = '+';
|
||||
let mut applied = String::new();
|
||||
let mut last = ' ';
|
||||
for c in modestring.chars() {
|
||||
if c == '+' || c == '-' {
|
||||
sign = c;
|
||||
continue;
|
||||
}
|
||||
let adding = sign == '+';
|
||||
let Some(handler) = user_mode(c) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_UMODEUNKNOWNFLAG,
|
||||
&format!(":Unknown MODE flag {c}"),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if handler.apply(s, uid, adding) {
|
||||
emit(&mut applied, &mut last, sign, c);
|
||||
}
|
||||
}
|
||||
if !applied.is_empty() {
|
||||
s.send(uid, format!(":{me} MODE {me} :{applied}"));
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
778
src/coremods/core_oper.rs
Normal file
778
src/coremods/core_oper.rs
Normal file
|
|
@ -0,0 +1,778 @@
|
|||
//! core_oper — IRC operator commands: OPER, KILL, WALLOPS. Mirrors InspIRCd's
|
||||
//! `coremods/core_oper/`. Oper blocks are configured with `oper = name pass`.
|
||||
|
||||
use crate::channels::Topic;
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::config::Config;
|
||||
use crate::coremods::core_mode::apply_mode;
|
||||
use crate::module::Hook;
|
||||
use crate::numeric::*;
|
||||
use crate::server::{now, Server};
|
||||
use crate::users::{valid_host, valid_ident, valid_nick};
|
||||
use crate::xline::{parse_duration, XKind};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(Oper),
|
||||
Box::new(Kill),
|
||||
Box::new(Wallops),
|
||||
Box::new(SvsLogin),
|
||||
Box::new(SvsLogout),
|
||||
Box::new(Rehash),
|
||||
Box::new(GlobOps),
|
||||
Box::new(SaJoin),
|
||||
Box::new(SaPart),
|
||||
Box::new(SaNick),
|
||||
Box::new(Die),
|
||||
Box::new(Restart),
|
||||
Box::new(Kline),
|
||||
Box::new(Gline),
|
||||
Box::new(Zline),
|
||||
Box::new(ChgHost),
|
||||
Box::new(ChgIdent),
|
||||
Box::new(SetHost),
|
||||
Box::new(SetIdent),
|
||||
Box::new(SaMode),
|
||||
Box::new(SaTopic),
|
||||
Box::new(SaKick),
|
||||
]
|
||||
}
|
||||
|
||||
/// Reject non-opers with 481; returns whether the caller is an oper.
|
||||
fn require_oper(s: &mut Server, uid: Uid) -> bool {
|
||||
if s.is_oper(uid) {
|
||||
return true;
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- You're not an IRC operator",
|
||||
);
|
||||
false
|
||||
}
|
||||
|
||||
struct Oper;
|
||||
impl Command for Oper {
|
||||
fn name(&self) -> &'static str {
|
||||
"OPER"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let (name, pass) = (¶ms[0], ¶ms[1]);
|
||||
if s.opers.iter().any(|(n, p)| n == name && p == pass) {
|
||||
s.oper_up(uid);
|
||||
CmdResult::Ok
|
||||
} else {
|
||||
s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
|
||||
CmdResult::Fail
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Kill;
|
||||
impl Command for Kill {
|
||||
fn name(&self) -> &'static str {
|
||||
"KILL"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.is_oper(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- You're not an IRC operator",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let target = ¶ms[0];
|
||||
let reason = params
|
||||
.get(1)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Killed".to_string());
|
||||
let Some(tuid) = s.find_nick(target) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{target} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
let killer = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
s.send(
|
||||
tuid,
|
||||
format!(":{} KILL {target} :{killer} ({reason})", s.name),
|
||||
);
|
||||
s.remove_user(tuid, &format!("Killed by {killer}: {reason}"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SVSLOGIN / SVSLOGOUT — the **services interface** to the account layer
|
||||
/// ([`crate::accounts`]). Over S2S these arrive from a services pseudoserver
|
||||
/// (Anope/Atheme); until S2S exists an oper may invoke them to drive `+r` and the
|
||||
/// account-gated channel modes. `SVSLOGIN <nick> <account>` logs a user in
|
||||
/// (`account` of `*`/`0` logs out); `SVSLOGOUT <nick>` logs them out.
|
||||
struct SvsLogin;
|
||||
impl Command for SvsLogin {
|
||||
fn name(&self) -> &'static str {
|
||||
"SVSLOGIN"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.is_oper(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- SVSLOGIN is a services command",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let (target, account) = (¶ms[0], ¶ms[1]);
|
||||
let Some(tuid) = s.find_nick(target) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{target} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if account == "*" || account == "0" {
|
||||
s.logout(tuid);
|
||||
} else {
|
||||
s.set_login(tuid, account);
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct SvsLogout;
|
||||
impl Command for SvsLogout {
|
||||
fn name(&self) -> &'static str {
|
||||
"SVSLOGOUT"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.is_oper(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- SVSLOGOUT is a services command",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(¶ms[0]) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{} :No such nick/channel", params[0]),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
s.logout(tuid);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Wallops;
|
||||
impl Command for Wallops {
|
||||
fn name(&self) -> &'static str {
|
||||
"WALLOPS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.is_oper(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOPRIVILEGES,
|
||||
":Permission Denied- You're not an IRC operator",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let from = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
s.wallops(&from, ¶ms[0]);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// REHASH — reload the config file (MOTD, oper blocks, cloak key).
|
||||
struct Rehash;
|
||||
impl Command for Rehash {
|
||||
fn name(&self) -> &'static str {
|
||||
"REHASH"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let fresh = Config::load(&s.conf_path);
|
||||
s.motd = fresh.motd;
|
||||
s.opers = fresh.opers;
|
||||
s.cloak_key = fresh.cloak_key;
|
||||
s.censor = fresh.censor;
|
||||
s.amu = fresh.amu;
|
||||
s.numeric(uid, RPL_REHASHING, &format!("{} :Rehashing", s.conf_path));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// GLOBOPS — a message to every IRC operator.
|
||||
struct GlobOps;
|
||||
impl Command for GlobOps {
|
||||
fn name(&self) -> &'static str {
|
||||
"GLOBOPS"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let from = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
let opers: Vec<Uid> = s
|
||||
.users
|
||||
.iter()
|
||||
.filter(|(_, u)| u.flags.oper)
|
||||
.map(|(&u, _)| u)
|
||||
.collect();
|
||||
for o in opers {
|
||||
let nick = s.users.get(&o).map(|u| u.nick.clone()).unwrap_or_default();
|
||||
s.send(
|
||||
o,
|
||||
format!(
|
||||
":{} NOTICE {nick} :*** GLOBOPS from {from}: {}",
|
||||
s.name, params[0]
|
||||
),
|
||||
);
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SAJOIN — force a user into a channel.
|
||||
struct SaJoin;
|
||||
impl Command for SaJoin {
|
||||
fn name(&self) -> &'static str {
|
||||
"SAJOIN"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(¶ms[0]) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{} :No such nick/channel", params[0]),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
s.join(tuid, ¶ms[1], None);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SAPART — force a user out of a channel.
|
||||
struct SaPart;
|
||||
impl Command for SaPart {
|
||||
fn name(&self) -> &'static str {
|
||||
"SAPART"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(¶ms[0]) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{} :No such nick/channel", params[0]),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
let chan = ¶ms[1];
|
||||
let key = chan.to_ascii_lowercase();
|
||||
let reason = params
|
||||
.get(2)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Removed".to_string());
|
||||
if s.is_member(tuid, &key) {
|
||||
let prefix = s.users[&tuid].prefix();
|
||||
s.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), None);
|
||||
s.propagate_part(tuid, chan, &reason);
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.members.remove(&tuid);
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&tuid) {
|
||||
u.channels.remove(&key);
|
||||
}
|
||||
s.channels.retain(|_, c| !c.is_empty());
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SANICK — force a user's nickname.
|
||||
struct SaNick;
|
||||
impl Command for SaNick {
|
||||
fn name(&self) -> &'static str {
|
||||
"SANICK"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(¶ms[0]) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{} :No such nick/channel", params[0]),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
let newnick = ¶ms[1];
|
||||
if !valid_nick(newnick) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_ERRONEUSNICKNAME,
|
||||
&format!("{newnick} :Erroneous nickname"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if s.find_nick(newnick).is_some()
|
||||
|| s.remote_nick.contains_key(&newnick.to_ascii_lowercase())
|
||||
{
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NICKNAMEINUSE,
|
||||
&format!("{newnick} :Nickname is already in use"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
s.set_nick(tuid, newnick);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// DIE — shut the server down (requires the server name as confirmation).
|
||||
struct Die;
|
||||
impl Command for Die {
|
||||
fn name(&self) -> &'static str {
|
||||
"DIE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if params[0] != s.name {
|
||||
s.numeric(uid, ERR_NOPRIVILEGES, ":DIE requires the server name");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let by = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
eprintln!("[oper] DIE by {by}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// RESTART — like DIE (a supervisor is expected to relaunch us).
|
||||
struct Restart;
|
||||
impl Command for Restart {
|
||||
fn name(&self) -> &'static str {
|
||||
"RESTART"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if params[0] != s.name {
|
||||
s.numeric(uid, ERR_NOPRIVILEGES, ":RESTART requires the server name");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let by = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
eprintln!("[oper] RESTART by {by}");
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared KLINE/GLINE/ZLINE handling: the mask alone removes, mask+duration adds.
|
||||
fn do_xline(s: &mut Server, uid: Uid, params: &[String], kind: XKind) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let mask = params[0].clone();
|
||||
let nick = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
if params.len() < 2 {
|
||||
let word = if s.remove_xline(kind, &mask) {
|
||||
"removed"
|
||||
} else {
|
||||
"not found"
|
||||
};
|
||||
s.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :{}-line {word}: {mask}",
|
||||
s.name,
|
||||
kind.tag()
|
||||
),
|
||||
);
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
let dur = parse_duration(¶ms[1]).unwrap_or(0);
|
||||
let reason = params
|
||||
.get(2)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "No reason given".to_string());
|
||||
s.add_xline(kind, &mask, dur, &nick, &reason);
|
||||
CmdResult::Ok
|
||||
}
|
||||
|
||||
/// KLINE — ban a `user@host` mask on this server.
|
||||
struct Kline;
|
||||
impl Command for Kline {
|
||||
fn name(&self) -> &'static str {
|
||||
"KLINE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
do_xline(s, uid, params, XKind::Kline)
|
||||
}
|
||||
}
|
||||
|
||||
/// GLINE — a network-wide `user@host` ban.
|
||||
struct Gline;
|
||||
impl Command for Gline {
|
||||
fn name(&self) -> &'static str {
|
||||
"GLINE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
do_xline(s, uid, params, XKind::Gline)
|
||||
}
|
||||
}
|
||||
|
||||
/// ZLINE — ban an IP address (glob).
|
||||
struct Zline;
|
||||
impl Command for Zline {
|
||||
fn name(&self) -> &'static str {
|
||||
"ZLINE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
do_xline(s, uid, params, XKind::Zline)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a nick to a uid, sending ERR_NOSUCHNICK if it's unknown.
|
||||
fn oper_target(s: &mut Server, uid: Uid, nick: &str) -> Option<Uid> {
|
||||
match s.find_nick(nick) {
|
||||
Some(t) => Some(t),
|
||||
None => {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{nick} :No such nick/channel"),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A server NOTICE to the invoking oper (soft errors for the CHG*/SA* set).
|
||||
fn onotice(s: &mut Server, uid: Uid, msg: &str) {
|
||||
let nick = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_else(|| "*".to_string());
|
||||
s.send(uid, format!(":{} NOTICE {nick} :{msg}", s.name));
|
||||
}
|
||||
|
||||
/// The oper's nick, for audit snotices.
|
||||
fn oper_nick(s: &Server, uid: Uid) -> String {
|
||||
s.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// CHGHOST — change another user's displayed host.
|
||||
struct ChgHost;
|
||||
impl Command for ChgHost {
|
||||
fn name(&self) -> &'static str {
|
||||
"CHGHOST"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !valid_host(¶ms[1]) {
|
||||
onotice(s, uid, "*** CHGHOST: invalid characters in hostname");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(t) = oper_target(s, uid, ¶ms[0]) else {
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
s.change_host_ident(t, None, Some(¶ms[1]));
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!(
|
||||
"{by} used CHGHOST on {}: {}",
|
||||
params[0], params[1]
|
||||
));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SETHOST — change your own displayed host.
|
||||
struct SetHost;
|
||||
impl Command for SetHost {
|
||||
fn name(&self) -> &'static str {
|
||||
"SETHOST"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !valid_host(¶ms[0]) {
|
||||
onotice(s, uid, "*** SETHOST: invalid characters in hostname");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
s.change_host_ident(uid, None, Some(¶ms[0]));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// CHGIDENT — change another user's ident/username.
|
||||
struct ChgIdent;
|
||||
impl Command for ChgIdent {
|
||||
fn name(&self) -> &'static str {
|
||||
"CHGIDENT"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !valid_ident(¶ms[1]) {
|
||||
onotice(s, uid, "*** CHGIDENT: invalid characters in ident");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(t) = oper_target(s, uid, ¶ms[0]) else {
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
s.change_host_ident(t, Some(¶ms[1]), None);
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!(
|
||||
"{by} used CHGIDENT on {}: {}",
|
||||
params[0], params[1]
|
||||
));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SETIDENT — change your own ident/username.
|
||||
struct SetIdent;
|
||||
impl Command for SetIdent {
|
||||
fn name(&self) -> &'static str {
|
||||
"SETIDENT"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if !valid_ident(¶ms[0]) {
|
||||
onotice(s, uid, "*** SETIDENT: invalid characters in ident");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
s.change_host_ident(uid, Some(¶ms[0]), None);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SAMODE — apply a channel MODE as the server, bypassing the rank ladder.
|
||||
struct SaMode;
|
||||
impl Command for SaMode {
|
||||
fn name(&self) -> &'static str {
|
||||
"SAMODE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
s.mode_sudo = true;
|
||||
let r = apply_mode(s, uid, params);
|
||||
s.mode_sudo = false;
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!("{by} used SAMODE: {}", params.join(" ")));
|
||||
r
|
||||
}
|
||||
}
|
||||
|
||||
/// SATOPIC — set a channel topic as the server, bypassing +t / op checks.
|
||||
struct SaTopic;
|
||||
impl Command for SaTopic {
|
||||
fn name(&self) -> &'static str {
|
||||
"SATOPIC"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let chan = ¶ms[0];
|
||||
let key = chan.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let text = params[1].clone();
|
||||
let (prefix, setter) = {
|
||||
let u = &s.users[&uid];
|
||||
(u.prefix(), u.nick.clone())
|
||||
};
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.topic = Some(Topic {
|
||||
text: text.clone(),
|
||||
setter,
|
||||
ts: now(),
|
||||
});
|
||||
}
|
||||
s.to_channel(&key, &format!(":{prefix} TOPIC {chan} :{text}"), None);
|
||||
s.propagate_from_user(uid, &format!("TOPIC {chan} :{text}"));
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!("{by} used SATOPIC on {chan}"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// SAKICK — kick a user as the server, bypassing rank checks.
|
||||
struct SaKick;
|
||||
impl Command for SaKick {
|
||||
fn name(&self) -> &'static str {
|
||||
"SAKICK"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
2
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !require_oper(s, uid) {
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let (chan, victim) = (¶ms[0], ¶ms[1]);
|
||||
let key = chan.to_ascii_lowercase();
|
||||
if !s.channels.contains_key(&key) {
|
||||
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let Some(tuid) = s.find_nick(victim) else {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NOSUCHNICK,
|
||||
&format!("{victim} :No such nick/channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if !s.channels[&key].members.contains_key(&tuid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_USERNOTINCHANNEL,
|
||||
&format!("{victim} {chan} :They aren't on that channel"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let reason = params
|
||||
.get(2)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Kicked by services".to_string());
|
||||
let prefix = s.users[&uid].prefix();
|
||||
s.to_channel(
|
||||
&key,
|
||||
&format!(":{prefix} KICK {chan} {victim} :{reason}"),
|
||||
None,
|
||||
);
|
||||
s.propagate_from_user(uid, &format!("KICK {chan} {victim} :{reason}"));
|
||||
if let Some(ch) = s.channels.get_mut(&key) {
|
||||
ch.members.remove(&tuid);
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&tuid) {
|
||||
u.channels.remove(&key);
|
||||
}
|
||||
s.channels.retain(|_, c| !c.is_empty());
|
||||
s.events
|
||||
.push_back(Hook::Part(tuid, key, "kicked".to_string()));
|
||||
let by = oper_nick(s, uid);
|
||||
s.snotice(&format!("{by} used SAKICK on {victim} in {chan}"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
398
src/coremods/core_user.rs
Normal file
398
src/coremods/core_user.rs
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
//! core_user — the client registration & session commands: CAP, NICK, USER,
|
||||
//! PING, PONG, QUIT.
|
||||
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::Server;
|
||||
use crate::users::{ident_of, valid_nick, Caps};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![
|
||||
Box::new(Cap),
|
||||
Box::new(Authenticate),
|
||||
Box::new(Nick),
|
||||
Box::new(UserCmd),
|
||||
Box::new(Ping),
|
||||
Box::new(Pong),
|
||||
Box::new(Quit),
|
||||
Box::new(Away),
|
||||
Box::new(SetName),
|
||||
]
|
||||
}
|
||||
|
||||
struct Away;
|
||||
impl Command for Away {
|
||||
fn name(&self) -> &'static str {
|
||||
"AWAY"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let msg = params.first().cloned().filter(|m| !m.is_empty());
|
||||
let now_away = msg.is_some();
|
||||
let prefix = match s.users.get_mut(&uid) {
|
||||
Some(u) => {
|
||||
u.flags.away = msg.clone();
|
||||
u.prefix()
|
||||
}
|
||||
None => return CmdResult::Fail,
|
||||
};
|
||||
// away-notify: tell capable peers we went away / came back
|
||||
let line = match &msg {
|
||||
Some(m) => format!(":{prefix} AWAY :{m}"),
|
||||
None => format!(":{prefix} AWAY"),
|
||||
};
|
||||
s.notify_peers(uid, &line, |c| c.away_notify);
|
||||
if now_away {
|
||||
s.numeric(uid, RPL_NOWAWAY, ":You have been marked as being away");
|
||||
} else {
|
||||
s.numeric(uid, RPL_UNAWAY, ":You are no longer marked as being away");
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Cap;
|
||||
impl Command for Cap {
|
||||
fn name(&self) -> &'static str {
|
||||
"CAP"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let who = cap_target(s, uid);
|
||||
match params[0].to_ascii_uppercase().as_str() {
|
||||
"LS" => {
|
||||
let cap302 = params.get(1).map(|v| v == "302").unwrap_or(false);
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.cap = true; // hold registration until CAP END
|
||||
u.cap_302 |= cap302;
|
||||
}
|
||||
s.send(
|
||||
uid,
|
||||
format!(":{} CAP {who} LS :{}", s.name, Caps::ls_line(cap302)),
|
||||
);
|
||||
}
|
||||
"REQ" => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.cap = true;
|
||||
}
|
||||
let req = params.get(1).cloned().unwrap_or_default();
|
||||
let wanted: Vec<(&str, bool)> = req
|
||||
.split_whitespace()
|
||||
.map(|t| match t.strip_prefix('-') {
|
||||
Some(rest) => (rest, false),
|
||||
None => (t, true),
|
||||
})
|
||||
.collect();
|
||||
// CAP REQ is atomic: ACK the whole set or NAK the whole set
|
||||
if !wanted.is_empty() && wanted.iter().all(|(n, _)| Caps::is_known(n)) {
|
||||
for (name, on) in &wanted {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.caps.set(name, *on);
|
||||
}
|
||||
}
|
||||
s.send(uid, format!(":{} CAP {who} ACK :{req}", s.name));
|
||||
} else {
|
||||
s.send(uid, format!(":{} CAP {who} NAK :{req}", s.name));
|
||||
}
|
||||
}
|
||||
"LIST" => {
|
||||
let list = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.caps.enabled())
|
||||
.unwrap_or_default();
|
||||
s.send(uid, format!(":{} CAP {who} LIST :{list}", s.name));
|
||||
}
|
||||
"END" => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.cap = false;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
/// CAP reply target: the nick, or `*` before one is set.
|
||||
fn cap_target(s: &Server, uid: Uid) -> String {
|
||||
s.users
|
||||
.get(&uid)
|
||||
.map(|u| {
|
||||
if u.nick.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
u.nick.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "*".to_string())
|
||||
}
|
||||
|
||||
/// AUTHENTICATE — the SASL handshake. echoIRCd verifies nothing itself (it has no
|
||||
/// accounts); once a services server is linked over S2S the payload is relayed to
|
||||
/// it and `set_login` applied on success. Until then — exactly like InspIRCd with
|
||||
/// no services — SASL fails cleanly.
|
||||
struct Authenticate;
|
||||
impl Command for Authenticate {
|
||||
fn name(&self) -> &'static str {
|
||||
"AUTHENTICATE"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if !s.users.get(&uid).map(|u| u.caps.sasl).unwrap_or(false) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_SASLFAIL,
|
||||
":You must request the sasl capability first",
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let arg = ¶ms[0];
|
||||
let mech = s.users.get(&uid).and_then(|u| u.sasl_mech.clone());
|
||||
match mech {
|
||||
// step 1 — the client picks a mechanism
|
||||
None => {
|
||||
if arg.eq_ignore_ascii_case("PLAIN") {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.sasl_mech = Some("PLAIN".to_string());
|
||||
}
|
||||
s.send(uid, "AUTHENTICATE +".to_string());
|
||||
CmdResult::Ok
|
||||
} else if arg == "*" {
|
||||
s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
|
||||
CmdResult::Ok
|
||||
} else {
|
||||
s.numeric(uid, RPL_SASLMECHS, "PLAIN :are available SASL mechanisms");
|
||||
s.numeric(uid, ERR_SASLFAIL, ":Unsupported SASL mechanism");
|
||||
CmdResult::Fail
|
||||
}
|
||||
}
|
||||
// step 2 — the client sends the base64 payload (or aborts with `*`)
|
||||
Some(_) => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.sasl_mech = None;
|
||||
}
|
||||
if arg == "*" {
|
||||
s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
if arg.len() > 400 {
|
||||
s.numeric(uid, ERR_SASLTOOLONG, ":SASL message too long");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// base64(authzid \0 authcid \0 passwd) — would be relayed to services
|
||||
let _creds = openssl::base64::decode_block(arg).unwrap_or_default();
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_SASLFAIL,
|
||||
":SASL authentication failed (services are not available)",
|
||||
);
|
||||
CmdResult::Fail
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// SETNAME — change your realname (IRCv3). Broadcast to `setname`-capable peers.
|
||||
struct SetName;
|
||||
impl Command for SetName {
|
||||
fn name(&self) -> &'static str {
|
||||
"SETNAME"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let realname = params[0].clone();
|
||||
let prefix = match s.users.get_mut(&uid) {
|
||||
Some(u) => {
|
||||
u.realname = realname.clone();
|
||||
u.prefix()
|
||||
}
|
||||
None => return CmdResult::Fail,
|
||||
};
|
||||
let line = format!(":{prefix} SETNAME :{realname}");
|
||||
if s.users.get(&uid).map(|u| u.caps.setname).unwrap_or(false) {
|
||||
s.send(uid, line.clone());
|
||||
}
|
||||
s.notify_peers(uid, &line, |c| c.setname);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Nick;
|
||||
impl Command for Nick {
|
||||
fn name(&self) -> &'static str {
|
||||
"NICK"
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let Some(newnick) = params.first() else {
|
||||
s.numeric(uid, ERR_NONICKNAMEGIVEN, ":No nickname given");
|
||||
return CmdResult::Fail;
|
||||
};
|
||||
if !valid_nick(newnick) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_ERRONEUSNICKNAME,
|
||||
&format!("{newnick} :Erroneous nickname"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if let Some(other) = s.find_nick(newnick) {
|
||||
if other != uid {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NICKNAMEINUSE,
|
||||
&format!("{newnick} :Nickname is already in use"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
return CmdResult::Ok; // same nick, no-op
|
||||
}
|
||||
// a nick already held by a user on a linked server is taken too
|
||||
if s.remote_nick.contains_key(&newnick.to_ascii_lowercase()) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_NICKNAMEINUSE,
|
||||
&format!("{newnick} :Nickname is already in use"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +N — can't change nick while on a no-nick-change channel (opers bypass)
|
||||
if !s.is_oper(uid) {
|
||||
let blocked = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.clone())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.find_map(|k| {
|
||||
s.channels
|
||||
.get(k)
|
||||
.filter(|c| c.modes.no_nick)
|
||||
.map(|c| c.name.clone())
|
||||
});
|
||||
if let Some(cn) = blocked {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANTCHANGENICK,
|
||||
&format!("{cn} :Cannot change nick while on this channel (+N is set)"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// extban `n:` — a matched user can't change nick on that channel
|
||||
let chans = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.clone())
|
||||
.unwrap_or_default();
|
||||
if let Some(k) = chans.into_iter().find(|k| s.extban_active(uid, k, 'n')) {
|
||||
let cn = s.channels.get(&k).map(|c| c.name.clone()).unwrap_or(k);
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANTCHANGENICK,
|
||||
&format!("{cn} :Cannot change nick here (+b n:)"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
// +F nick-change flood — locks nick changes on the channel for 60s
|
||||
if let Some(cn) = s.nickflood_blocked(uid) {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_CANTCHANGENICK,
|
||||
&format!("{cn} :Too many nick changes, try later (+F is set)"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
}
|
||||
s.set_nick(uid, newnick);
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct UserCmd;
|
||||
impl Command for UserCmd {
|
||||
fn name(&self) -> &'static str {
|
||||
"USER"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
4
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if s.users.get(&uid).map(|u| u.registered).unwrap_or(false) {
|
||||
s.numeric(uid, ERR_ALREADYREGISTERED, ":You may not reregister");
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
let ident = ident_of(¶ms[0]);
|
||||
let realname = params[3].clone();
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.ident = format!("~{ident}");
|
||||
u.realname = realname;
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Ping;
|
||||
impl Command for Ping {
|
||||
fn name(&self) -> &'static str {
|
||||
"PING"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
s.send(uid, format!(":{} PONG {} :{}", s.name, s.name, params[0]));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
struct Pong;
|
||||
impl Command for Pong {
|
||||
fn name(&self) -> &'static str {
|
||||
"PONG"
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, _s: &mut Server, _uid: Uid, _params: &[String]) -> CmdResult {
|
||||
CmdResult::Ok // keepalive; nothing to do yet
|
||||
}
|
||||
}
|
||||
|
||||
struct Quit;
|
||||
impl Command for Quit {
|
||||
fn name(&self) -> &'static str {
|
||||
"QUIT"
|
||||
}
|
||||
fn before_reg(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let reason = params
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Client quit".to_string());
|
||||
s.mark_quit(uid, format!("Quit: {reason}"));
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
306
src/coremods/core_watch.rs
Normal file
306
src/coremods/core_watch.rs
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
//! core_watch — WATCH, MONITOR (IRCv3) and SILENCE. The per-user lists live on
|
||||
//! the `User`; the online/offline notifications are driven from the lifecycle
|
||||
//! code via [`crate::server::Server::watch_notify_online`] / `_offline`. Mirrors
|
||||
//! InspIRCd's `m_watch` / `m_monitor` / `m_silence`.
|
||||
|
||||
use crate::channels::normalize_mask;
|
||||
use crate::command::{CmdResult, Command};
|
||||
use crate::numeric::*;
|
||||
use crate::server::Server;
|
||||
use crate::watch::{MONITOR_MAX, SILENCE_MAX, WATCH_MAX};
|
||||
use crate::Uid;
|
||||
|
||||
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||
vec![Box::new(Watch), Box::new(Monitor), Box::new(Silence)]
|
||||
}
|
||||
|
||||
// --- WATCH ------------------------------------------------------------------
|
||||
|
||||
/// Report a nick's current presence as RPL_NOWON (604) or RPL_NOWOFF (605).
|
||||
fn watch_status(s: &Server, uid: Uid, nick: &str) {
|
||||
if let Some(u) = s.find_nick(nick).and_then(|tu| s.users.get(&tu)) {
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_NOWON,
|
||||
&format!(
|
||||
"{} {} {} {} :is online",
|
||||
u.nick,
|
||||
u.ident,
|
||||
u.host_display(),
|
||||
u.signon
|
||||
),
|
||||
);
|
||||
} else {
|
||||
s.numeric(uid, RPL_NOWOFF, &format!("{nick} * * 0 :is offline"));
|
||||
}
|
||||
}
|
||||
|
||||
fn watch_add(s: &mut Server, uid: Uid, nick: &str) {
|
||||
if nick.is_empty() {
|
||||
return;
|
||||
}
|
||||
let low = nick.to_ascii_lowercase();
|
||||
let full = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.watch.len() >= WATCH_MAX && !u.watch.contains(&low))
|
||||
.unwrap_or(true);
|
||||
if full {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_TOOMANYWATCH,
|
||||
&format!("{nick} :Maximum size for WATCH-list exceeded"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
if !u.watch.contains(&low) {
|
||||
u.watch.push(low);
|
||||
}
|
||||
}
|
||||
watch_status(s, uid, nick);
|
||||
}
|
||||
|
||||
fn watch_list(s: &Server, uid: Uid, online_only: bool) {
|
||||
let nicks = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.watch.clone())
|
||||
.unwrap_or_default();
|
||||
for n in nicks {
|
||||
// `l` (online-only) skips offline entries; `L` shows all
|
||||
if !online_only || s.find_nick(&n).is_some() {
|
||||
watch_status(s, uid, &n);
|
||||
}
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWATCHLIST, ":End of WATCH list");
|
||||
}
|
||||
|
||||
struct Watch;
|
||||
impl Command for Watch {
|
||||
fn name(&self) -> &'static str {
|
||||
"WATCH"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
if params.is_empty() {
|
||||
watch_list(s, uid, true); // bare WATCH lists your online entries
|
||||
return CmdResult::Ok;
|
||||
}
|
||||
for tok in params.iter().flat_map(|p| p.split_whitespace()) {
|
||||
match tok {
|
||||
"C" | "c" => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.watch.clear();
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWATCHLIST, ":End of WATCH list");
|
||||
}
|
||||
"S" | "s" => {
|
||||
let (mine, watched) = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| (u.watch.len(), u.watch.clone()))
|
||||
.unwrap_or((0, Vec::new()));
|
||||
let me = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
let on_me = s.watchers_of(&me);
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WATCHSTAT,
|
||||
&format!(":You have {mine} and are on {on_me} WATCH entries"),
|
||||
);
|
||||
if !watched.is_empty() {
|
||||
s.numeric(uid, RPL_WATCHLIST, &format!(":{}", watched.join(" ")));
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFWATCHLIST, ":End of WATCH S");
|
||||
}
|
||||
"L" => watch_list(s, uid, false),
|
||||
"l" => watch_list(s, uid, true),
|
||||
_ if tok.starts_with('+') => watch_add(s, uid, &tok[1..]),
|
||||
_ if tok.starts_with('-') => {
|
||||
let low = tok[1..].to_ascii_lowercase();
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.watch.retain(|n| n != &low);
|
||||
}
|
||||
s.numeric(
|
||||
uid,
|
||||
RPL_WATCHOFF,
|
||||
&format!("{} * * 0 :stopped watching", &tok[1..]),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
// --- MONITOR (IRCv3) --------------------------------------------------------
|
||||
|
||||
/// Report the online/offline split of `nicks` to `uid` (730 / 731).
|
||||
fn monitor_report(s: &Server, uid: Uid, nicks: &[String]) {
|
||||
let mut online = Vec::new();
|
||||
let mut offline = Vec::new();
|
||||
for n in nicks {
|
||||
match s.find_nick(n).and_then(|tu| s.users.get(&tu)) {
|
||||
Some(u) => online.push(format!("{}!{}@{}", u.nick, u.ident, u.host_display())),
|
||||
None => offline.push(n.clone()),
|
||||
}
|
||||
}
|
||||
if !online.is_empty() {
|
||||
s.numeric(uid, RPL_MONONLINE, &format!(":{}", online.join(",")));
|
||||
}
|
||||
if !offline.is_empty() {
|
||||
s.numeric(uid, RPL_MONOFFLINE, &format!(":{}", offline.join(",")));
|
||||
}
|
||||
}
|
||||
|
||||
struct Monitor;
|
||||
impl Command for Monitor {
|
||||
fn name(&self) -> &'static str {
|
||||
"MONITOR"
|
||||
}
|
||||
fn min_params(&self) -> usize {
|
||||
1
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
match params[0].to_ascii_uppercase().as_str() {
|
||||
"+" => {
|
||||
let targets: Vec<String> = params
|
||||
.get(1)
|
||||
.map(|t| {
|
||||
t.split(',')
|
||||
.filter(|x| !x.is_empty())
|
||||
.map(String::from)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut added = Vec::new();
|
||||
for t in targets {
|
||||
let low = t.to_ascii_lowercase();
|
||||
let full = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.monitor.len() >= MONITOR_MAX && !u.monitor.contains(&low))
|
||||
.unwrap_or(true);
|
||||
if full {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_MONLISTFULL,
|
||||
&format!("{MONITOR_MAX} {t} :Monitor list is full"),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
if !u.monitor.contains(&low) {
|
||||
u.monitor.push(low);
|
||||
}
|
||||
}
|
||||
added.push(t);
|
||||
}
|
||||
monitor_report(s, uid, &added);
|
||||
}
|
||||
"-" => {
|
||||
let targets: Vec<String> = params
|
||||
.get(1)
|
||||
.map(|t| {
|
||||
t.split(',')
|
||||
.filter(|x| !x.is_empty())
|
||||
.map(|x| x.to_ascii_lowercase())
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.monitor.retain(|n| !targets.contains(n));
|
||||
}
|
||||
}
|
||||
"C" => {
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.monitor.clear();
|
||||
}
|
||||
}
|
||||
"L" => {
|
||||
let list = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.monitor.clone())
|
||||
.unwrap_or_default();
|
||||
if !list.is_empty() {
|
||||
s.numeric(uid, RPL_MONLIST, &format!(":{}", list.join(",")));
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFMONLIST, ":End of MONITOR list");
|
||||
}
|
||||
"S" => {
|
||||
let list = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.monitor.clone())
|
||||
.unwrap_or_default();
|
||||
monitor_report(s, uid, &list);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
|
||||
// --- SILENCE ----------------------------------------------------------------
|
||||
|
||||
fn silence_list(s: &Server, uid: Uid) {
|
||||
let list = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.silence.clone())
|
||||
.unwrap_or_default();
|
||||
for m in list {
|
||||
s.numeric(uid, RPL_SILELIST, &m);
|
||||
}
|
||||
s.numeric(uid, RPL_ENDOFSILENCE, ":End of SILENCE list");
|
||||
}
|
||||
|
||||
struct Silence;
|
||||
impl Command for Silence {
|
||||
fn name(&self) -> &'static str {
|
||||
"SILENCE"
|
||||
}
|
||||
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||
let Some(arg) = params.first() else {
|
||||
silence_list(s, uid);
|
||||
return CmdResult::Ok;
|
||||
};
|
||||
let prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
||||
if let Some(m) = arg.strip_prefix('+') {
|
||||
let mask = normalize_mask(m);
|
||||
let full = s
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.silence.len() >= SILENCE_MAX && !u.silence.contains(&mask))
|
||||
.unwrap_or(true);
|
||||
if full {
|
||||
s.numeric(
|
||||
uid,
|
||||
ERR_SILELISTFULL,
|
||||
&format!("{mask} :Your SILENCE list is full"),
|
||||
);
|
||||
return CmdResult::Fail;
|
||||
}
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
if !u.silence.contains(&mask) {
|
||||
u.silence.push(mask.clone());
|
||||
}
|
||||
}
|
||||
s.send(uid, format!(":{prefix} SILENCE +{mask}"));
|
||||
} else if let Some(m) = arg.strip_prefix('-') {
|
||||
let mask = normalize_mask(m);
|
||||
if let Some(u) = s.users.get_mut(&uid) {
|
||||
u.silence.retain(|x| x != &mask);
|
||||
}
|
||||
s.send(uid, format!(":{prefix} SILENCE -{mask}"));
|
||||
} else {
|
||||
silence_list(s, uid);
|
||||
}
|
||||
CmdResult::Ok
|
||||
}
|
||||
}
|
||||
34
src/coremods/mod.rs
Normal file
34
src/coremods/mod.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
//! The built-in commands, grouped the way InspIRCd groups its `coremods/`:
|
||||
//! `core_user`, `core_channel`, `core_message`, `core_mode`, `core_oper`,
|
||||
//! `core_info`. Each module exposes `commands()`; [`command_table`] assembles the
|
||||
//! registry the core dispatches through.
|
||||
|
||||
pub mod core_channel;
|
||||
pub mod core_extra;
|
||||
pub mod core_info;
|
||||
pub mod core_message;
|
||||
pub mod core_mode;
|
||||
pub mod core_oper;
|
||||
pub mod core_user;
|
||||
pub mod core_watch;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::command::Command;
|
||||
|
||||
pub fn command_table() -> HashMap<&'static str, Box<dyn Command>> {
|
||||
let mut m: HashMap<&'static str, Box<dyn Command>> = HashMap::new();
|
||||
for c in core_user::commands()
|
||||
.into_iter()
|
||||
.chain(core_channel::commands())
|
||||
.chain(core_message::commands())
|
||||
.chain(core_mode::commands())
|
||||
.chain(core_oper::commands())
|
||||
.chain(core_info::commands())
|
||||
.chain(core_extra::commands())
|
||||
.chain(core_watch::commands())
|
||||
{
|
||||
m.insert(c.name(), c);
|
||||
}
|
||||
m
|
||||
}
|
||||
79
src/extensible.rs
Normal file
79
src/extensible.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
//! Typed per-object metadata — echoIRCd's answer to InspIRCd's `Extensible` /
|
||||
//! `ExtensionItem`.
|
||||
//!
|
||||
//! In C++ InspIRCd, a module attaches data to a user/channel through a `void*`
|
||||
//! `ExtensionItem`: it registers the item, casts on every access, and must supply
|
||||
//! a `free()` callback — a well-worn source of leaks, type-confusion and
|
||||
//! use-after-free (the reason the core carries a whole "cull list").
|
||||
//!
|
||||
//! Here it's a `TypeId`-keyed typemap. A module stores its own concrete type and
|
||||
//! gets it back type-checked; the value is owned by the object it hangs off, so
|
||||
//! it's dropped automatically when that object is — no registry, no `unsafe`, no
|
||||
//! manual free, no dangling data.
|
||||
|
||||
use std::any::{Any, TypeId};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Extensible {
|
||||
map: HashMap<TypeId, Box<dyn Any + Send>>,
|
||||
}
|
||||
|
||||
impl Extensible {
|
||||
pub fn get<T: Any + Send>(&self) -> Option<&T> {
|
||||
self.map
|
||||
.get(&TypeId::of::<T>())
|
||||
.and_then(|b| b.downcast_ref::<T>())
|
||||
}
|
||||
|
||||
pub fn get_mut<T: Any + Send>(&mut self) -> Option<&mut T> {
|
||||
self.map
|
||||
.get_mut(&TypeId::of::<T>())
|
||||
.and_then(|b| b.downcast_mut::<T>())
|
||||
}
|
||||
|
||||
pub fn set<T: Any + Send>(&mut self, value: T) {
|
||||
self.map.insert(TypeId::of::<T>(), Box::new(value));
|
||||
}
|
||||
|
||||
/// Get the stored `T`, inserting `f()`'s value first if it's not there yet.
|
||||
pub fn get_or_insert_with<T: Any + Send>(&mut self, f: impl FnOnce() -> T) -> &mut T {
|
||||
self.map
|
||||
.entry(TypeId::of::<T>())
|
||||
.or_insert_with(|| Box::new(f()))
|
||||
.downcast_mut::<T>()
|
||||
.expect("each TypeId keys exactly its own type")
|
||||
}
|
||||
|
||||
pub fn take<T: Any + Send>(&mut self) -> Option<T> {
|
||||
self.map
|
||||
.remove(&TypeId::of::<T>())
|
||||
.and_then(|b| b.downcast::<T>().ok())
|
||||
.map(|b| *b)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[derive(Default, PartialEq, Debug)]
|
||||
struct A(u32);
|
||||
struct B(&'static str);
|
||||
|
||||
#[test]
|
||||
fn typed_storage_is_isolated_and_recoverable() {
|
||||
let mut e = Extensible::default();
|
||||
assert!(e.get::<A>().is_none());
|
||||
e.set(A(7));
|
||||
e.set(B("hi"));
|
||||
// two different types coexist, each recovered as itself
|
||||
assert_eq!(e.get::<A>(), Some(&A(7)));
|
||||
assert_eq!(e.get::<B>().map(|b| b.0), Some("hi"));
|
||||
e.get_mut::<A>().unwrap().0 += 1;
|
||||
assert_eq!(e.get::<A>(), Some(&A(8)));
|
||||
assert_eq!(e.take::<A>(), Some(A(8)));
|
||||
assert!(e.get::<A>().is_none());
|
||||
assert_eq!(*e.get_or_insert_with(|| A(100)), A(100));
|
||||
}
|
||||
}
|
||||
277
src/ircd.rs
Normal file
277
src/ircd.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
//! The core: owns the [`Server`] state, the command table and the module list,
|
||||
//! and turns a stream of [`Event`]s into IRC. Everything here runs on one
|
||||
//! thread, so no state is ever locked.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
use crate::command::Command;
|
||||
use crate::config::Config;
|
||||
use crate::coremods::command_table;
|
||||
use crate::message;
|
||||
use crate::module::{Hook, ModResult, Module};
|
||||
use crate::numeric::{ERR_NEEDMOREPARAMS, ERR_NOTREGISTERED, ERR_UNKNOWNCOMMAND};
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
/// What the I/O threads hand to the core.
|
||||
pub enum Event {
|
||||
Connect {
|
||||
uid: Uid,
|
||||
addr: SocketAddr,
|
||||
out: Sender<String>,
|
||||
sock: TcpStream,
|
||||
secure: bool,
|
||||
link: bool, // a server-to-server connection, not a client
|
||||
outbound: bool, // (link) we dialed them
|
||||
},
|
||||
Line {
|
||||
uid: Uid,
|
||||
line: String,
|
||||
},
|
||||
Disconnect {
|
||||
uid: Uid,
|
||||
},
|
||||
/// Background timer tick — drives ping/idle timeouts.
|
||||
Tick,
|
||||
}
|
||||
|
||||
pub struct Ircd {
|
||||
server: Server,
|
||||
commands: HashMap<&'static str, Box<dyn Command>>,
|
||||
modules: Vec<Box<dyn Module>>,
|
||||
}
|
||||
|
||||
impl Ircd {
|
||||
pub fn new(cfg: Config) -> Ircd {
|
||||
Ircd {
|
||||
server: Server::new(cfg),
|
||||
commands: command_table(),
|
||||
modules: crate::modules::default_modules(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run until the event channel closes (i.e. the listener is gone).
|
||||
pub fn run(mut self, rx: Receiver<Event>) {
|
||||
for ev in rx {
|
||||
match ev {
|
||||
Event::Connect {
|
||||
uid,
|
||||
addr,
|
||||
out,
|
||||
sock,
|
||||
secure,
|
||||
link,
|
||||
outbound,
|
||||
} => {
|
||||
if link {
|
||||
self.server.add_link(uid, addr, out, sock, outbound);
|
||||
} else {
|
||||
self.server.add_conn(uid, addr, out, sock, secure);
|
||||
}
|
||||
}
|
||||
Event::Line { uid, line } => {
|
||||
if self.server.links.contains_key(&uid) {
|
||||
if let Some(msg) = message::parse(&line) {
|
||||
self.server.on_link(uid, &msg);
|
||||
}
|
||||
} else {
|
||||
self.on_line(uid, &line);
|
||||
}
|
||||
}
|
||||
Event::Disconnect { uid } => {
|
||||
if self.server.links.contains_key(&uid) {
|
||||
self.server.close_link(uid, "Connection closed");
|
||||
} else {
|
||||
self.quit_user(uid, "Connection closed");
|
||||
}
|
||||
}
|
||||
Event::Tick => self.on_tick(),
|
||||
}
|
||||
self.drain_hooks();
|
||||
}
|
||||
}
|
||||
|
||||
fn on_line(&mut self, uid: Uid, line: &str) {
|
||||
let Some(msg) = message::parse(line) else {
|
||||
return;
|
||||
};
|
||||
// stash this line's client-only tags for TAGMSG / PRIVMSG relay
|
||||
self.server.line_ctags = msg.ctags.clone();
|
||||
// any valid line means the connection is alive
|
||||
if let Some(u) = self.server.users.get_mut(&uid) {
|
||||
u.last_active = crate::server::now();
|
||||
u.ping_sent = false;
|
||||
}
|
||||
let cmd = msg.command.as_str();
|
||||
let registered = self
|
||||
.server
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.registered)
|
||||
.unwrap_or(false);
|
||||
|
||||
// module pre-command gate
|
||||
for m in &mut self.modules {
|
||||
if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// message pre-hook (PRIVMSG/NOTICE)
|
||||
if matches!(cmd, "PRIVMSG" | "NOTICE") && msg.params.len() >= 2 {
|
||||
let (target, text) = (msg.params[0].clone(), msg.params[1].clone());
|
||||
for m in &mut self.modules {
|
||||
if m.on_pre_message(&mut self.server, uid, &target, &text) == ModResult::Deny {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(handler) = self.commands.get(cmd) else {
|
||||
if registered {
|
||||
self.server
|
||||
.numeric(uid, ERR_UNKNOWNCOMMAND, &format!("{cmd} :Unknown command"));
|
||||
}
|
||||
return;
|
||||
};
|
||||
if !registered && !handler.before_reg() {
|
||||
self.server
|
||||
.numeric(uid, ERR_NOTREGISTERED, ":You have not registered");
|
||||
return;
|
||||
}
|
||||
if msg.params.len() < handler.min_params() {
|
||||
self.server.numeric(
|
||||
uid,
|
||||
ERR_NEEDMOREPARAMS,
|
||||
&format!("{cmd} :Not enough parameters"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let _ = handler.handle(&mut self.server, uid, &msg.params);
|
||||
|
||||
for m in &mut self.modules {
|
||||
m.on_post_command(&mut self.server, uid, cmd);
|
||||
}
|
||||
|
||||
// a command may have asked to quit (QUIT)
|
||||
if let Some(reason) = self.server.take_quit(uid) {
|
||||
self.quit_user(uid, &reason);
|
||||
return;
|
||||
}
|
||||
// …or completed the registration handshake
|
||||
if !registered {
|
||||
let ready = self
|
||||
.server
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| !u.registered && !u.nick.is_empty() && !u.ident.is_empty() && !u.cap)
|
||||
.unwrap_or(false);
|
||||
if ready {
|
||||
self.complete_registration(uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn complete_registration(&mut self, uid: Uid) {
|
||||
for m in &mut self.modules {
|
||||
if m.on_user_register(&mut self.server, uid) == ModResult::Deny {
|
||||
self.server.send(
|
||||
uid,
|
||||
"ERROR :Closing link (registration refused)".to_string(),
|
||||
);
|
||||
self.server.remove_user(uid, "Registration refused");
|
||||
return;
|
||||
}
|
||||
}
|
||||
// x-line: refuse a banned host / ip before welcoming
|
||||
let (ident, host, ip) = {
|
||||
let u = &self.server.users[&uid];
|
||||
(u.ident.clone(), u.host.clone(), u.addr.ip().to_string())
|
||||
};
|
||||
if let Some(reason) = self.server.matched_xline(&ident, &host, &ip) {
|
||||
self.server
|
||||
.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||
self.server.remove_user(uid, &reason);
|
||||
return;
|
||||
}
|
||||
self.server.welcome(uid);
|
||||
}
|
||||
|
||||
fn quit_user(&mut self, uid: Uid, reason: &str) {
|
||||
if !self.server.users.contains_key(&uid) {
|
||||
return;
|
||||
}
|
||||
let registered = self.server.users[&uid].registered;
|
||||
if registered {
|
||||
// fire the quit hook while the user still exists
|
||||
for m in &mut self.modules {
|
||||
m.on_user_quit(&mut self.server, uid, reason);
|
||||
}
|
||||
}
|
||||
self.server.remove_user(uid, reason);
|
||||
}
|
||||
|
||||
/// Background timer: PING idle clients, reap the unresponsive and the
|
||||
/// never-registered.
|
||||
fn on_tick(&mut self) {
|
||||
self.server.ping_links(); // keepalive on every server link
|
||||
self.server.purge_xlines(); // drop expired server bans
|
||||
let now = crate::server::now();
|
||||
let (to_ping, to_quit) = self.server.idle_check(now);
|
||||
for uid in to_ping {
|
||||
let token = self.server.name.clone();
|
||||
self.server.send(uid, format!("PING :{token}"));
|
||||
if let Some(u) = self.server.users.get_mut(&uid) {
|
||||
u.ping_sent = true;
|
||||
}
|
||||
}
|
||||
for uid in to_quit {
|
||||
let reg = self
|
||||
.server
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.registered)
|
||||
.unwrap_or(false);
|
||||
let reason = if reg {
|
||||
"Ping timeout"
|
||||
} else {
|
||||
"Registration timeout"
|
||||
};
|
||||
self.server
|
||||
.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||
self.quit_user(uid, reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire queued notify-hooks. Draining a queue (not iterating in place) lets a
|
||||
/// hook enqueue more work (e.g. a module forcing a join) without surprises.
|
||||
fn drain_hooks(&mut self) {
|
||||
while let Some(hook) = self.server.events.pop_front() {
|
||||
match hook {
|
||||
Hook::Connect(uid) => {
|
||||
for m in &mut self.modules {
|
||||
m.on_user_connect(&mut self.server, uid);
|
||||
}
|
||||
// burst to links after modules (so the cloak is already set)
|
||||
self.server.introduce_to_links(uid);
|
||||
}
|
||||
Hook::Join(uid, chan) => {
|
||||
for m in &mut self.modules {
|
||||
m.on_join(&mut self.server, uid, &chan);
|
||||
}
|
||||
}
|
||||
Hook::Part(uid, chan, reason) => {
|
||||
for m in &mut self.modules {
|
||||
m.on_part(&mut self.server, uid, &chan, &reason);
|
||||
}
|
||||
}
|
||||
Hook::Quit(uid, reason) => {
|
||||
for m in &mut self.modules {
|
||||
m.on_user_quit(&mut self.server, uid, &reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/lib.rs
Normal file
33
src/lib.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//! echoIRCd — a small, dependency-light IRC daemon, laid out like InspIRCd:
|
||||
//!
|
||||
//! - **engine** — `server` (the core + state), `users`, `channels`, `message`,
|
||||
//! `numeric`, `config`.
|
||||
//! - **`coremods`** — the built-in commands, grouped the way InspIRCd groups its
|
||||
//! `coremods/` (core_user, core_channel, core_message, core_mode, core_info).
|
||||
//! - **`modules`** — optional, pluggable behaviour via lifecycle hooks.
|
||||
//! - **`socketengine`** — the I/O edge (accept + per-connection threads).
|
||||
//! - **`ircd`** — the single-threaded core loop that ties it together.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
/// A local connection id. (Server linking will later need real UUIDs.)
|
||||
pub type Uid = u64;
|
||||
|
||||
pub mod accounts;
|
||||
pub mod channels;
|
||||
pub mod command;
|
||||
pub mod config;
|
||||
pub mod coremods;
|
||||
pub mod extensible;
|
||||
pub mod ircd;
|
||||
pub mod link;
|
||||
pub mod message;
|
||||
pub mod mode;
|
||||
pub mod module;
|
||||
pub mod modules;
|
||||
pub mod numeric;
|
||||
pub mod server;
|
||||
pub mod socketengine;
|
||||
pub mod tls;
|
||||
pub mod users;
|
||||
pub mod watch;
|
||||
pub mod xline;
|
||||
996
src/link.rs
Normal file
996
src/link.rs
Normal file
|
|
@ -0,0 +1,996 @@
|
|||
//! Server-to-server linking — echoIRCd's answer to InspIRCd's `m_spanningtree`.
|
||||
//!
|
||||
//! A link connection is a first-class peer, *not* a client `User`: it lives in
|
||||
//! `Server.links` and is driven by [`Server::on_link`] instead of the client
|
||||
//! command table. What's here:
|
||||
//! * **handshake** — `SERVER <name> <pass> <sid> :<desc>` (shared per-block key),
|
||||
//! then `BURST`/`ENDBURST`; a registry of linked servers (`Server.servers`).
|
||||
//! * **users** — local users get network **UIDs**; on link-up they're burst as
|
||||
//! `UID`, and NICK/QUIT propagate; remote users live in `remote_users`.
|
||||
//! * **channels** — JOIN/PART/TOPIC/KICK/MODE (incl. ban/except/invex lists)
|
||||
//! propagate; every server tracks a channel's full membership (`Channel.rmembers`,
|
||||
//! with prefix modes), modes and bans; channel messages fan out **one copy per
|
||||
//! link** (not per remote member), forwarded on but the origin; `FJOIN` bursts
|
||||
//! channels (members + bans) on link-up.
|
||||
//! * **collisions** — a nick already on the network is refused; an incoming `UID`
|
||||
//! that clashes with a local user kills the local (both sides ⇒ both vanish).
|
||||
//! * **netsplit** — dropping a link QUITs every user behind it.
|
||||
//!
|
||||
//! Toward full InspIRCd interop still: TS6 tie-breaking and the exact
|
||||
//! CAPAB/FJOIN/metadata wire format. Also: SASL relays here once a services links in.
|
||||
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::channels::{Ban, Channel, Member, Topic};
|
||||
use crate::message::Message;
|
||||
use crate::server::{now, Server};
|
||||
use crate::users::User;
|
||||
use crate::Uid;
|
||||
|
||||
/// A local server-link connection (one hop away). Distinct from a client `User`.
|
||||
pub struct Link {
|
||||
pub uid: Uid,
|
||||
pub out: Sender<String>,
|
||||
pub outbound: bool, // we dialed them (so we introduce ourselves first)
|
||||
pub registered: bool, // handshake complete
|
||||
pub sent_server: bool, // we've sent our own SERVER line
|
||||
pub sid: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub bursting: bool, // between the peer's BURST and ENDBURST
|
||||
}
|
||||
|
||||
/// A server known on the network, for LINKS / MAP / routing.
|
||||
pub struct RemoteServer {
|
||||
pub sid: String,
|
||||
pub name: String,
|
||||
pub desc: String,
|
||||
pub via: Uid, // the local link uid it is reachable through
|
||||
}
|
||||
|
||||
/// A user living on another server, reached via a link — not a local `User`.
|
||||
pub struct RemoteUser {
|
||||
pub uuid: String,
|
||||
pub nick: String,
|
||||
pub ident: String,
|
||||
pub host: String,
|
||||
pub realname: String,
|
||||
pub account: Option<String>,
|
||||
pub sid: String, // origin server id
|
||||
pub via: Uid, // local link uid it is reached through
|
||||
}
|
||||
|
||||
impl RemoteUser {
|
||||
pub fn prefix(&self) -> String {
|
||||
format!("{}!{}@{}", self.nick, self.ident, self.host)
|
||||
}
|
||||
}
|
||||
|
||||
/// A valid 3-char SID: digit, then two upper-case alphanumerics (InspIRCd's rule).
|
||||
pub fn valid_sid(s: &str) -> bool {
|
||||
let b = s.as_bytes();
|
||||
b.len() == 3
|
||||
&& b[0].is_ascii_digit()
|
||||
&& b.iter()
|
||||
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Mint the next network-wide UID for a local user: our SID + 6 base-26 chars
|
||||
/// (InspIRCd-style, e.g. `0AAAAAAAB`).
|
||||
pub fn next_uuid(&mut self) -> String {
|
||||
let mut x = self.uuid_counter;
|
||||
self.uuid_counter += 1;
|
||||
let mut suffix = [b'A'; 6];
|
||||
for c in suffix.iter_mut().rev() {
|
||||
*c = b'A' + (x % 26) as u8;
|
||||
x /= 26;
|
||||
}
|
||||
format!(
|
||||
"{}{}",
|
||||
self.sid,
|
||||
std::str::from_utf8(&suffix).unwrap_or("AAAAAA")
|
||||
)
|
||||
}
|
||||
|
||||
/// Register a new server-link connection. An **outbound** link introduces
|
||||
/// itself right away with our `SERVER` line (using the dialled block's key).
|
||||
pub fn add_link(
|
||||
&mut self,
|
||||
uid: Uid,
|
||||
addr: SocketAddr,
|
||||
out: Sender<String>,
|
||||
_sock: TcpStream, // held by the reader/writer threads; closed gracefully
|
||||
outbound: bool,
|
||||
) {
|
||||
let mut sent_server = false;
|
||||
if outbound {
|
||||
let pass = self
|
||||
.link_blocks
|
||||
.iter()
|
||||
.find(|b| b.ip == addr.ip().to_string())
|
||||
.map(|b| b.password.clone());
|
||||
if let Some(pass) = pass {
|
||||
let _ = out.send(format!(
|
||||
"SERVER {} {} {} :{}",
|
||||
self.name, pass, self.sid, self.server_desc
|
||||
));
|
||||
sent_server = true;
|
||||
}
|
||||
}
|
||||
self.links.insert(
|
||||
uid,
|
||||
Link {
|
||||
uid,
|
||||
out,
|
||||
outbound,
|
||||
registered: false,
|
||||
sent_server,
|
||||
sid: None,
|
||||
name: None,
|
||||
bursting: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn link_out(&self, uid: Uid, line: String) {
|
||||
if let Some(l) = self.links.get(&uid) {
|
||||
let _ = l.out.send(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dispatch one parsed S2S line from link `uid`.
|
||||
pub fn on_link(&mut self, uid: Uid, msg: &Message) {
|
||||
let registered = self.links.get(&uid).map(|l| l.registered).unwrap_or(false);
|
||||
match msg.command.as_str() {
|
||||
"SERVER" if !registered => self.link_server(uid, msg),
|
||||
"PING" if registered => {
|
||||
let token = msg.params.first().cloned().unwrap_or_default();
|
||||
self.link_out(uid, format!("PONG :{token}"));
|
||||
}
|
||||
"UID" if registered => self.link_uid_recv(uid, msg),
|
||||
"NICK" if registered => self.link_nick_recv(uid, msg),
|
||||
"QUIT" if registered => self.link_quit_recv(uid, msg),
|
||||
"PRIVMSG" if registered => self.link_message_recv(uid, msg, false),
|
||||
"NOTICE" if registered => self.link_message_recv(uid, msg, true),
|
||||
"JOIN" if registered => self.link_join_recv(uid, msg),
|
||||
"PART" if registered => self.link_part_recv(uid, msg),
|
||||
"TOPIC" if registered => self.link_topic_recv(uid, msg),
|
||||
"KICK" if registered => self.link_kick_recv(uid, msg),
|
||||
"MODE" | "FMODE" if registered => self.link_mode_recv(uid, msg),
|
||||
"FJOIN" if registered => self.link_fjoin_recv(uid, msg),
|
||||
"BURST" => {
|
||||
if let Some(l) = self.links.get_mut(&uid) {
|
||||
l.bursting = true;
|
||||
}
|
||||
}
|
||||
"ENDBURST" => {
|
||||
if let Some(l) = self.links.get_mut(&uid) {
|
||||
l.bursting = false;
|
||||
}
|
||||
}
|
||||
"SQUIT" => self.close_link(uid, "SQUIT"),
|
||||
"ERROR" => {
|
||||
eprintln!("[link] {uid} ERROR: {}", msg.params.join(" "));
|
||||
self.close_link(uid, "peer error");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle the `SERVER <name> <password> <sid> :<desc>` handshake line.
|
||||
fn link_server(&mut self, uid: Uid, msg: &Message) {
|
||||
if msg.params.len() < 4 {
|
||||
self.reject_link(uid, "Not enough SERVER parameters");
|
||||
return;
|
||||
}
|
||||
let (name, pass, sid, desc) = (
|
||||
msg.params[0].clone(),
|
||||
msg.params[1].clone(),
|
||||
msg.params[2].clone(),
|
||||
msg.params[3].clone(),
|
||||
);
|
||||
let Some(block) = self.link_blocks.iter().find(|b| b.name == name).cloned() else {
|
||||
self.reject_link(uid, "No link block for that server name");
|
||||
return;
|
||||
};
|
||||
if block.password != pass {
|
||||
self.reject_link(uid, "Invalid link password");
|
||||
return;
|
||||
}
|
||||
if !valid_sid(&sid) || sid == self.sid || self.servers.contains_key(&sid) {
|
||||
self.reject_link(uid, "Bad or already-present SID");
|
||||
return;
|
||||
}
|
||||
|
||||
let already_sent = self.links.get(&uid).map(|l| l.sent_server).unwrap_or(false);
|
||||
if let Some(l) = self.links.get_mut(&uid) {
|
||||
l.registered = true;
|
||||
l.sid = Some(sid.clone());
|
||||
l.name = Some(name.clone());
|
||||
}
|
||||
self.servers.insert(
|
||||
sid.clone(),
|
||||
RemoteServer {
|
||||
sid: sid.clone(),
|
||||
name: name.clone(),
|
||||
desc: desc.clone(),
|
||||
via: uid,
|
||||
},
|
||||
);
|
||||
// if we accepted (inbound) we still owe them our SERVER line
|
||||
if !already_sent {
|
||||
self.link_out(
|
||||
uid,
|
||||
format!(
|
||||
"SERVER {} {} {} :{}",
|
||||
self.name, block.password, self.sid, self.server_desc
|
||||
),
|
||||
);
|
||||
if let Some(l) = self.links.get_mut(&uid) {
|
||||
l.sent_server = true;
|
||||
}
|
||||
}
|
||||
// netburst: introduce our local users (channels/FJOIN are phase 2b)
|
||||
self.link_out(uid, format!("BURST {}", now()));
|
||||
self.burst_users(uid);
|
||||
self.burst_channels(uid);
|
||||
self.link_out(uid, "ENDBURST".to_string());
|
||||
eprintln!("[link] linked {name} ({sid}) — {desc}");
|
||||
}
|
||||
|
||||
fn reject_link(&mut self, uid: Uid, why: &str) {
|
||||
self.link_out(uid, format!("ERROR :Link denied: {why}"));
|
||||
eprintln!("[link] rejected {uid}: {why}");
|
||||
self.close_link(uid, why);
|
||||
}
|
||||
|
||||
/// Drop a link and every server reachable through it (a netsplit). We don't
|
||||
/// force the socket shut: dropping the `Link` drops its `out` sender, so the
|
||||
/// writer thread first flushes any queued line (e.g. an `ERROR`) and *then*
|
||||
/// closes the socket — otherwise a rejection races its own disconnect.
|
||||
pub fn close_link(&mut self, uid: Uid, reason: &str) {
|
||||
let mut peer = String::new();
|
||||
if let Some(l) = self.links.remove(&uid) {
|
||||
peer = l.name.clone().unwrap_or_default();
|
||||
if let Some(sid) = l.sid {
|
||||
eprintln!("[link] netsplit {peer} ({sid}): {reason}");
|
||||
}
|
||||
}
|
||||
self.servers.retain(|_, s| s.via != uid);
|
||||
// every remote user reached through this link is now gone (netsplit) —
|
||||
// drop them from channels and QUIT them to any local channel-mates.
|
||||
let netreason = format!("{} {peer}", self.name);
|
||||
let gone: Vec<String> = self
|
||||
.remote_users
|
||||
.iter()
|
||||
.filter(|(_, ru)| ru.via == uid)
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
for uuid in &gone {
|
||||
self.drop_remote_user(uuid, &netreason);
|
||||
}
|
||||
}
|
||||
|
||||
/// Periodic keepalive: PING every registered link.
|
||||
pub fn ping_links(&self) {
|
||||
let token = self.sid.clone();
|
||||
let uids: Vec<Uid> = self
|
||||
.links
|
||||
.iter()
|
||||
.filter(|(_, l)| l.registered)
|
||||
.map(|(&u, _)| u)
|
||||
.collect();
|
||||
for u in uids {
|
||||
self.link_out(u, format!("PING :{token}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay `line` to every registered link except `except` (the origin).
|
||||
pub fn propagate(&self, line: &str, except: Option<Uid>) {
|
||||
let targets: Vec<Uid> = self
|
||||
.links
|
||||
.iter()
|
||||
.filter(|(u, l)| l.registered && Some(**u) != except)
|
||||
.map(|(&u, _)| u)
|
||||
.collect();
|
||||
for u in targets {
|
||||
self.link_out(u, line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// The `UID` introduction line for a local user.
|
||||
fn uid_line(&self, u: &User) -> String {
|
||||
let acct = u.account.clone().unwrap_or_else(|| "*".to_string());
|
||||
format!(
|
||||
":{} UID {} {} {} {} {} :{}",
|
||||
self.sid,
|
||||
u.uuid,
|
||||
u.nick,
|
||||
u.ident,
|
||||
u.host_display(),
|
||||
acct,
|
||||
u.realname
|
||||
)
|
||||
}
|
||||
|
||||
/// Burst all local registered users to a freshly-linked peer.
|
||||
fn burst_users(&self, link_uid: Uid) {
|
||||
let lines: Vec<String> = self
|
||||
.users
|
||||
.values()
|
||||
.filter(|u| u.registered)
|
||||
.map(|u| self.uid_line(u))
|
||||
.collect();
|
||||
for l in lines {
|
||||
self.link_out(link_uid, l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Announce a newly-registered local user to every link.
|
||||
pub fn introduce_to_links(&self, uid: Uid) {
|
||||
if self.links.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(u) = self.users.get(&uid) {
|
||||
let line = self.uid_line(u);
|
||||
self.propagate(&line, None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Propagate a local user's nick change.
|
||||
pub fn propagate_nick(&self, uid: Uid, newnick: &str) {
|
||||
if let Some(u) = self.users.get(&uid) {
|
||||
if u.registered && !self.links.is_empty() {
|
||||
self.propagate(&format!(":{} NICK {newnick}", u.uuid), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Find a remote user by nick: returns `(uuid, via-link)`.
|
||||
pub fn find_remote(&self, nick: &str) -> Option<(String, Uid)> {
|
||||
let uuid = self.remote_nick.get(&nick.to_ascii_lowercase())?;
|
||||
let ru = self.remote_users.get(uuid)?;
|
||||
Some((uuid.clone(), ru.via))
|
||||
}
|
||||
|
||||
/// Resolve any network uuid (remote or local) to a `nick!user@host` prefix.
|
||||
pub fn uuid_prefix(&self, uuid: &str) -> Option<String> {
|
||||
if let Some(ru) = self.remote_users.get(uuid) {
|
||||
return Some(ru.prefix());
|
||||
}
|
||||
self.uuid_local
|
||||
.get(uuid)
|
||||
.and_then(|&uid| self.users.get(&uid))
|
||||
.map(|u| u.prefix())
|
||||
}
|
||||
|
||||
/// Route a message from a local sender to a remote user across its link.
|
||||
pub fn send_to_remote(&self, sender: Uid, target_uuid: &str, via: Uid, cmd: &str, text: &str) {
|
||||
if let Some(u) = self.users.get(&sender) {
|
||||
self.link_out(via, format!(":{} {cmd} {target_uuid} :{text}", u.uuid));
|
||||
}
|
||||
}
|
||||
|
||||
// --- inbound S2S records --------------------------------------------------
|
||||
|
||||
fn link_uid_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<sid> UID <uuid> <nick> <ident> <host> <account> :<realname>
|
||||
if msg.params.len() < 6 {
|
||||
return;
|
||||
}
|
||||
let sid = msg.source.clone().unwrap_or_default();
|
||||
let uuid = msg.params[0].clone();
|
||||
let nick = msg.params[1].clone();
|
||||
// nick collision: a local holder is killed (both sides do this, so both
|
||||
// vanish deterministically); an existing remote holder simply wins.
|
||||
if let Some(luid) = self.find_nick(&nick) {
|
||||
self.send(luid, "ERROR :Closing link: Nick collision".to_string());
|
||||
self.remove_user(luid, "Nick collision");
|
||||
return;
|
||||
}
|
||||
if self.remote_nick.contains_key(&nick.to_ascii_lowercase()) {
|
||||
return;
|
||||
}
|
||||
let account = if msg.params[4] == "*" {
|
||||
None
|
||||
} else {
|
||||
Some(msg.params[4].clone())
|
||||
};
|
||||
self.remote_nick
|
||||
.insert(nick.to_ascii_lowercase(), uuid.clone());
|
||||
self.remote_users.insert(
|
||||
uuid.clone(),
|
||||
RemoteUser {
|
||||
uuid,
|
||||
nick,
|
||||
ident: msg.params[2].clone(),
|
||||
host: msg.params[3].clone(),
|
||||
realname: msg.params[5].clone(),
|
||||
account,
|
||||
sid: sid.clone(),
|
||||
via,
|
||||
},
|
||||
);
|
||||
let line = format!(
|
||||
":{sid} UID {} {} {} {} {} :{}",
|
||||
msg.params[0],
|
||||
msg.params[1],
|
||||
msg.params[2],
|
||||
msg.params[3],
|
||||
msg.params[4],
|
||||
msg.params[5]
|
||||
);
|
||||
self.propagate(&line, Some(via));
|
||||
}
|
||||
|
||||
fn link_nick_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<uuid> NICK <newnick>
|
||||
let Some(uuid) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(newnick) = msg.params.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
let old = match self.remote_users.get_mut(&uuid) {
|
||||
Some(ru) => {
|
||||
let old = ru.nick.clone();
|
||||
ru.nick = newnick.clone();
|
||||
old
|
||||
}
|
||||
None => return,
|
||||
};
|
||||
self.remote_nick.remove(&old.to_ascii_lowercase());
|
||||
self.remote_nick
|
||||
.insert(newnick.to_ascii_lowercase(), uuid.clone());
|
||||
self.propagate(&format!(":{uuid} NICK {newnick}"), Some(via));
|
||||
}
|
||||
|
||||
fn link_quit_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<uuid> QUIT :<reason>
|
||||
let Some(uuid) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
let reason = msg.params.first().cloned().unwrap_or_default();
|
||||
self.drop_remote_user(&uuid, &reason);
|
||||
self.propagate(&format!(":{uuid} QUIT :{reason}"), Some(via));
|
||||
}
|
||||
|
||||
fn link_message_recv(&mut self, via: Uid, msg: &Message, notice: bool) {
|
||||
// :<srcuuid> PRIVMSG <#chan|dstuuid> :<text>
|
||||
let cmd = if notice { "NOTICE" } else { "PRIVMSG" };
|
||||
let Some(src) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
if msg.params.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let (target, text) = (msg.params[0].clone(), msg.params[1].clone());
|
||||
let Some(prefix) = self.uuid_prefix(&src) else {
|
||||
return;
|
||||
};
|
||||
if target.starts_with('#') {
|
||||
let key = target.to_ascii_lowercase();
|
||||
if !self.channels.contains_key(&key) {
|
||||
return;
|
||||
}
|
||||
let line = format!(":{prefix} {cmd} {target} :{text}");
|
||||
let members: Vec<Uid> = self.channels[&key].members.keys().copied().collect();
|
||||
for m in members {
|
||||
if self.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
self.send(m, line.clone());
|
||||
}
|
||||
// forward to the other links that have members in this channel
|
||||
for l in self.channel_link_targets(&key, Some(via)) {
|
||||
self.link_out(l, format!(":{src} {cmd} {target} :{text}"));
|
||||
}
|
||||
} else if let Some(&dst) = self.uuid_local.get(&target) {
|
||||
let nick = self
|
||||
.users
|
||||
.get(&dst)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
self.send(dst, format!(":{prefix} {cmd} {nick} :{text}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell linked servers a local user joined a channel.
|
||||
pub fn propagate_join(&self, uid: Uid, chan: &str) {
|
||||
if self.links.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(u) = self.users.get(&uid) {
|
||||
if u.registered {
|
||||
self.propagate(&format!(":{} JOIN {chan}", u.uuid), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tell linked servers a local user parted a channel.
|
||||
pub fn propagate_part(&self, uid: Uid, chan: &str, reason: &str) {
|
||||
if self.links.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(u) = self.users.get(&uid) {
|
||||
if u.registered {
|
||||
let line = if reason.is_empty() {
|
||||
format!(":{} PART {chan}", u.uuid)
|
||||
} else {
|
||||
format!(":{} PART {chan} :{reason}", u.uuid)
|
||||
};
|
||||
self.propagate(&line, None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The distinct links a channel's remote members sit behind (minus `except`).
|
||||
fn channel_link_targets(&self, key: &str, except: Option<Uid>) -> Vec<Uid> {
|
||||
let mut set: HashSet<Uid> = HashSet::new();
|
||||
if let Some(ch) = self.channels.get(key) {
|
||||
for uuid in ch.rmembers.keys() {
|
||||
if let Some(ru) = self.remote_users.get(uuid) {
|
||||
if Some(ru.via) != except {
|
||||
set.insert(ru.via);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
set.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Relay a local user's channel message to every link with members there.
|
||||
pub fn send_channel_to_links(
|
||||
&self,
|
||||
sender: Uid,
|
||||
key: &str,
|
||||
target: &str,
|
||||
cmd: &str,
|
||||
text: &str,
|
||||
) {
|
||||
let Some(uuid) = self.users.get(&sender).map(|u| u.uuid.clone()) else {
|
||||
return;
|
||||
};
|
||||
let line = format!(":{uuid} {cmd} {target} :{text}");
|
||||
for l in self.channel_link_targets(key, None) {
|
||||
self.link_out(l, line.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn link_join_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<uuid> JOIN #chan
|
||||
let Some(uuid) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(chan) = msg.params.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
if !self.remote_users.contains_key(&uuid) {
|
||||
return;
|
||||
}
|
||||
let key = chan.to_ascii_lowercase();
|
||||
self.channels
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| Channel::new(&chan))
|
||||
.rmembers
|
||||
.insert(uuid.clone(), Member::default());
|
||||
let prefix = self
|
||||
.remote_users
|
||||
.get(&uuid)
|
||||
.map(|r| r.prefix())
|
||||
.unwrap_or_default();
|
||||
self.to_channel(&key, &format!(":{prefix} JOIN {chan}"), None);
|
||||
self.propagate(&format!(":{uuid} JOIN {chan}"), Some(via));
|
||||
}
|
||||
|
||||
fn link_part_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<uuid> PART #chan [:reason]
|
||||
let Some(uuid) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
let Some(chan) = msg.params.first().cloned() else {
|
||||
return;
|
||||
};
|
||||
let reason = msg.params.get(1).cloned().unwrap_or_default();
|
||||
let key = chan.to_ascii_lowercase();
|
||||
let removed = self
|
||||
.channels
|
||||
.get_mut(&key)
|
||||
.map(|c| c.rmembers.remove(&uuid).is_some())
|
||||
.unwrap_or(false);
|
||||
if !removed {
|
||||
return;
|
||||
}
|
||||
let prefix = self
|
||||
.remote_users
|
||||
.get(&uuid)
|
||||
.map(|r| r.prefix())
|
||||
.unwrap_or_default();
|
||||
let line = if reason.is_empty() {
|
||||
format!(":{prefix} PART {chan}")
|
||||
} else {
|
||||
format!(":{prefix} PART {chan} :{reason}")
|
||||
};
|
||||
self.to_channel(&key, &line, None);
|
||||
self.channels.retain(|_, c| !c.is_empty());
|
||||
let fwd = if reason.is_empty() {
|
||||
format!(":{uuid} PART {chan}")
|
||||
} else {
|
||||
format!(":{uuid} PART {chan} :{reason}")
|
||||
};
|
||||
self.propagate(&fwd, Some(via));
|
||||
}
|
||||
|
||||
/// Remove a remote user everywhere (channels + registries) and QUIT them to
|
||||
/// any local users who shared a channel.
|
||||
fn drop_remote_user(&mut self, uuid: &str, reason: &str) {
|
||||
let prefix = match self.remote_users.get(uuid) {
|
||||
Some(ru) => ru.prefix(),
|
||||
None => return,
|
||||
};
|
||||
let mut notify: HashSet<Uid> = HashSet::new();
|
||||
let chans: Vec<String> = self
|
||||
.channels
|
||||
.iter()
|
||||
.filter(|(_, c)| c.rmembers.contains_key(uuid))
|
||||
.map(|(k, _)| k.clone())
|
||||
.collect();
|
||||
for key in &chans {
|
||||
if let Some(c) = self.channels.get_mut(key) {
|
||||
c.rmembers.remove(uuid);
|
||||
for &m in c.members.keys() {
|
||||
notify.insert(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
let line = format!(":{prefix} QUIT :{reason}");
|
||||
for m in notify {
|
||||
self.send(m, line.clone());
|
||||
}
|
||||
self.channels.retain(|_, c| !c.is_empty());
|
||||
if let Some(ru) = self.remote_users.remove(uuid) {
|
||||
self.remote_nick.remove(&ru.nick.to_ascii_lowercase());
|
||||
}
|
||||
}
|
||||
|
||||
/// Relay `:<sender-uuid> <rest>` to every link (MODE/TOPIC/KICK propagation).
|
||||
pub fn propagate_from_user(&self, uid: Uid, rest: &str) {
|
||||
if self.links.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(u) = self.users.get(&uid) {
|
||||
if u.registered {
|
||||
self.propagate(&format!(":{} {rest}", u.uuid), None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Set a prefix mode on `nick` in `key`, be they a local or remote member.
|
||||
fn set_member_prefix(&mut self, key: &str, nick: &str, letter: char, adding: bool) {
|
||||
if let Some(uid) = self.find_nick(nick) {
|
||||
if let Some(m) = self
|
||||
.channels
|
||||
.get_mut(key)
|
||||
.and_then(|c| c.members.get_mut(&uid))
|
||||
{
|
||||
m.set_prefix(letter, adding);
|
||||
}
|
||||
} else if let Some((uuid, _)) = self.find_remote(nick) {
|
||||
if let Some(m) = self
|
||||
.channels
|
||||
.get_mut(key)
|
||||
.and_then(|c| c.rmembers.get_mut(&uuid))
|
||||
{
|
||||
m.set_prefix(letter, adding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn link_topic_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<uuid> TOPIC #chan :<text>
|
||||
let Some(src) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
if msg.params.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let chan = msg.params[0].clone();
|
||||
let key = chan.to_ascii_lowercase();
|
||||
let text = msg.params[1].clone();
|
||||
if !self.channels.contains_key(&key) {
|
||||
return;
|
||||
}
|
||||
let setter = self
|
||||
.remote_users
|
||||
.get(&src)
|
||||
.map(|r| r.nick.clone())
|
||||
.unwrap_or_default();
|
||||
if let Some(c) = self.channels.get_mut(&key) {
|
||||
c.topic = Some(Topic {
|
||||
text: text.clone(),
|
||||
setter,
|
||||
ts: now(),
|
||||
});
|
||||
}
|
||||
let prefix = self.uuid_prefix(&src).unwrap_or_default();
|
||||
self.to_channel(&key, &format!(":{prefix} TOPIC {chan} :{text}"), None);
|
||||
self.propagate(&format!(":{src} TOPIC {chan} :{text}"), Some(via));
|
||||
}
|
||||
|
||||
fn link_kick_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<kicker-uuid> KICK #chan <victim-nick> :<reason>
|
||||
let Some(src) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
if msg.params.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let chan = msg.params[0].clone();
|
||||
let key = chan.to_ascii_lowercase();
|
||||
let victim = msg.params[1].clone();
|
||||
let reason = msg.params.get(2).cloned().unwrap_or_else(|| victim.clone());
|
||||
let prefix = self.uuid_prefix(&src).unwrap_or_default();
|
||||
let mut removed = false;
|
||||
if let Some(vuid) = self.find_nick(&victim) {
|
||||
if let Some(c) = self.channels.get_mut(&key) {
|
||||
removed = c.members.remove(&vuid).is_some();
|
||||
}
|
||||
if removed {
|
||||
if let Some(u) = self.users.get_mut(&vuid) {
|
||||
u.channels.remove(&key);
|
||||
}
|
||||
}
|
||||
} else if let Some((vuuid, _)) = self.find_remote(&victim) {
|
||||
if let Some(c) = self.channels.get_mut(&key) {
|
||||
removed = c.rmembers.remove(&vuuid).is_some();
|
||||
}
|
||||
}
|
||||
if !removed {
|
||||
return;
|
||||
}
|
||||
self.to_channel(
|
||||
&key,
|
||||
&format!(":{prefix} KICK {chan} {victim} :{reason}"),
|
||||
None,
|
||||
);
|
||||
self.channels.retain(|_, c| !c.is_empty());
|
||||
self.propagate(&format!(":{src} KICK {chan} {victim} :{reason}"), Some(via));
|
||||
}
|
||||
|
||||
fn link_mode_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<uuid> MODE #chan <modestring> [params...] (applied without re-checking)
|
||||
let Some(src) = msg.source.clone() else {
|
||||
return;
|
||||
};
|
||||
if msg.params.len() < 2 || !msg.params[0].starts_with('#') {
|
||||
return;
|
||||
}
|
||||
let chan = msg.params[0].clone();
|
||||
let key = chan.to_ascii_lowercase();
|
||||
if !self.channels.contains_key(&key) {
|
||||
return;
|
||||
}
|
||||
let modestring = msg.params[1].clone();
|
||||
let args: Vec<String> = msg.params[2..].to_vec();
|
||||
let mut argi = 0usize;
|
||||
let mut sign = '+';
|
||||
for c in modestring.chars() {
|
||||
if c == '+' || c == '-' {
|
||||
sign = c;
|
||||
continue;
|
||||
}
|
||||
let adding = sign == '+';
|
||||
match c {
|
||||
'q' | 'a' | 'o' | 'h' | 'v' => {
|
||||
if let Some(n) = args.get(argi).cloned() {
|
||||
argi += 1;
|
||||
self.set_member_prefix(&key, &n, c, adding);
|
||||
}
|
||||
}
|
||||
'k' => {
|
||||
let p = args.get(argi).cloned();
|
||||
if p.is_some() {
|
||||
argi += 1;
|
||||
}
|
||||
if let Some(ch) = self.channels.get_mut(&key) {
|
||||
ch.modes.key = if adding { p } else { None };
|
||||
}
|
||||
}
|
||||
'l' => {
|
||||
if adding {
|
||||
if let Some(n) = args.get(argi).and_then(|s| s.parse::<u32>().ok()) {
|
||||
argi += 1;
|
||||
if let Some(ch) = self.channels.get_mut(&key) {
|
||||
ch.modes.limit = Some(n);
|
||||
}
|
||||
}
|
||||
} else if let Some(ch) = self.channels.get_mut(&key) {
|
||||
ch.modes.limit = None;
|
||||
}
|
||||
}
|
||||
'b' | 'e' | 'I' => {
|
||||
if let Some(mask) = args.get(argi).cloned() {
|
||||
argi += 1;
|
||||
let setter = self
|
||||
.remote_users
|
||||
.get(&src)
|
||||
.map(|r| r.nick.clone())
|
||||
.unwrap_or_else(|| src.clone());
|
||||
if let Some(ch) = self.channels.get_mut(&key) {
|
||||
let list = match c {
|
||||
'b' => &mut ch.bans,
|
||||
'e' => &mut ch.excepts,
|
||||
_ => &mut ch.invex,
|
||||
};
|
||||
if adding {
|
||||
if !list.iter().any(|b| b.mask == mask) {
|
||||
list.push(Ban {
|
||||
mask,
|
||||
setter,
|
||||
ts: now(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
list.retain(|b| b.mask != mask);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(ch) = self.channels.get_mut(&key) {
|
||||
ch.modes.set_by_letter(c, adding);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// source is a user (uuid) or, for a burst, a server (sid)
|
||||
let prefix = self
|
||||
.uuid_prefix(&src)
|
||||
.or_else(|| self.servers.get(&src).map(|s| s.name.clone()))
|
||||
.unwrap_or_else(|| self.name.clone());
|
||||
let paramstr = if args.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" {}", args.join(" "))
|
||||
};
|
||||
self.to_channel(
|
||||
&key,
|
||||
&format!(":{prefix} MODE {chan} {modestring}{paramstr}"),
|
||||
None,
|
||||
);
|
||||
self.propagate(
|
||||
&format!(":{src} MODE {chan} {modestring}{paramstr}"),
|
||||
Some(via),
|
||||
);
|
||||
}
|
||||
|
||||
/// Burst every channel (name, ts, modes, prefixed members) to a new peer.
|
||||
fn burst_channels(&self, link_uid: Uid) {
|
||||
let mut lines = Vec::new();
|
||||
for ch in self.channels.values() {
|
||||
let mut mem: Vec<String> = Vec::new();
|
||||
for (uid, m) in &ch.members {
|
||||
if let Some(u) = self.users.get(uid) {
|
||||
mem.push(format!("{}{}", m.all_prefixes(), u.uuid));
|
||||
}
|
||||
}
|
||||
for (uuid, m) in &ch.rmembers {
|
||||
mem.push(format!("{}{}", m.all_prefixes(), uuid));
|
||||
}
|
||||
if mem.is_empty() {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!(
|
||||
":{} FJOIN {} {} {} :{}",
|
||||
self.sid,
|
||||
ch.name,
|
||||
ch.created,
|
||||
ch.modes.render(false),
|
||||
mem.join(" ")
|
||||
));
|
||||
// burst the ban / except / invite-exception lists too
|
||||
for (letter, list) in [('b', &ch.bans), ('e', &ch.excepts), ('I', &ch.invex)] {
|
||||
for b in list {
|
||||
lines.push(format!(
|
||||
":{} MODE {} +{letter} {}",
|
||||
self.sid, ch.name, b.mask
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for l in lines {
|
||||
self.link_out(link_uid, l);
|
||||
}
|
||||
}
|
||||
|
||||
fn link_fjoin_recv(&mut self, via: Uid, msg: &Message) {
|
||||
// :<sid> FJOIN #chan <ts> <modes> :<pfx>uuid <pfx>uuid ...
|
||||
if msg.params.len() < 4 {
|
||||
return;
|
||||
}
|
||||
let chan = msg.params[0].clone();
|
||||
let key = chan.to_ascii_lowercase();
|
||||
let ts: u64 = msg.params[1].parse().unwrap_or_else(|_| now());
|
||||
let modes = msg.params[2].clone();
|
||||
let memberlist = msg.params[3].clone();
|
||||
let is_new = !self.channels.contains_key(&key);
|
||||
{
|
||||
let ch = self
|
||||
.channels
|
||||
.entry(key.clone())
|
||||
.or_insert_with(|| Channel::new(&chan));
|
||||
if is_new {
|
||||
ch.created = ts;
|
||||
let mut sign = '+';
|
||||
for c in modes.chars() {
|
||||
match c {
|
||||
'+' => sign = '+',
|
||||
'-' => sign = '-',
|
||||
_ => ch.modes.set_by_letter(c, sign == '+'),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut adds: Vec<(String, Member)> = Vec::new();
|
||||
for tok in memberlist.split_whitespace() {
|
||||
let (pfx, uuid) = split_member(tok);
|
||||
if self.uuid_local.contains_key(&uuid) || !self.remote_users.contains_key(&uuid) {
|
||||
continue; // our own user, or one we don't know yet
|
||||
}
|
||||
let mut m = Member::default();
|
||||
for pc in pfx.chars() {
|
||||
m.set_prefix(prefix_letter(pc), true);
|
||||
}
|
||||
adds.push((uuid, m));
|
||||
}
|
||||
if let Some(ch) = self.channels.get_mut(&key) {
|
||||
for (uuid, m) in adds {
|
||||
ch.rmembers.insert(uuid, m);
|
||||
}
|
||||
}
|
||||
let raw = format!(
|
||||
":{} FJOIN {chan} {ts} {modes} :{memberlist}",
|
||||
msg.source.clone().unwrap_or_default()
|
||||
);
|
||||
self.propagate(&raw, Some(via));
|
||||
}
|
||||
}
|
||||
|
||||
/// Split a bursted member token `@+0AAAAAAAB` into its prefix chars and uuid.
|
||||
fn split_member(tok: &str) -> (String, String) {
|
||||
let idx = tok
|
||||
.find(|c: char| !"~&@%+".contains(c))
|
||||
.unwrap_or(tok.len());
|
||||
(tok[..idx].to_string(), tok[idx..].to_string())
|
||||
}
|
||||
|
||||
/// Map a prefix char to its mode letter (`@` → `o`, …).
|
||||
fn prefix_letter(c: char) -> char {
|
||||
match c {
|
||||
'~' => 'q',
|
||||
'&' => 'a',
|
||||
'@' => 'o',
|
||||
'%' => 'h',
|
||||
'+' => 'v',
|
||||
_ => ' ',
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn sid_validation() {
|
||||
assert!(valid_sid("0AA"));
|
||||
assert!(valid_sid("1Z9"));
|
||||
assert!(valid_sid("9ZZ"));
|
||||
assert!(!valid_sid("AAA")); // must start with a digit
|
||||
assert!(!valid_sid("0a1")); // no lowercase
|
||||
assert!(!valid_sid("0A")); // too short
|
||||
assert!(!valid_sid("0ABC")); // too long
|
||||
}
|
||||
}
|
||||
106
src/main.rs
Normal file
106
src/main.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
//! echoIRCd entry point: read config, bind the plaintext (and, if configured,
|
||||
//! the TLS) listener, then run the single-threaded core while the accept loops
|
||||
//! feed it connections.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::net::TcpListener;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
|
||||
use echoircd::config::Config;
|
||||
use echoircd::ircd::{Event, Ircd};
|
||||
use echoircd::socketengine;
|
||||
use echoircd::tls::{OpensslBackend, TlsBackend};
|
||||
|
||||
fn main() {
|
||||
let path = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "echoircd.conf".to_string());
|
||||
let cfg = Config::load(&path);
|
||||
|
||||
let listener = match TcpListener::bind(&cfg.bind) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("echoircd: cannot bind {}: {e}", cfg.bind);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
eprintln!(
|
||||
"echoircd {} on {} (network {}, server {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
cfg.bind,
|
||||
cfg.network,
|
||||
cfg.servername
|
||||
);
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let core_cfg = cfg.clone();
|
||||
let core = thread::spawn(move || Ircd::new(core_cfg).run(rx));
|
||||
|
||||
// background timer: drives ping/idle timeouts
|
||||
let tick_tx = tx.clone();
|
||||
thread::spawn(move || loop {
|
||||
thread::sleep(std::time::Duration::from_secs(echoircd::server::TICK_SECS));
|
||||
if tick_tx.send(Event::Tick).is_err() {
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// one uid counter shared by every listener so ids stay unique
|
||||
let counter = Arc::new(AtomicU64::new(1));
|
||||
|
||||
// optional TLS listener (bind_tls + tls_cert + tls_key). A cert/bind problem
|
||||
// disables TLS but never takes the plaintext listener down.
|
||||
if let (Some(bind_tls), Some(cert), Some(key)) = (&cfg.bind_tls, &cfg.tls_cert, &cfg.tls_key) {
|
||||
match OpensslBackend::new(cert, key) {
|
||||
Ok(backend) => match TcpListener::bind(bind_tls) {
|
||||
Ok(tls_listener) => {
|
||||
eprintln!("echoircd TLS on {bind_tls} (openssl)");
|
||||
let backend: Arc<dyn TlsBackend> = Arc::new(backend);
|
||||
let tls_tx = tx.clone();
|
||||
let tls_counter = counter.clone();
|
||||
thread::spawn(move || {
|
||||
socketengine::accept_loop(
|
||||
tls_listener,
|
||||
tls_tx,
|
||||
Some(backend),
|
||||
tls_counter,
|
||||
false,
|
||||
)
|
||||
});
|
||||
}
|
||||
Err(e) => eprintln!("echoircd: cannot bind TLS {bind_tls}: {e}"),
|
||||
},
|
||||
Err(e) => eprintln!("echoircd: TLS disabled (cert/key error): {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// server-to-server link listener (see crate::link)
|
||||
if let Some(bind_srv) = &cfg.bind_server {
|
||||
match TcpListener::bind(bind_srv) {
|
||||
Ok(sl) => {
|
||||
eprintln!("echoircd S2S link listener on {bind_srv} (sid {})", cfg.sid);
|
||||
let s_tx = tx.clone();
|
||||
let s_counter = counter.clone();
|
||||
thread::spawn(move || socketengine::accept_loop(sl, s_tx, None, s_counter, true));
|
||||
}
|
||||
Err(e) => eprintln!("echoircd: cannot bind server port {bind_srv}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
// dial any autoconnect uplinks (after a short delay so the peer can boot)
|
||||
for block in cfg.links.iter().filter(|b| b.autoconnect) {
|
||||
let addr = format!("{}:{}", block.ip, block.port);
|
||||
let u_tx = tx.clone();
|
||||
let u_counter = counter.clone();
|
||||
thread::spawn(move || {
|
||||
thread::sleep(std::time::Duration::from_secs(2));
|
||||
socketengine::connect_link(&addr, u_tx, u_counter);
|
||||
});
|
||||
}
|
||||
|
||||
socketengine::accept_loop(listener, tx, None, counter, false);
|
||||
let _ = core.join();
|
||||
}
|
||||
124
src/message.rs
Normal file
124
src/message.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
//! IRC line parsing (RFC 1459 + IRCv3 message tags). One line in → an optional
|
||||
//! [`Message`] out. Client-only tags (the `+`-prefixed ones) are captured so
|
||||
//! TAGMSG / PRIVMSG can relay them onward; server tags from clients are dropped.
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct Message {
|
||||
/// The `:source` prefix, if any (clients rarely send one).
|
||||
pub source: Option<String>,
|
||||
/// Command, upper-cased (`PRIVMSG`, `JOIN`, …) or a 3-digit numeric.
|
||||
pub command: String,
|
||||
/// Parameters, with the trailing `:param` unwrapped into the last element.
|
||||
pub params: Vec<String>,
|
||||
/// Client-only IRCv3 tags (`+key=val;…`) re-serialised for relay; `""` if none.
|
||||
pub ctags: String,
|
||||
}
|
||||
|
||||
/// Parse one wire line. Returns `None` for an empty/garbage line.
|
||||
pub fn parse(line: &str) -> Option<Message> {
|
||||
let mut rest = line.trim_start();
|
||||
|
||||
// IRCv3 message tags — keep the client-only (`+`) tags for relay, drop the rest.
|
||||
let mut ctags = String::new();
|
||||
if let Some(after_at) = rest.strip_prefix('@') {
|
||||
let (tags, r) = after_at.split_once(' ')?;
|
||||
ctags = tags
|
||||
.split(';')
|
||||
.filter(|t| t.starts_with('+'))
|
||||
.collect::<Vec<_>>()
|
||||
.join(";");
|
||||
rest = r.trim_start();
|
||||
}
|
||||
|
||||
let mut source = None;
|
||||
if let Some(after_colon) = rest.strip_prefix(':') {
|
||||
let (src, r) = after_colon.split_once(' ')?;
|
||||
source = Some(src.to_string());
|
||||
rest = r.trim_start();
|
||||
}
|
||||
|
||||
let (cmd, mut rest) = match rest.split_once(' ') {
|
||||
Some((c, r)) => (c, r.trim_start()),
|
||||
None => (rest, ""),
|
||||
};
|
||||
if cmd.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut params = Vec::new();
|
||||
while !rest.is_empty() {
|
||||
if let Some(trailing) = rest.strip_prefix(':') {
|
||||
params.push(trailing.to_string());
|
||||
break;
|
||||
}
|
||||
match rest.split_once(' ') {
|
||||
Some((p, r)) => {
|
||||
params.push(p.to_string());
|
||||
rest = r.trim_start();
|
||||
}
|
||||
None => {
|
||||
params.push(rest.to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Message {
|
||||
source,
|
||||
command: cmd.to_ascii_uppercase(),
|
||||
params,
|
||||
ctags,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn simple_command() {
|
||||
let m = parse("NICK reverse").unwrap();
|
||||
assert_eq!(m.command, "NICK");
|
||||
assert_eq!(m.params, vec!["reverse"]);
|
||||
assert!(m.source.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_keeps_spaces() {
|
||||
let m = parse("PRIVMSG #argentina :hola que tal").unwrap();
|
||||
assert_eq!(m.command, "PRIVMSG");
|
||||
assert_eq!(m.params, vec!["#argentina", "hola que tal"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_and_lowercase_command_upcased() {
|
||||
let m = parse(":nick!u@h privmsg x :y").unwrap();
|
||||
assert_eq!(m.source.as_deref(), Some("nick!u@h"));
|
||||
assert_eq!(m.command, "PRIVMSG");
|
||||
assert_eq!(m.params, vec!["x", "y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_are_skipped() {
|
||||
let m = parse("@id=1;time=x PING :token").unwrap();
|
||||
assert_eq!(m.command, "PING");
|
||||
assert_eq!(m.params, vec!["token"]);
|
||||
assert_eq!(m.ctags, ""); // no client-only tags here
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_only_tags_kept_for_relay() {
|
||||
let m = parse("@time=x;+typing=done;account=z TAGMSG #devs").unwrap();
|
||||
assert_eq!(m.command, "TAGMSG");
|
||||
assert_eq!(m.params, vec!["#devs"]);
|
||||
assert_eq!(m.ctags, "+typing=done"); // server tags dropped, `+` kept
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_junk() {
|
||||
assert!(parse("").is_none());
|
||||
assert!(parse(" ").is_none());
|
||||
// a lone colon prefix with nothing after is not a message
|
||||
assert!(parse(":only").is_none());
|
||||
}
|
||||
}
|
||||
1002
src/mode.rs
Normal file
1002
src/mode.rs
Normal file
File diff suppressed because it is too large
Load diff
69
src/module.rs
Normal file
69
src/module.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
//! The module API — echoIRCd's answer to InspIRCd's `Module` class.
|
||||
//!
|
||||
//! Modules hook lifecycle events. "Pre" hooks return a [`ModResult`] and can
|
||||
//! **deny** an action; "notify" hooks are informational. The core fires pre-hooks
|
||||
//! inline (so a `Deny` actually blocks) and notify-hooks from a queue after the
|
||||
//! triggering command finishes — so a handler can emit an event without ever
|
||||
//! touching the module list. All hooks get `&mut Server`, so a module can act
|
||||
//! (send lines, force a join, …), exactly like an InspIRCd module gets the
|
||||
//! `ServerInstance`.
|
||||
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
/// A pre-hook's verdict. `Passthru` = no opinion; `Allow` = force-allow (skip
|
||||
/// remaining checks); `Deny` = block the action.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum ModResult {
|
||||
Passthru,
|
||||
Allow,
|
||||
Deny,
|
||||
}
|
||||
|
||||
/// A queued notify-event, drained by the core after each command.
|
||||
pub enum Hook {
|
||||
Connect(Uid),
|
||||
Join(Uid, String),
|
||||
Part(Uid, String, String),
|
||||
Quit(Uid, String),
|
||||
}
|
||||
|
||||
#[allow(unused_variables)]
|
||||
pub trait Module: Send {
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
// --- pre-hooks (can Deny) ------------------------------------------------
|
||||
|
||||
/// Last gate before a client finishes registration. `Deny` refuses the link.
|
||||
fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult {
|
||||
ModResult::Passthru
|
||||
}
|
||||
/// Before any command runs. `Deny` swallows the command silently.
|
||||
fn on_pre_command(
|
||||
&mut self,
|
||||
srv: &mut Server,
|
||||
uid: Uid,
|
||||
cmd: &str,
|
||||
params: &[String],
|
||||
) -> ModResult {
|
||||
ModResult::Passthru
|
||||
}
|
||||
/// Before a PRIVMSG/NOTICE is delivered. `Deny` drops it.
|
||||
fn on_pre_message(
|
||||
&mut self,
|
||||
srv: &mut Server,
|
||||
uid: Uid,
|
||||
target: &str,
|
||||
text: &str,
|
||||
) -> ModResult {
|
||||
ModResult::Passthru
|
||||
}
|
||||
|
||||
// --- notify-hooks --------------------------------------------------------
|
||||
|
||||
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {}
|
||||
fn on_post_command(&mut self, srv: &mut Server, uid: Uid, cmd: &str) {}
|
||||
fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {}
|
||||
fn on_part(&mut self, srv: &mut Server, uid: Uid, chan: &str, reason: &str) {}
|
||||
fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) {}
|
||||
}
|
||||
371
src/modules/antimixedutf8.rs
Normal file
371
src/modules/antimixedutf8.rs
Normal file
|
|
@ -0,0 +1,371 @@
|
|||
//! antimixedutf8 — blocks spam that mixes Unicode scripts within words (Latin
|
||||
//! letters swapped for Cyrillic/Greek look-alikes: "FᏒee Ⅴ1аgrа"), a very common
|
||||
//! obfuscation. This is reverse's own detection model — the scoring rules and the
|
||||
//! confusable / fancy-Latin / zero-width tables — implemented from scratch in
|
||||
//! native Rust. (The *tables and weights* are the detector's spec: they define
|
||||
//! what counts as spam. Everything around them is original echoIRCd code.)
|
||||
//!
|
||||
//! Per word: letters from more than one script score; so do words that are ASCII
|
||||
//! mixed with Latin-confusable letters, words built almost entirely of confusables,
|
||||
//! and "fancy" styled-Latin words. Zero-width chars score too. At/above the
|
||||
//! configured threshold the action fires (block | kill | gline | kline | zline).
|
||||
//!
|
||||
//! Rust strings are valid UTF-8, so we walk codepoints straight from `chars()` and
|
||||
//! fold the per-word state through a `Scorer` — idiomatic Rust, no manual UTF-8
|
||||
//! decoding and no ref-capturing lambdas.
|
||||
|
||||
use crate::module::{ModResult, Module};
|
||||
use crate::server::Server;
|
||||
use crate::xline::XKind;
|
||||
use crate::Uid;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Script {
|
||||
Other = 0, // digits, punctuation, symbols — ignored for mixing
|
||||
Latin,
|
||||
Cyrillic,
|
||||
Greek,
|
||||
Armenian,
|
||||
Hebrew,
|
||||
Arabic,
|
||||
Cjk,
|
||||
}
|
||||
|
||||
/// Map a codepoint to a script; `Other` for anything that isn't a letter we track.
|
||||
fn classify_script(cp: u32) -> Script {
|
||||
match cp {
|
||||
0x41..=0x5A | 0x61..=0x7A => Script::Latin, // ASCII A-Z a-z
|
||||
0x00C0..=0x024F => Script::Latin, // Latin-1 suppl + extended
|
||||
0x0370..=0x03FF => Script::Greek,
|
||||
0x0400..=0x04FF => Script::Cyrillic,
|
||||
0x0530..=0x058F => Script::Armenian,
|
||||
0x0590..=0x05FF => Script::Hebrew,
|
||||
0x0600..=0x06FF => Script::Arabic,
|
||||
0x4E00..=0x9FFF => Script::Cjk, // CJK unified
|
||||
0x3040..=0x30FF => Script::Cjk, // hiragana / katakana
|
||||
_ => Script::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// A non-Latin letter that LOOKS like an ASCII Latin letter (the homoglyphs
|
||||
/// spammers swap in). Catches pure-homoglyph words that script-mixing misses,
|
||||
/// without tripping on genuine monolingual text.
|
||||
fn is_latin_confusable(cp: u32) -> bool {
|
||||
matches!(
|
||||
cp,
|
||||
// Cyrillic look-alikes
|
||||
0x0430 | 0x0410 | 0x0435 | 0x0415 | 0x043E | 0x041E | 0x0440 | 0x0420 |
|
||||
0x0441 | 0x0421 | 0x0443 | 0x0423 | 0x0445 | 0x0425 | 0x0456 | 0x0406 |
|
||||
0x0455 | 0x0405 | 0x0458 | 0x0408 | 0x043A | 0x041A | 0x043C | 0x041C |
|
||||
0x043D | 0x041D | 0x0432 | 0x0412 | 0x0442 | 0x0422 |
|
||||
// Greek look-alikes
|
||||
0x03BF | 0x039F | 0x03B1 | 0x0391 | 0x03B5 | 0x0395 | 0x03C1 | 0x03A1 |
|
||||
0x03C5 | 0x03A5 | 0x03BD | 0x03BA | 0x039A | 0x03B9 | 0x0399 | 0x03BC |
|
||||
0x0392 | 0x039D | 0x03A4 | 0x0397 | 0x03A7 | 0x0396
|
||||
)
|
||||
}
|
||||
|
||||
/// "Fancy" Latin: fullwidth, mathematical alphanumerics, enclosed/circled letters.
|
||||
/// These render as styled ASCII ("𝐅𝐫𝐞𝐞", "Free", "🅵🆁🅴🅴") — pure obfuscation.
|
||||
fn is_fancy_latin(cp: u32) -> bool {
|
||||
matches!(
|
||||
cp,
|
||||
0xFF21..=0xFF5A // fullwidth A-Z a-z
|
||||
| 0x1D400..=0x1D7FF // mathematical alphanumeric symbols
|
||||
| 0x1F130..=0x1F189 // squared/enclosed latin
|
||||
| 0x24B6..=0x24E9 // circled latin
|
||||
| 0x2460..=0x24FF // enclosed alphanumerics (loose)
|
||||
)
|
||||
}
|
||||
|
||||
/// Invisible / zero-width characters used to split words and evade filters.
|
||||
fn is_invisible(cp: u32) -> bool {
|
||||
matches!(
|
||||
cp,
|
||||
0x00AD | 0x200B | 0x200C | 0x200D | 0x2060 | 0xFEFF | 0x180E
|
||||
)
|
||||
}
|
||||
|
||||
/// Per-message tally, folded word by word: the per-word scoring state as a struct.
|
||||
#[derive(Default)]
|
||||
struct Scorer {
|
||||
mixedwords: u32, // words mixing >1 real script
|
||||
homoglyphwords: u32, // ASCII + confusable letters in one word
|
||||
purehomowords: u32, // word made (almost) entirely of confusables
|
||||
fancywords: u32, // words containing fancy/styled latin
|
||||
invisibles: u32, // zero-width chars anywhere
|
||||
totalletters: u32,
|
||||
latinletters: u32,
|
||||
wordhas: [bool; 8],
|
||||
word_has_ascii: bool,
|
||||
word_has_confusable: bool,
|
||||
word_has_fancy: bool,
|
||||
word_letters: u32,
|
||||
word_confusables: u32,
|
||||
}
|
||||
|
||||
impl Scorer {
|
||||
fn word_scripts(&self) -> u32 {
|
||||
(1..8).filter(|&s| self.wordhas[s]).count() as u32
|
||||
}
|
||||
|
||||
fn reset_word(&mut self) {
|
||||
self.wordhas = [false; 8];
|
||||
self.word_has_ascii = false;
|
||||
self.word_has_confusable = false;
|
||||
self.word_has_fancy = false;
|
||||
self.word_letters = 0;
|
||||
self.word_confusables = 0;
|
||||
}
|
||||
|
||||
fn end_word(&mut self) {
|
||||
// Confusable mixed WITH real ASCII in one word = the classic "swap a few
|
||||
// letters" attack (already script-mixing) — count it ONCE here so a single
|
||||
// stray homoglyph doesn't double-score.
|
||||
if self.word_has_confusable && self.word_has_ascii {
|
||||
self.homoglyphwords += 1;
|
||||
} else if self.word_scripts() >= 2 {
|
||||
self.mixedwords += 1;
|
||||
} else if !self.word_has_ascii
|
||||
&& self.word_scripts() == 1
|
||||
&& self.word_letters >= 4
|
||||
&& self.word_confusables * 100 / self.word_letters >= 80
|
||||
{
|
||||
// single-script word with no ASCII, ≥80% confusables = Latin in disguise
|
||||
self.purehomowords += 1;
|
||||
}
|
||||
if self.word_has_fancy {
|
||||
self.fancywords += 1;
|
||||
}
|
||||
self.reset_word();
|
||||
}
|
||||
}
|
||||
|
||||
/// Score a message for look-alike / obfuscated-text spam. Higher = worse; genuine
|
||||
/// monolingual text (any script) stays at 0.
|
||||
fn score_message(text: &str) -> u32 {
|
||||
let mut sc = Scorer::default();
|
||||
for cp in text.chars().map(|c| c as u32) {
|
||||
if is_invisible(cp) {
|
||||
sc.invisibles += 1;
|
||||
continue; // not a word boundary
|
||||
}
|
||||
let fancy = is_fancy_latin(cp);
|
||||
let confusable = is_latin_confusable(cp);
|
||||
let script = classify_script(cp);
|
||||
let isletter = script != Script::Other || fancy;
|
||||
let isboundary = matches!(cp, 0x20 | 0x09 | 0x2C | 0x2E | 0x21 | 0x3F | 0xFFFD);
|
||||
|
||||
if isletter {
|
||||
if script != Script::Other {
|
||||
sc.wordhas[script as usize] = true;
|
||||
}
|
||||
sc.totalletters += 1;
|
||||
sc.word_letters += 1;
|
||||
if script == Script::Latin {
|
||||
sc.latinletters += 1;
|
||||
sc.word_has_ascii = true;
|
||||
}
|
||||
if confusable {
|
||||
sc.word_has_confusable = true;
|
||||
sc.word_confusables += 1;
|
||||
}
|
||||
if fancy {
|
||||
sc.word_has_fancy = true;
|
||||
}
|
||||
}
|
||||
if isboundary {
|
||||
sc.end_word();
|
||||
}
|
||||
}
|
||||
sc.end_word(); // final word
|
||||
|
||||
// One disguised word is usually an accident (a pasted Cyrillic letter); real
|
||||
// attacks disguise MANY. Grant a 1-word grace.
|
||||
let disguised = sc.homoglyphwords + sc.mixedwords + sc.purehomowords + sc.fancywords;
|
||||
let effective = disguised.saturating_sub(1);
|
||||
|
||||
let mut score = 0u32;
|
||||
score += effective * 5; // each disguised word past the first
|
||||
score += sc.fancywords; // styled unicode is rarely innocent
|
||||
score += sc.invisibles * 3; // zero-width evasion is always suspicious
|
||||
|
||||
// Ratio bonus: only with real disguise (≥2 words) and non-Latin dominance.
|
||||
if sc.totalletters >= 8 && disguised >= 2 {
|
||||
let nonlatin = sc.totalletters - sc.latinletters;
|
||||
if nonlatin > 0 && sc.latinletters > 0 && nonlatin * 100 / sc.totalletters >= 40 {
|
||||
score += 3;
|
||||
}
|
||||
}
|
||||
score
|
||||
}
|
||||
|
||||
/// If `text` is a CTCP, return the ACTION body to check, else `None` to skip
|
||||
/// (non-ACTION CTCPs aren't scanned). Plain messages return the text unchanged.
|
||||
fn checkable(text: &str) -> Option<&str> {
|
||||
let Some(inner) = text.strip_prefix('\u{01}') else {
|
||||
return Some(text);
|
||||
};
|
||||
let inner = inner.strip_suffix('\u{01}').unwrap_or(inner);
|
||||
let (name, body) = inner.split_once(' ').unwrap_or((inner, ""));
|
||||
if name.eq_ignore_ascii_case("ACTION") {
|
||||
Some(body)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// A single-line, length-capped snippet of a blocked message for the oper
|
||||
/// snotice. Keeps the look-alike glyphs visible (that's the point) but neutralises
|
||||
/// every control byte (CR/LF, mIRC formatting) so it can't inject into or break
|
||||
/// the protocol line the snotice is embedded in.
|
||||
fn snippet(text: &str) -> String {
|
||||
const MAX: usize = 120;
|
||||
let mut out = String::new();
|
||||
for (i, ch) in text.chars().enumerate() {
|
||||
if i >= MAX {
|
||||
out.push('…');
|
||||
break;
|
||||
}
|
||||
if (ch as u32) < 0x20 || ch == '\u{7f}' {
|
||||
out.push(' ');
|
||||
} else {
|
||||
out.push(ch);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub struct AntiMixedUtf8;
|
||||
|
||||
impl Module for AntiMixedUtf8 {
|
||||
fn name(&self) -> &'static str {
|
||||
"antimixedutf8"
|
||||
}
|
||||
|
||||
fn on_pre_message(
|
||||
&mut self,
|
||||
srv: &mut Server,
|
||||
uid: Uid,
|
||||
target: &str,
|
||||
text: &str,
|
||||
) -> ModResult {
|
||||
if !srv.amu.enable {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
// exempt opers and users logged into an account
|
||||
if srv.is_oper(uid) || srv.is_logged_in(uid) {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
let is_channel = target.starts_with('#');
|
||||
if (is_channel && !srv.amu.check_channel) || (!is_channel && !srv.amu.check_private) {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
let Some(body) = checkable(text) else {
|
||||
return ModResult::Passthru;
|
||||
};
|
||||
if body.chars().count() < srv.amu.minlen {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
let score = score_message(body);
|
||||
if score < srv.amu.threshold {
|
||||
return ModResult::Passthru;
|
||||
}
|
||||
|
||||
let (nick, mask, host, ip) = {
|
||||
let Some(u) = srv.users.get(&uid) else {
|
||||
return ModResult::Passthru;
|
||||
};
|
||||
(
|
||||
u.nick.clone(),
|
||||
u.prefix(),
|
||||
u.host.clone(),
|
||||
u.addr.ip().to_string(),
|
||||
)
|
||||
};
|
||||
// Show opers WHAT was blocked (a sanitized snippet) so they can judge the
|
||||
// catch and spot false positives — the whole point of an antispam log.
|
||||
srv.snotice(&format!(
|
||||
"ANTIMIXEDUTF8: blocked spam from {mask} to {target} (score {score} >= {}): {}",
|
||||
srv.amu.threshold,
|
||||
snippet(body)
|
||||
));
|
||||
|
||||
// Always tell the sender their message was blocked and that opers were
|
||||
// told — even for punitive actions, since the writer flushes queued lines
|
||||
// before a disconnect.
|
||||
srv.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :*** {} (Flagged by the spam filter; network operators have been notified.)",
|
||||
srv.name, srv.amu.block_msg
|
||||
),
|
||||
);
|
||||
|
||||
let action = srv.amu.action.to_ascii_lowercase();
|
||||
let (dur, reason, setter) = (
|
||||
srv.amu.duration,
|
||||
srv.amu.reason.clone(),
|
||||
format!("antimixedutf8@{}", srv.name),
|
||||
);
|
||||
match action.as_str() {
|
||||
"gline" => srv.add_xline(XKind::Gline, &format!("*@{host}"), dur, &setter, &reason),
|
||||
"kline" => srv.add_xline(XKind::Kline, &format!("*@{host}"), dur, &setter, &reason),
|
||||
"zline" => srv.add_xline(XKind::Zline, &ip, dur, &setter, &reason),
|
||||
"kill" => srv.remove_user(uid, &reason),
|
||||
// "block": also emit the standard channel-failure numeric so clients
|
||||
// render the drop inline; the explanatory NOTICE above covers the rest.
|
||||
_ if is_channel => srv.numeric(
|
||||
uid,
|
||||
crate::numeric::ERR_CANNOTSENDTOCHAN,
|
||||
&format!("{target} :Message blocked by the spam filter"),
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
ModResult::Deny
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn genuine_monolingual_text_scores_zero() {
|
||||
assert_eq!(score_message("hello everyone how are you today"), 0); // Latin
|
||||
assert_eq!(score_message("привет всем как у вас дела сегодня"), 0); // Russian
|
||||
assert_eq!(score_message("γεια σας πως ειστε ολοι σημερα εδω"), 0); // Greek
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_script_spam_scores_high() {
|
||||
// Cyrillic look-alikes swapped into Latin words (multi-word disguise)
|
||||
assert!(score_message("Ѕесurіtу аlеrt сlісk hеrе nоw рlеаѕе") >= 8);
|
||||
// fancy/fullwidth styled word run
|
||||
assert!(score_message("Free V1agra now click here") >= 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_stray_homoglyph_is_tolerated() {
|
||||
// a single disguised word gets the 1-word grace → stays under threshold
|
||||
assert!(score_message("hello wоrld this is a normal message") < 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_evasion_scores() {
|
||||
// three zero-width joiners = 3*3 = 9
|
||||
assert!(score_message("buy\u{200b}now\u{200b}cheap\u{200b}deal") >= 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_is_one_clean_line_and_capped() {
|
||||
// CR/LF and mIRC control bytes are neutralised (no protocol injection)
|
||||
assert_eq!(snippet("hi\r\nthere"), "hi there");
|
||||
assert!(!snippet("x\u{03}04red").contains('\u{03}'));
|
||||
// look-alike glyphs survive so opers can see what was caught
|
||||
assert!(snippet("Ѕесurіtу").contains('Ѕ'));
|
||||
// long input is capped with an ellipsis
|
||||
let s = snippet(&"a".repeat(200));
|
||||
assert!(s.ends_with('…') && s.chars().count() == 121);
|
||||
}
|
||||
}
|
||||
168
src/modules/cloak.rs
Normal file
168
src/modules/cloak.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
//! cloak — echoIRCd's host-masking module (InspIRCd's `m_cloak_*`, our way).
|
||||
//!
|
||||
//! Every user gets a deterministic, keyed **cloak** of their host that hides the
|
||||
//! real IP while *preserving subnet structure*, so a channel ban on a whole /24
|
||||
//! or /16 still bites. The cloak is shown under user mode **+x**, which this
|
||||
//! module auto-sets on connect; only opers may drop it (see [`crate::mode`]),
|
||||
//! which stops +x from becoming a ban-evasion switch.
|
||||
//!
|
||||
//! Format follows InspIRCd's `SegmentIP`: one hashed segment per cumulative IP
|
||||
//! octet-prefix, most-specific on the left, ending in the literal `.IP` suffix
|
||||
//! that marks a cloaked address (as opposed to a cloaked hostname, which keeps
|
||||
//! its domain). For `a.b.c.d`:
|
||||
//!
|
||||
//! ```text
|
||||
//! HASH(a.b.c.d) . HASH(a.b.c) . HASH(a.b) . HASH(a) . IP
|
||||
//! (/32) (/24) (/16) (/8)
|
||||
//! ```
|
||||
//!
|
||||
//! so two IPs in the same /24 share the `…/24./16./8.IP` tail (same /16 shares
|
||||
//! `…/16./8.IP`), and the exact address never leaks. Where this improves on the
|
||||
//! C++ original: the hash is **SHA-256** (via the `openssl` we already link for
|
||||
//! TLS) instead of MD5, it needs no separate hashing module, and the whole path
|
||||
//! stays `#![forbid(unsafe_code)]`. The key lives in the config (`cloak_key = …`);
|
||||
//! with no key set, cloaking is simply off and +x is a no-op.
|
||||
|
||||
use openssl::sha::sha256;
|
||||
|
||||
use crate::module::Module;
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
/// The suffix marking a cloaked IP address (InspIRCd's default is `.IP` too).
|
||||
const IP_SUFFIX: &str = ".IP";
|
||||
|
||||
pub struct Cloak;
|
||||
|
||||
impl Module for Cloak {
|
||||
fn name(&self) -> &'static str {
|
||||
"cloak"
|
||||
}
|
||||
|
||||
/// Compute the cloak once, at connect, and cloak the user by default (+x).
|
||||
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {
|
||||
let Some(key) = srv.cloak_key.clone() else {
|
||||
return; // no cloak key configured -> cloaking disabled
|
||||
};
|
||||
let Some(host) = srv.users.get(&uid).map(|u| u.host.clone()) else {
|
||||
return;
|
||||
};
|
||||
let cloak = cloak_host(&key, &host);
|
||||
if let Some(u) = srv.users.get_mut(&uid) {
|
||||
u.cloak = cloak;
|
||||
u.flags.cloak = true; // cloaked by default; -x is oper-only
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One cloak label: the first `n` hex chars of `SHA-256(key ‖ NUL ‖ data)`.
|
||||
fn label(key: &str, data: &str, n: usize) -> String {
|
||||
let digest = sha256(format!("{key}\u{0}{data}").as_bytes());
|
||||
let mut s = String::with_capacity(n + 1);
|
||||
for b in &digest {
|
||||
s.push_str(&format!("{b:02x}"));
|
||||
if s.len() >= n {
|
||||
break;
|
||||
}
|
||||
}
|
||||
s.truncate(n);
|
||||
s
|
||||
}
|
||||
|
||||
/// Parse `"a.b.c.d"` into four octets, or `None` if it isn't a dotted IPv4.
|
||||
fn parse_v4(host: &str) -> Option<(u8, u8, u8, u8)> {
|
||||
let mut it = host.split('.');
|
||||
let a = it.next()?.parse().ok()?;
|
||||
let b = it.next()?.parse().ok()?;
|
||||
let c = it.next()?.parse().ok()?;
|
||||
let d = it.next()?.parse().ok()?;
|
||||
if it.next().is_some() {
|
||||
return None;
|
||||
}
|
||||
Some((a, b, c, d))
|
||||
}
|
||||
|
||||
/// Compute a user's cloak from their real host.
|
||||
///
|
||||
/// - IPv4 `a.b.c.d` → `H(a.b.c.d).H(a.b.c).H(a.b).H(a).IP` — one keyed segment per
|
||||
/// octet-prefix tier (/32 · /24 · /16 · /8), so subnet bans keep working while
|
||||
/// the exact address never appears.
|
||||
/// - IPv6 → `ALPHA.BETA.GAMMA.IP` (mirrors InspIRCd), coarsened by hextet groups.
|
||||
/// - hostname → keep the last two labels (the domain), mask everything to the left
|
||||
/// (no `.IP` — a resolved name isn't a raw address).
|
||||
pub fn cloak_host(key: &str, host: &str) -> String {
|
||||
if let Some((a, b, c, d)) = parse_v4(host) {
|
||||
let h32 = label(key, &format!("{a}.{b}.{c}.{d}"), 6);
|
||||
let h24 = label(key, &format!("{a}.{b}.{c}"), 5);
|
||||
let h16 = label(key, &format!("{a}.{b}"), 4);
|
||||
let h8 = label(key, &format!("{a}"), 4);
|
||||
format!("{h32}.{h24}.{h16}.{h8}{IP_SUFFIX}")
|
||||
} else if host.contains(':') {
|
||||
let groups: Vec<&str> = host.split(':').filter(|g| !g.is_empty()).collect();
|
||||
let mid = groups.iter().take(4).copied().collect::<Vec<_>>().join(":");
|
||||
let wide = groups.iter().take(2).copied().collect::<Vec<_>>().join(":");
|
||||
let alpha = label(key, host, 6);
|
||||
let beta = label(key, &mid, 5);
|
||||
let gamma = label(key, &wide, 4);
|
||||
format!("{alpha}.{beta}.{gamma}{IP_SUFFIX}")
|
||||
} else {
|
||||
let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect();
|
||||
if parts.len() >= 3 {
|
||||
let suffix = parts[parts.len() - 2..].join(".");
|
||||
format!("{}.{suffix}", label(key, host, 8))
|
||||
} else {
|
||||
format!("{}.cloak", label(key, host, 8))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn v4_cloak_is_deterministic_and_hides_the_ip() {
|
||||
let c = cloak_host("secret", "203.0.113.7");
|
||||
assert_eq!(c, cloak_host("secret", "203.0.113.7")); // stable
|
||||
assert!(!c.contains("203.0.113")); // the dotted IP never appears
|
||||
assert!(c.ends_with(".IP")); // InspIRCd-style IP suffix
|
||||
assert_eq!(c.split('.').count(), 5); // H32.H24.H16.H8.IP
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_subnet_shares_a_suffix_but_host_differs() {
|
||||
let a = cloak_host("secret", "203.0.113.7");
|
||||
let b = cloak_host("secret", "203.0.113.9"); // same /24
|
||||
let e = cloak_host("secret", "8.8.8.8"); // different net
|
||||
let tail = |s: &str| s.split_once('.').unwrap().1.to_string();
|
||||
assert_eq!(tail(&a), tail(&b)); // /24 ban still matches both
|
||||
assert_ne!(a, b); // but the exact host label differs
|
||||
assert_ne!(tail(&a), tail(&e)); // unrelated net -> unrelated tail
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wider_ban_matches_the_whole_16() {
|
||||
// two different /24s inside the same /16 share only the /16./8.IP tail
|
||||
let a = cloak_host("secret", "203.0.113.7");
|
||||
let b = cloak_host("secret", "203.0.200.4");
|
||||
let net16_tail = |s: &str| s.splitn(3, '.').nth(2).unwrap().to_string();
|
||||
assert_eq!(net16_tail(&a), net16_tail(&b)); // H16.H8.IP shared
|
||||
assert_ne!(a.split_once('.').unwrap().1, b.split_once('.').unwrap().1); // /24 differs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_key_changes_the_cloak() {
|
||||
assert_ne!(
|
||||
cloak_host("key-one", "203.0.113.7"),
|
||||
cloak_host("key-two", "203.0.113.7"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostname_keeps_its_domain_and_has_no_ip_suffix() {
|
||||
let c = cloak_host("secret", "host.dyn.example.com");
|
||||
assert!(c.ends_with(".example.com"));
|
||||
assert!(!c.ends_with(".IP"));
|
||||
assert!(!c.starts_with("host"));
|
||||
}
|
||||
}
|
||||
71
src/modules/flood.rs
Normal file
71
src/modules/flood.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
//! Flood protection — a module that rate-limits messages.
|
||||
//!
|
||||
//! It keeps each user's recent message times in that user's typed
|
||||
//! [`crate::extensible::Extensible`] slot. Because the state is *owned by the
|
||||
//! `User`*, it vanishes the moment the user quits — no cleanup callback, no cull
|
||||
//! list, no chance of a dangling reference (the C++ InspIRCd failure mode this
|
||||
//! design rules out at compile time).
|
||||
|
||||
use crate::module::{ModResult, Module};
|
||||
use crate::server::{now, Server};
|
||||
use crate::Uid;
|
||||
|
||||
const FLOOD_MAX: usize = 8; // messages allowed…
|
||||
const FLOOD_WINDOW: u64 = 4; // …within this many seconds
|
||||
|
||||
#[derive(Default)]
|
||||
struct FloodState {
|
||||
times: Vec<u64>,
|
||||
warned: bool,
|
||||
}
|
||||
|
||||
pub struct Flood;
|
||||
|
||||
impl Module for Flood {
|
||||
fn name(&self) -> &'static str {
|
||||
"flood"
|
||||
}
|
||||
|
||||
fn on_pre_message(
|
||||
&mut self,
|
||||
srv: &mut Server,
|
||||
uid: Uid,
|
||||
_target: &str,
|
||||
_text: &str,
|
||||
) -> ModResult {
|
||||
let now = now();
|
||||
let (over, warn) = {
|
||||
let Some(u) = srv.users.get_mut(&uid) else {
|
||||
return ModResult::Passthru;
|
||||
};
|
||||
if u.flags.oper {
|
||||
return ModResult::Passthru; // opers bypass flood limits
|
||||
}
|
||||
let st = u.ext.get_or_insert_with(FloodState::default);
|
||||
st.times.retain(|&t| now.saturating_sub(t) < FLOOD_WINDOW);
|
||||
st.times.push(now);
|
||||
let over = st.times.len() > FLOOD_MAX;
|
||||
let warn = over && !st.warned; // notice once per burst
|
||||
st.warned = over;
|
||||
(over, warn)
|
||||
};
|
||||
if over {
|
||||
if warn {
|
||||
let nick = srv
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
srv.send(
|
||||
uid,
|
||||
format!(
|
||||
":{} NOTICE {nick} :*** Flood detected — slow down",
|
||||
srv.name
|
||||
),
|
||||
);
|
||||
}
|
||||
return ModResult::Deny;
|
||||
}
|
||||
ModResult::Passthru
|
||||
}
|
||||
}
|
||||
19
src/modules/mod.rs
Normal file
19
src/modules/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
//! Optional, pluggable modules — echoIRCd's answer to InspIRCd's `src/modules/`.
|
||||
//! Each hooks lifecycle events via the [`crate::module::Module`] trait.
|
||||
|
||||
pub mod antimixedutf8;
|
||||
pub mod cloak;
|
||||
pub mod flood;
|
||||
pub mod snoop;
|
||||
|
||||
use crate::module::Module;
|
||||
|
||||
/// The modules loaded at boot. (Later: load by name from the config.)
|
||||
pub fn default_modules() -> Vec<Box<dyn Module>> {
|
||||
vec![
|
||||
Box::new(snoop::Snoop),
|
||||
Box::new(flood::Flood),
|
||||
Box::new(cloak::Cloak),
|
||||
Box::new(antimixedutf8::AntiMixedUtf8),
|
||||
]
|
||||
}
|
||||
36
src/modules/snoop.rs
Normal file
36
src/modules/snoop.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
//! A tiny example module: log connects, joins and quits to stderr. It exercises
|
||||
//! the hook wiring end-to-end and is the template for real modules.
|
||||
|
||||
use crate::module::Module;
|
||||
use crate::server::Server;
|
||||
use crate::Uid;
|
||||
|
||||
pub struct Snoop;
|
||||
|
||||
impl Module for Snoop {
|
||||
fn name(&self) -> &'static str {
|
||||
"snoop"
|
||||
}
|
||||
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {
|
||||
let info = srv
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| (u.nick.clone(), u.ident.clone(), u.host.clone()));
|
||||
if let Some((nick, ident, host)) = info {
|
||||
eprintln!("[snoop] connect {nick} ({ident}@{host})");
|
||||
srv.snotice(&format!("Client connecting: {nick} ({ident}@{host})"));
|
||||
}
|
||||
}
|
||||
fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {
|
||||
if let Some(u) = srv.users.get(&uid) {
|
||||
eprintln!("[snoop] {} joined {chan}", u.nick);
|
||||
}
|
||||
}
|
||||
fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) {
|
||||
let nick = srv.users.get(&uid).map(|u| u.nick.clone());
|
||||
eprintln!("[snoop] quit uid={uid} ({reason})");
|
||||
if let Some(nick) = nick {
|
||||
srv.snotice(&format!("Client exiting: {nick} ({reason})"));
|
||||
}
|
||||
}
|
||||
}
|
||||
145
src/numeric.rs
Normal file
145
src/numeric.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
//! IRC numeric replies (RFC 1459/2812 subset) used by the core.
|
||||
|
||||
pub const RPL_MAP: u16 = 6;
|
||||
pub const RPL_MAPEND: u16 = 7;
|
||||
pub const RPL_STATSCOMMANDS: u16 = 212;
|
||||
pub const RPL_ENDOFSTATS: u16 = 219;
|
||||
pub const RPL_STATSUPTIME: u16 = 242;
|
||||
pub const RPL_STATSOLINE: u16 = 243;
|
||||
pub const RPL_STATSXLINE: u16 = 223; // K/G/Z-line listing
|
||||
pub const RPL_ADMINME: u16 = 256;
|
||||
pub const RPL_ADMINLOC1: u16 = 257;
|
||||
pub const RPL_ADMINLOC2: u16 = 258;
|
||||
pub const RPL_ADMINEMAIL: u16 = 259;
|
||||
pub const RPL_USERHOST: u16 = 302;
|
||||
pub const RPL_ISON: u16 = 303;
|
||||
pub const RPL_WHOISIDLE: u16 = 317;
|
||||
pub const RPL_LISTSTART: u16 = 321;
|
||||
pub const RPL_LIST: u16 = 322;
|
||||
pub const RPL_LISTEND: u16 = 323;
|
||||
pub const RPL_WHOWASUSER: u16 = 314;
|
||||
pub const RPL_ENDOFWHOWAS: u16 = 369;
|
||||
pub const RPL_INFO: u16 = 371;
|
||||
pub const RPL_ENDOFINFO: u16 = 374;
|
||||
pub const RPL_REHASHING: u16 = 382;
|
||||
pub const RPL_TIME: u16 = 391;
|
||||
pub const ERR_NOSUCHSERVER: u16 = 402;
|
||||
pub const ERR_WASNOSUCHNICK: u16 = 406;
|
||||
pub const ERR_UNAVAILRESOURCE: u16 = 437; // channel temporarily unavailable (+j)
|
||||
pub const ERR_LINKCHANNEL: u16 = 470; // +L — you were redirected to another channel
|
||||
pub const RPL_ENDOFSPAMFILTER: u16 = 940; // end of the +g word-filter list
|
||||
pub const RPL_SPAMFILTER: u16 = 941; // one +g word-filter entry
|
||||
pub const RPL_KNOCK: u16 = 710; // channel gets the knock
|
||||
pub const RPL_KNOCKDLVR: u16 = 711; // knocker's ack
|
||||
|
||||
// SILENCE (server-side ignore list)
|
||||
pub const RPL_SILELIST: u16 = 271;
|
||||
pub const RPL_ENDOFSILENCE: u16 = 272;
|
||||
pub const ERR_SILELISTFULL: u16 = 511;
|
||||
|
||||
// WATCH (notify list)
|
||||
pub const RPL_LOGON: u16 = 600;
|
||||
pub const RPL_LOGOFF: u16 = 601;
|
||||
pub const RPL_WATCHOFF: u16 = 602;
|
||||
pub const RPL_WATCHSTAT: u16 = 603;
|
||||
pub const RPL_NOWON: u16 = 604;
|
||||
pub const RPL_NOWOFF: u16 = 605;
|
||||
pub const RPL_WATCHLIST: u16 = 606;
|
||||
pub const RPL_ENDOFWATCHLIST: u16 = 607;
|
||||
pub const ERR_TOOMANYWATCH: u16 = 512;
|
||||
|
||||
// MONITOR (IRCv3 notify list)
|
||||
pub const RPL_MONONLINE: u16 = 730;
|
||||
pub const RPL_MONOFFLINE: u16 = 731;
|
||||
pub const RPL_MONLIST: u16 = 732;
|
||||
pub const RPL_ENDOFMONLIST: u16 = 733;
|
||||
pub const ERR_MONLISTFULL: u16 = 734;
|
||||
|
||||
pub const RPL_WELCOME: u16 = 1;
|
||||
pub const RPL_YOURHOST: u16 = 2;
|
||||
pub const RPL_CREATED: u16 = 3;
|
||||
pub const RPL_MYINFO: u16 = 4;
|
||||
pub const RPL_ISUPPORT: u16 = 5;
|
||||
|
||||
pub const RPL_UMODEIS: u16 = 221;
|
||||
pub const RPL_YOUREOPER: u16 = 381;
|
||||
pub const ERR_PASSWDMISMATCH: u16 = 464;
|
||||
pub const ERR_NOPRIVILEGES: u16 = 481;
|
||||
pub const ERR_UMODEUNKNOWNFLAG: u16 = 501;
|
||||
pub const RPL_LUSERCLIENT: u16 = 251;
|
||||
|
||||
pub const RPL_WHOISUSER: u16 = 311;
|
||||
pub const RPL_WHOISSERVER: u16 = 312;
|
||||
pub const RPL_ENDOFWHO: u16 = 315;
|
||||
pub const RPL_WHOISCHANNELS: u16 = 319;
|
||||
pub const RPL_ENDOFWHOIS: u16 = 318;
|
||||
pub const RPL_WHOISOPERATOR: u16 = 313; // "is an IRC operator" (hidden by +H)
|
||||
pub const RPL_WHOISBOT: u16 = 335; // "is a bot" (umode +B)
|
||||
pub const RPL_WHOISACCOUNT: u16 = 330; // "<nick> <account> :is logged in as"
|
||||
pub const ERR_NEEDREGGEDNICK: u16 = 477; // chan +R/+M — must be logged into an account
|
||||
pub const RPL_WHOISHOST: u16 = 378; // oper-only: real host/ip behind a cloak
|
||||
pub const RPL_WHOISSECURE: u16 = 671; // "is using a secure connection" (sslinfo)
|
||||
pub const RPL_HOSTHIDDEN: u16 = 396; // "is now your displayed host" (cloak on/off)
|
||||
|
||||
pub const RPL_CHANNELMODEIS: u16 = 324;
|
||||
pub const RPL_CREATIONTIME: u16 = 329;
|
||||
pub const RPL_NOTOPIC: u16 = 331;
|
||||
pub const RPL_TOPIC: u16 = 332;
|
||||
pub const RPL_WHOREPLY: u16 = 352;
|
||||
pub const RPL_NAMREPLY: u16 = 353;
|
||||
pub const RPL_ENDOFNAMES: u16 = 366;
|
||||
|
||||
pub const RPL_MOTD: u16 = 372;
|
||||
pub const RPL_MOTDSTART: u16 = 375;
|
||||
pub const RPL_ENDOFMOTD: u16 = 376;
|
||||
pub const RPL_LINKS: u16 = 364;
|
||||
pub const RPL_ENDOFLINKS: u16 = 365;
|
||||
|
||||
pub const ERR_NOSUCHNICK: u16 = 401;
|
||||
pub const ERR_NOSUCHCHANNEL: u16 = 403;
|
||||
pub const ERR_CANNOTSENDTOCHAN: u16 = 404;
|
||||
pub const ERR_NORECIPIENT: u16 = 411;
|
||||
pub const ERR_NOTEXTTOSEND: u16 = 412;
|
||||
pub const ERR_UNKNOWNCOMMAND: u16 = 421;
|
||||
pub const ERR_NOMOTD: u16 = 422;
|
||||
pub const ERR_NONICKNAMEGIVEN: u16 = 431;
|
||||
pub const ERR_ERRONEUSNICKNAME: u16 = 432;
|
||||
pub const ERR_NICKNAMEINUSE: u16 = 433;
|
||||
pub const ERR_USERNOTINCHANNEL: u16 = 441;
|
||||
pub const ERR_NOTONCHANNEL: u16 = 442;
|
||||
pub const ERR_CHANNELISFULL: u16 = 471;
|
||||
pub const ERR_UNKNOWNMODE: u16 = 472;
|
||||
pub const ERR_INVITEONLYCHAN: u16 = 473;
|
||||
pub const ERR_BADCHANNELKEY: u16 = 475;
|
||||
pub const ERR_CHANOPRIVSNEEDED: u16 = 482;
|
||||
pub const ERR_SECUREONLYCHAN: u16 = 489; // can't join a +z channel without TLS
|
||||
pub const ERR_ALLMUSTSSL: u16 = 490; // can't set +z while a member isn't on TLS
|
||||
pub const ERR_NOTREGISTERED: u16 = 451;
|
||||
pub const ERR_NEEDMOREPARAMS: u16 = 461;
|
||||
pub const ERR_ALREADYREGISTERED: u16 = 462;
|
||||
pub const ERR_USERSDONTMATCH: u16 = 502;
|
||||
|
||||
pub const RPL_AWAY: u16 = 301;
|
||||
pub const RPL_UNAWAY: u16 = 305;
|
||||
pub const RPL_NOWAWAY: u16 = 306;
|
||||
pub const RPL_INVITING: u16 = 341;
|
||||
pub const RPL_BANLIST: u16 = 367;
|
||||
pub const RPL_ENDOFBANLIST: u16 = 368;
|
||||
pub const RPL_INVEXLIST: u16 = 346;
|
||||
pub const RPL_ENDOFINVEXLIST: u16 = 347;
|
||||
pub const RPL_EXCEPTLIST: u16 = 348;
|
||||
pub const RPL_ENDOFEXCEPTLIST: u16 = 349;
|
||||
pub const ERR_CANTCHANGENICK: u16 = 447; // +N — no nick change on channel
|
||||
pub const ERR_CANTJOINOPERSONLY: u16 = 520; // +O — IRC operators only
|
||||
pub const ERR_USERONCHANNEL: u16 = 443;
|
||||
pub const ERR_BANNEDFROMCHAN: u16 = 474;
|
||||
|
||||
// SASL (IRCv3)
|
||||
pub const RPL_LOGGEDIN: u16 = 900;
|
||||
pub const RPL_LOGGEDOUT: u16 = 901;
|
||||
pub const ERR_NICKLOCKED: u16 = 902;
|
||||
pub const RPL_SASLSUCCESS: u16 = 903;
|
||||
pub const ERR_SASLFAIL: u16 = 904;
|
||||
pub const ERR_SASLTOOLONG: u16 = 905;
|
||||
pub const ERR_SASLABORTED: u16 = 906;
|
||||
pub const RPL_SASLMECHS: u16 = 908;
|
||||
645
src/server.rs
Normal file
645
src/server.rs
Normal file
|
|
@ -0,0 +1,645 @@
|
|||
//! The engine core: the `Server` struct that owns all state, the output
|
||||
//! primitives (send / numeric / to_channel) and the connection lifecycle.
|
||||
//! Per-subsystem behaviour lives beside its data — [`crate::users`] and
|
||||
//! [`crate::channels`] add their own `impl Server` blocks, the way InspIRCd
|
||||
//! keeps usermanager / channelmanager separate from the core. No locks: only the
|
||||
//! single core thread ever holds a `Server`.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::channels::Channel;
|
||||
use crate::config::{Config, LinkBlock};
|
||||
use crate::extensible::Extensible;
|
||||
use crate::link::{Link, RemoteServer, RemoteUser};
|
||||
use crate::module::Hook;
|
||||
use crate::users::{Caps, User, UserFlags};
|
||||
use crate::xline::XLine;
|
||||
use crate::Uid;
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
/// Background timer cadence + idle/ping timeouts, in seconds.
|
||||
pub const TICK_SECS: u64 = 15;
|
||||
pub const PING_AFTER: u64 = 90;
|
||||
pub const PING_TIMEOUT: u64 = 60;
|
||||
pub const REG_TIMEOUT: u64 = 60;
|
||||
|
||||
pub fn now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Format a unix timestamp as an IRCv3 `server-time` tag value
|
||||
/// (`2026-08-05T07:58:03.000Z`), computing the civil date with std only.
|
||||
pub fn iso_time(secs: u64) -> String {
|
||||
let days = (secs / 86400) as i64;
|
||||
let (h, mi, s) = ((secs % 86400) / 3600, (secs % 3600) / 60, secs % 60);
|
||||
// civil date from days since 1970-01-01 (Howard Hinnant's algorithm)
|
||||
let z = days + 719468;
|
||||
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||
let doe = z - era * 146097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
|
||||
}
|
||||
|
||||
/// A recently-departed identity, kept for WHOWAS.
|
||||
pub struct WhowasEntry {
|
||||
pub nick: String,
|
||||
pub ident: String,
|
||||
pub host: String,
|
||||
pub realname: String,
|
||||
pub account: Option<String>,
|
||||
pub ts: u64,
|
||||
}
|
||||
|
||||
pub struct Server {
|
||||
pub name: String,
|
||||
pub network: String,
|
||||
pub created: u64,
|
||||
pub motd: Vec<String>,
|
||||
pub users: HashMap<Uid, User>,
|
||||
pub nick_index: HashMap<String, Uid>, // lower nick -> uid
|
||||
pub channels: HashMap<String, Channel>, // lower name -> channel
|
||||
pub events: VecDeque<Hook>,
|
||||
pub opers: Vec<(String, String)>, // (name, password) from config
|
||||
pub cloak_key: Option<String>, // host-cloaking key (see modules::cloak)
|
||||
pub line_ctags: String, // client-only tags of the line being handled
|
||||
// --- server-to-server (see crate::link) ---
|
||||
pub sid: String, // our 3-char server id
|
||||
pub server_desc: String, // our description
|
||||
pub link_blocks: Vec<LinkBlock>, // peers we accept / dial
|
||||
pub links: HashMap<Uid, Link>, // local link connections
|
||||
pub servers: HashMap<String, RemoteServer>, // sid -> linked server
|
||||
pub uuid_counter: u64, // mints local user UIDs
|
||||
pub msgid_counter: u64, // mints IRCv3 `msgid` message tags
|
||||
pub uuid_local: HashMap<String, Uid>, // our users, by network uuid
|
||||
pub remote_users: HashMap<String, RemoteUser>, // users on other servers
|
||||
pub remote_nick: HashMap<String, String>, // lower nick -> remote uuid
|
||||
pub whowas: VecDeque<WhowasEntry>, // recent nick history (WHOWAS)
|
||||
pub conf_path: String, // config path, for REHASH
|
||||
pub xlines: Vec<XLine>, // server bans (KLINE/GLINE/ZLINE)
|
||||
pub mode_sudo: bool, // SAMODE/SAKICK: bypass rank checks
|
||||
pub in_redirect: bool, // +L: guards against redirect loops
|
||||
pub censor: Vec<(String, String)>, // +G bad words: (find, replace)
|
||||
pub amu: crate::config::AntiMixedCfg, // antimixedutf8 module config
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn new(cfg: Config) -> Server {
|
||||
Server {
|
||||
name: cfg.servername,
|
||||
network: cfg.network,
|
||||
created: now(),
|
||||
motd: cfg.motd,
|
||||
users: HashMap::new(),
|
||||
nick_index: HashMap::new(),
|
||||
channels: HashMap::new(),
|
||||
events: VecDeque::new(),
|
||||
opers: cfg.opers,
|
||||
cloak_key: cfg.cloak_key,
|
||||
line_ctags: String::new(),
|
||||
sid: cfg.sid,
|
||||
server_desc: cfg.serverdesc,
|
||||
link_blocks: cfg.links,
|
||||
links: HashMap::new(),
|
||||
servers: HashMap::new(),
|
||||
uuid_counter: 0,
|
||||
msgid_counter: 0,
|
||||
uuid_local: HashMap::new(),
|
||||
remote_users: HashMap::new(),
|
||||
remote_nick: HashMap::new(),
|
||||
whowas: VecDeque::new(),
|
||||
conf_path: cfg.conf_path,
|
||||
xlines: Vec::new(),
|
||||
mode_sudo: false,
|
||||
in_redirect: false,
|
||||
censor: cfg.censor,
|
||||
amu: cfg.amu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember an identity for WHOWAS (capped ring, newest first).
|
||||
pub fn push_whowas(
|
||||
&mut self,
|
||||
nick: &str,
|
||||
ident: &str,
|
||||
host: &str,
|
||||
realname: &str,
|
||||
account: Option<String>,
|
||||
) {
|
||||
if nick.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.whowas.push_front(WhowasEntry {
|
||||
nick: nick.to_string(),
|
||||
ident: ident.to_string(),
|
||||
host: host.to_string(),
|
||||
realname: realname.to_string(),
|
||||
account,
|
||||
ts: now(),
|
||||
});
|
||||
while self.whowas.len() > 256 {
|
||||
self.whowas.pop_back();
|
||||
}
|
||||
}
|
||||
|
||||
// --- connection lifecycle ------------------------------------------------
|
||||
|
||||
pub fn add_conn(
|
||||
&mut self,
|
||||
uid: Uid,
|
||||
addr: SocketAddr,
|
||||
out: Sender<String>,
|
||||
sock: TcpStream,
|
||||
secure: bool,
|
||||
) {
|
||||
let uuid = self.next_uuid();
|
||||
self.uuid_local.insert(uuid.clone(), uid);
|
||||
self.users.insert(
|
||||
uid,
|
||||
User {
|
||||
uid,
|
||||
uuid,
|
||||
nick: String::new(),
|
||||
ident: String::new(),
|
||||
realname: String::new(),
|
||||
host: addr.ip().to_string(),
|
||||
cloak: String::new(),
|
||||
vhost: None,
|
||||
secure,
|
||||
account: None,
|
||||
signon: now(),
|
||||
addr,
|
||||
registered: false,
|
||||
cap: false,
|
||||
cap_302: false,
|
||||
caps: Caps::default(),
|
||||
sasl_mech: None,
|
||||
channels: HashSet::new(),
|
||||
watch: Vec::new(),
|
||||
monitor: Vec::new(),
|
||||
silence: Vec::new(),
|
||||
quitting: None,
|
||||
flags: UserFlags::default(),
|
||||
last_active: now(),
|
||||
ping_sent: false,
|
||||
ext: Extensible::default(),
|
||||
out,
|
||||
sock: Some(sock),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Mark a user as quitting; the core turns this into a full quit after the
|
||||
/// current command returns (so hooks fire while the user still exists).
|
||||
pub fn mark_quit(&mut self, uid: Uid, reason: String) {
|
||||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.quitting = Some(reason);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_quit(&mut self, uid: Uid) -> Option<String> {
|
||||
self.users.get_mut(&uid).and_then(|u| u.quitting.take())
|
||||
}
|
||||
|
||||
/// Remove a user: broadcast QUIT to everyone sharing a channel, drop them
|
||||
/// from all channels, free the nick, and close the socket.
|
||||
pub fn remove_user(&mut self, uid: Uid, reason: &str) {
|
||||
let Some(user) = self.users.remove(&uid) else {
|
||||
return;
|
||||
};
|
||||
self.uuid_local.remove(&user.uuid);
|
||||
if user.registered {
|
||||
self.push_whowas(
|
||||
&user.nick,
|
||||
&user.ident,
|
||||
user.host_display(),
|
||||
&user.realname,
|
||||
user.account.clone(),
|
||||
);
|
||||
self.propagate(&format!(":{} QUIT :{reason}", user.uuid), None); // tell links
|
||||
}
|
||||
// NB: we do *not* force-shutdown the socket here. When `user` drops at
|
||||
// the end of this function its `out` Sender drops with it, so the writer
|
||||
// thread drains any still-queued lines — e.g. a KILL / x-line ERROR —
|
||||
// and then closes the socket itself once the channel is empty.
|
||||
if !user.nick.is_empty() {
|
||||
self.nick_index.remove(&user.nick.to_ascii_lowercase());
|
||||
}
|
||||
if user.registered {
|
||||
let line = format!(":{} QUIT :{reason}", user.prefix());
|
||||
let mut seen: HashSet<Uid> = HashSet::new();
|
||||
for key in &user.channels {
|
||||
if let Some(ch) = self.channels.get_mut(key) {
|
||||
ch.members.remove(&uid);
|
||||
for &m in ch.members.keys() {
|
||||
seen.insert(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in seen {
|
||||
self.send(m, line.clone());
|
||||
}
|
||||
self.channels.retain(|_, c| !c.is_empty());
|
||||
self.watch_notify_offline(&user.nick); // tell WATCH/MONITOR watchers
|
||||
}
|
||||
}
|
||||
|
||||
// --- output primitives ---------------------------------------------------
|
||||
|
||||
/// Queue one raw line to a connection (no-op if it's gone).
|
||||
pub fn send(&self, uid: Uid, line: String) {
|
||||
if let Some(u) = self.users.get(&uid) {
|
||||
// server-time: tag sourced (`:prefix …`) lines for clients that asked
|
||||
let line = if u.caps.server_time && line.starts_with(':') {
|
||||
format!("@time={} {line}", iso_time(now()))
|
||||
} else {
|
||||
line
|
||||
};
|
||||
let _ = u.out.send(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a numeric: `:server NNN <target> <rest>`. `<target>` is the client's
|
||||
/// nick, or `*` before it has one.
|
||||
pub fn numeric(&self, uid: Uid, code: u16, rest: &str) {
|
||||
let target = self
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| {
|
||||
if u.nick.is_empty() {
|
||||
"*".to_string()
|
||||
} else {
|
||||
u.nick.clone()
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| "*".to_string());
|
||||
self.send(
|
||||
uid,
|
||||
format!(":{} {:03} {} {}", self.name, code, target, rest),
|
||||
);
|
||||
}
|
||||
|
||||
/// Send a server notice to every operator who has snomask (+s) on.
|
||||
pub fn snotice(&self, msg: &str) {
|
||||
let opers: Vec<Uid> = self
|
||||
.users
|
||||
.iter()
|
||||
.filter(|(_, u)| u.flags.oper && u.flags.snomask)
|
||||
.map(|(&u, _)| u)
|
||||
.collect();
|
||||
for o in opers {
|
||||
let nick = self
|
||||
.users
|
||||
.get(&o)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
self.send(o, format!(":{} NOTICE {nick} :*** {msg}", self.name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a line to every member of a channel, optionally skipping one uid.
|
||||
pub fn to_channel(&self, key: &str, line: &str, except: Option<Uid>) {
|
||||
if let Some(ch) = self.channels.get(key) {
|
||||
for &uid in ch.members.keys() {
|
||||
if Some(uid) != except {
|
||||
self.send(uid, line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a message body (`:prefix CMD …`) to `uid`, composing its IRCv3 tag
|
||||
/// prefix from that client's caps: `time=` (server-time) plus the client-only
|
||||
/// tags `ctags` (message-tags). Used for PRIVMSG / NOTICE / TAGMSG delivery.
|
||||
pub fn send_tagged(&self, uid: Uid, ctags: &str, msgid: &str, body: &str) {
|
||||
if let Some(u) = self.users.get(&uid) {
|
||||
let mut tags: Vec<String> = Vec::new();
|
||||
if u.caps.server_time {
|
||||
tags.push(format!("time={}", iso_time(now())));
|
||||
}
|
||||
// msgid (IRCv3): a unique, server-assigned id per message so clients
|
||||
// can reference it (reactions, replies, redaction). Tag-only feature,
|
||||
// so it goes to message-tags clients alongside any client `+`-tags.
|
||||
if u.caps.message_tags {
|
||||
if !msgid.is_empty() {
|
||||
tags.push(format!("msgid={msgid}"));
|
||||
}
|
||||
if !ctags.is_empty() {
|
||||
tags.push(ctags.to_string());
|
||||
}
|
||||
}
|
||||
let line = if tags.is_empty() {
|
||||
body.to_string()
|
||||
} else {
|
||||
format!("@{} {body}", tags.join(";"))
|
||||
};
|
||||
let _ = u.out.send(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a unique IRCv3 `msgid` for one message. Generated once per PRIVMSG/
|
||||
/// NOTICE/TAGMSG and shared across all its recipients so they correlate.
|
||||
/// `<server-start>-<counter>` in hex: unique for this run, distinct across
|
||||
/// restarts (the start time changes).
|
||||
pub fn next_msgid(&mut self) -> String {
|
||||
self.msgid_counter = self.msgid_counter.wrapping_add(1);
|
||||
format!("{:x}-{:x}", self.created, self.msgid_counter)
|
||||
}
|
||||
|
||||
/// Send `line` to every user sharing a channel with `uid` (except `uid`) whose
|
||||
/// capabilities satisfy `want`. Drives away-/account-/chghost-/setname-notify.
|
||||
pub fn notify_peers(&self, uid: Uid, line: &str, want: fn(&Caps) -> bool) {
|
||||
let chans: Vec<String> = self
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.channels.iter().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
let mut seen: HashSet<Uid> = HashSet::new();
|
||||
for k in &chans {
|
||||
if let Some(ch) = self.channels.get(k) {
|
||||
for &m in ch.members.keys() {
|
||||
seen.insert(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
// extended-monitor (IRCv3): clients that MONITOR this nick and negotiated
|
||||
// `extended-monitor` are treated as able to see it — so away/account/
|
||||
// chghost/setname reach them even without a shared channel. The `want`
|
||||
// filter below still requires the matching base cap, per spec.
|
||||
if let Some(low) = self.users.get(&uid).map(|u| u.nick.to_ascii_lowercase()) {
|
||||
for (&m, u) in &self.users {
|
||||
if u.caps.extended_monitor && u.monitor.contains(&low) {
|
||||
seen.insert(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
for m in seen {
|
||||
if m != uid && self.users.get(&m).map(|u| want(&u.caps)).unwrap_or(false) {
|
||||
self.send(m, line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change a user's displayed host and/or ident (CHGHOST/CHGIDENT/SETHOST/
|
||||
/// SETIDENT). Announces it via the `chghost` cap to peers that speak it and
|
||||
/// to the user, and sends RPL_HOSTHIDDEN (396) when the host changed. Mirrors
|
||||
/// InspIRCd's ChangeDisplayedHost / ChangeIdent (local scope for now).
|
||||
pub fn change_host_ident(&mut self, uid: Uid, new_ident: Option<&str>, new_host: Option<&str>) {
|
||||
let Some(u) = self.users.get(&uid) else {
|
||||
return;
|
||||
};
|
||||
let old_prefix = u.prefix();
|
||||
if let Some(h) = new_host {
|
||||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.vhost = Some(h.to_string());
|
||||
}
|
||||
}
|
||||
if let Some(i) = new_ident {
|
||||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.ident = i.to_string();
|
||||
}
|
||||
}
|
||||
let (ident, host, aware) = {
|
||||
let u = &self.users[&uid];
|
||||
(
|
||||
u.ident.clone(),
|
||||
u.host_display().to_string(),
|
||||
u.caps.chghost,
|
||||
)
|
||||
};
|
||||
// chghost-cap peers (and, if it speaks it, the user) get a CHGHOST line
|
||||
let line = format!(":{old_prefix} CHGHOST {ident} {host}");
|
||||
self.notify_peers(uid, &line, |c| c.chghost);
|
||||
if aware {
|
||||
self.send(uid, line);
|
||||
}
|
||||
if new_host.is_some() {
|
||||
self.numeric(
|
||||
uid,
|
||||
crate::numeric::RPL_HOSTHIDDEN,
|
||||
&format!("{host} :is now your displayed host"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Decide which connections to PING and which to drop, given `now`.
|
||||
/// Returns `(to_ping, to_quit)`. Pure over the state, so it's unit-testable.
|
||||
pub fn idle_check(&self, now: u64) -> (Vec<Uid>, Vec<Uid>) {
|
||||
let mut ping = Vec::new();
|
||||
let mut quit = Vec::new();
|
||||
for (&uid, u) in &self.users {
|
||||
let idle = now.saturating_sub(u.last_active);
|
||||
if !u.registered {
|
||||
if idle >= REG_TIMEOUT {
|
||||
quit.push(uid); // never registered in time
|
||||
}
|
||||
} else if u.ping_sent {
|
||||
if idle >= PING_AFTER + PING_TIMEOUT {
|
||||
quit.push(uid); // no reply to our PING
|
||||
}
|
||||
} else if idle >= PING_AFTER {
|
||||
ping.push(uid); // idle — poke it
|
||||
}
|
||||
}
|
||||
(ping, quit)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::channels::valid_chan;
|
||||
use crate::users::{valid_nick, User, UserFlags};
|
||||
use std::sync::mpsc::{self, Receiver};
|
||||
|
||||
/// Insert a registered user with an output channel we can read in the test.
|
||||
fn add_user(s: &mut Server, uid: Uid, nick: &str) -> Receiver<String> {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
s.users.insert(
|
||||
uid,
|
||||
User {
|
||||
uid,
|
||||
uuid: format!("TST{uid:06}"),
|
||||
nick: nick.to_string(),
|
||||
ident: "u".to_string(),
|
||||
realname: "real".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
cloak: String::new(),
|
||||
vhost: None,
|
||||
secure: false,
|
||||
account: None,
|
||||
signon: 0,
|
||||
addr: "127.0.0.1:1".parse().unwrap(),
|
||||
registered: true,
|
||||
cap: false,
|
||||
cap_302: false,
|
||||
caps: Caps::default(),
|
||||
sasl_mech: None,
|
||||
channels: HashSet::new(),
|
||||
watch: Vec::new(),
|
||||
monitor: Vec::new(),
|
||||
silence: Vec::new(),
|
||||
quitting: None,
|
||||
flags: UserFlags::default(),
|
||||
last_active: 0,
|
||||
ping_sent: false,
|
||||
ext: Extensible::default(),
|
||||
out: tx,
|
||||
sock: None,
|
||||
},
|
||||
);
|
||||
s.nick_index.insert(nick.to_ascii_lowercase(), uid);
|
||||
rx
|
||||
}
|
||||
|
||||
fn srv() -> Server {
|
||||
Server::new(Config::default())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn join_broadcasts_and_tracks_membership() {
|
||||
let mut s = srv();
|
||||
let arx = add_user(&mut s, 1, "ann");
|
||||
let brx = add_user(&mut s, 2, "bob");
|
||||
s.join(1, "#c", None); // ann creates -> gets @
|
||||
s.join(2, "#c", None); // bob joins
|
||||
|
||||
assert!(s.channels["#c"].members[&1].op);
|
||||
assert!(!s.channels["#c"].members[&2].op);
|
||||
assert_eq!(s.channels["#c"].members.len(), 2);
|
||||
|
||||
let ann: Vec<String> = arx.try_iter().collect();
|
||||
assert!(ann
|
||||
.iter()
|
||||
.any(|l| l.contains("JOIN #c") && l.contains("ann!")));
|
||||
assert!(ann
|
||||
.iter()
|
||||
.any(|l| l.contains(":bob!") && l.contains("JOIN #c")));
|
||||
let bob: Vec<String> = brx.try_iter().collect();
|
||||
assert!(bob.iter().any(|l| l.contains("353") && l.contains("@ann")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nick_change_reindexes_and_notifies_channel() {
|
||||
let mut s = srv();
|
||||
let arx = add_user(&mut s, 1, "ann");
|
||||
add_user(&mut s, 2, "bob");
|
||||
s.join(1, "#c", None);
|
||||
s.join(2, "#c", None);
|
||||
s.set_nick(1, "annie");
|
||||
assert_eq!(s.find_nick("annie"), Some(1));
|
||||
assert_eq!(s.find_nick("ann"), None);
|
||||
let ann: Vec<String> = arx.try_iter().collect();
|
||||
assert!(ann
|
||||
.iter()
|
||||
.any(|l| l.contains("NICK :annie") && l.contains("ann!")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quit_frees_the_nick_and_tells_neighbors() {
|
||||
let mut s = srv();
|
||||
add_user(&mut s, 1, "ann");
|
||||
let brx = add_user(&mut s, 2, "bob");
|
||||
s.join(1, "#c", None);
|
||||
s.join(2, "#c", None);
|
||||
s.remove_user(1, "bye");
|
||||
assert!(s.find_nick("ann").is_none());
|
||||
assert!(!s.channels["#c"].members.contains_key(&1));
|
||||
let bob: Vec<String> = brx.try_iter().collect();
|
||||
assert!(bob
|
||||
.iter()
|
||||
.any(|l| l.contains(":ann!") && l.contains("QUIT :bye")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moderated_channel_needs_voice() {
|
||||
let mut s = srv();
|
||||
add_user(&mut s, 1, "ann");
|
||||
add_user(&mut s, 2, "bob");
|
||||
s.join(1, "#c", None); // ann = op
|
||||
s.join(2, "#c", None);
|
||||
s.channels.get_mut("#c").unwrap().modes.moderated = true;
|
||||
assert!(s.is_op(1, "#c") && !s.is_op(2, "#c"));
|
||||
assert!(s.is_member(2, "#c"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secure_only_channel_rejects_plaintext() {
|
||||
let mut s = srv();
|
||||
add_user(&mut s, 1, "tls");
|
||||
s.users.get_mut(&1).unwrap().secure = true; // on TLS
|
||||
add_user(&mut s, 2, "plain"); // add_user defaults secure=false
|
||||
s.join(1, "#z", None); // tls creates -> op
|
||||
s.channels.get_mut("#z").unwrap().modes.secure_only = true;
|
||||
s.join(2, "#z", None); // plaintext tries to join
|
||||
assert!(s.is_member(1, "#z")); // the TLS user stays
|
||||
assert!(!s.is_member(2, "#z")); // the plaintext user is refused
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reg_only_channel_rejects_unregistered() {
|
||||
let mut s = srv();
|
||||
add_user(&mut s, 1, "member");
|
||||
s.users.get_mut(&1).unwrap().account = Some("acct".to_string()); // logged in
|
||||
add_user(&mut s, 2, "guest"); // account None
|
||||
s.join(1, "#r", None); // logged-in user creates -> op
|
||||
s.channels.get_mut("#r").unwrap().modes.reg_only = true;
|
||||
s.join(2, "#r", None); // guest tries to join
|
||||
assert!(s.is_member(1, "#r")); // the logged-in user stays
|
||||
assert!(!s.is_member(2, "#r")); // the guest is refused
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso_time_formats_server_time() {
|
||||
assert_eq!(iso_time(0), "1970-01-01T00:00:00.000Z");
|
||||
assert_eq!(iso_time(1_000_000_000), "2001-09-09T01:46:40.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nick_and_chan_validation() {
|
||||
assert!(valid_nick("reverse"));
|
||||
assert!(valid_nick("[abc]`"));
|
||||
assert!(!valid_nick("1abc")); // can't start with a digit
|
||||
assert!(!valid_nick(""));
|
||||
assert!(valid_chan("#argentina"));
|
||||
assert!(!valid_chan("argentina"));
|
||||
assert!(!valid_chan("#a b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn idle_check_pings_then_times_out() {
|
||||
let mut s = srv();
|
||||
add_user(&mut s, 1, "ann"); // registered, last_active = 0
|
||||
let now = 1000;
|
||||
let (ping, quit) = s.idle_check(now);
|
||||
assert_eq!(ping, vec![1]); // idle -> PING
|
||||
assert!(quit.is_empty());
|
||||
|
||||
s.users.get_mut(&1).unwrap().ping_sent = true;
|
||||
let (ping, quit) = s.idle_check(now);
|
||||
assert!(ping.is_empty()); // already pinged
|
||||
assert_eq!(quit, vec![1]); // no reply -> ping timeout
|
||||
|
||||
s.users.get_mut(&1).unwrap().ping_sent = false;
|
||||
s.users.get_mut(&1).unwrap().last_active = now;
|
||||
let (ping, quit) = s.idle_check(now);
|
||||
assert!(ping.is_empty() && quit.is_empty()); // active -> left alone
|
||||
|
||||
s.users.get_mut(&1).unwrap().registered = false;
|
||||
s.users.get_mut(&1).unwrap().last_active = 0;
|
||||
let (_, quit) = s.idle_check(now);
|
||||
assert_eq!(quit, vec![1]); // unregistered + idle -> registration timeout
|
||||
}
|
||||
}
|
||||
255
src/socketengine.rs
Normal file
255
src/socketengine.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
//! The socket engine: the I/O edge. Accept connections and, per socket, ferry
|
||||
//! the wire to/from the core. Plaintext sockets get a blocking reader thread +
|
||||
//! writer thread; TLS sockets get one thread that owns the session and polls
|
||||
//! (a single TLS object can't be split across two threads). The core never
|
||||
//! touches a socket except to shut it down. (InspIRCd has a `socketengines/`
|
||||
//! dir of epoll/kqueue/select backends; ours is threads.)
|
||||
|
||||
use std::io::{self, BufRead, BufReader, Write};
|
||||
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::ircd::Event;
|
||||
use crate::tls::TlsBackend;
|
||||
use crate::Uid;
|
||||
|
||||
/// Longest single line we'll buffer before dropping it (crude flood guard).
|
||||
const MAX_LINE: usize = 16 * 1024;
|
||||
/// How long a TLS thread blocks on a read before draining its write queue.
|
||||
const TLS_POLL: Duration = Duration::from_millis(100);
|
||||
|
||||
/// Accept forever, wiring each connection to the core. `tls` = the backend to
|
||||
/// wrap sockets in (None for a plaintext listener). `counter` is shared across
|
||||
/// every listener so uids stay unique.
|
||||
pub fn accept_loop(
|
||||
listener: TcpListener,
|
||||
core: Sender<Event>,
|
||||
tls: Option<Arc<dyn TlsBackend>>,
|
||||
counter: Arc<AtomicU64>,
|
||||
link: bool,
|
||||
) {
|
||||
for conn in listener.incoming() {
|
||||
let Ok(stream) = conn else { continue };
|
||||
let Ok(addr) = stream.peer_addr() else {
|
||||
continue;
|
||||
};
|
||||
let _ = stream.set_nodelay(true);
|
||||
let uid = counter.fetch_add(1, Ordering::Relaxed);
|
||||
|
||||
match &tls {
|
||||
None => {
|
||||
let Ok(reader) = stream.try_clone() else {
|
||||
continue;
|
||||
};
|
||||
let Ok(shutdown) = stream.try_clone() else {
|
||||
continue;
|
||||
};
|
||||
let (out_tx, out_rx) = mpsc::channel::<String>();
|
||||
thread::spawn(move || writer_loop(stream, out_rx));
|
||||
if core
|
||||
.send(Event::Connect {
|
||||
uid,
|
||||
addr,
|
||||
out: out_tx,
|
||||
sock: shutdown,
|
||||
secure: false,
|
||||
link,
|
||||
outbound: false,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break; // core gone
|
||||
}
|
||||
let core_tx = core.clone();
|
||||
thread::spawn(move || reader_loop(reader, uid, core_tx));
|
||||
}
|
||||
Some(backend) => {
|
||||
let backend = backend.clone();
|
||||
let core_tx = core.clone();
|
||||
thread::spawn(move || tls_conn(backend, stream, uid, addr, core_tx, link));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Dial an outbound server link and wire it to the core (an `outbound` link that
|
||||
/// introduces itself first). Used for auto-connecting to a configured uplink.
|
||||
pub fn connect_link(addr: &str, core: Sender<Event>, counter: Arc<AtomicU64>) {
|
||||
let stream = match TcpStream::connect(addr) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
eprintln!("[link] cannot dial {addr}: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = stream.set_nodelay(true);
|
||||
let Ok(peer) = stream.peer_addr() else { return };
|
||||
let Ok(reader) = stream.try_clone() else {
|
||||
return;
|
||||
};
|
||||
let Ok(shutdown) = stream.try_clone() else {
|
||||
return;
|
||||
};
|
||||
let uid = counter.fetch_add(1, Ordering::Relaxed);
|
||||
let (out_tx, out_rx) = mpsc::channel::<String>();
|
||||
thread::spawn(move || writer_loop(stream, out_rx));
|
||||
if core
|
||||
.send(Event::Connect {
|
||||
uid,
|
||||
addr: peer,
|
||||
out: out_tx,
|
||||
sock: shutdown,
|
||||
secure: false,
|
||||
link: true,
|
||||
outbound: true,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
thread::spawn(move || reader_loop(reader, uid, core));
|
||||
}
|
||||
|
||||
// --- plaintext: two blocking threads ----------------------------------------
|
||||
|
||||
fn reader_loop(stream: TcpStream, uid: Uid, core: Sender<Event>) {
|
||||
let mut buf = BufReader::new(stream);
|
||||
let mut line = String::new();
|
||||
loop {
|
||||
line.clear();
|
||||
match buf.read_line(&mut line) {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(_) => {
|
||||
if line.len() > MAX_LINE {
|
||||
continue;
|
||||
}
|
||||
let l = line.trim_end_matches(['\r', '\n']);
|
||||
if l.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if core
|
||||
.send(Event::Line {
|
||||
uid,
|
||||
line: l.to_string(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
let _ = core.send(Event::Disconnect { uid });
|
||||
}
|
||||
|
||||
fn writer_loop(mut stream: TcpStream, rx: Receiver<String>) {
|
||||
// Ends when every sender (the user's `out`) is dropped by the core.
|
||||
for line in rx {
|
||||
if stream.write_all(line.as_bytes()).is_err() || stream.write_all(b"\r\n").is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = stream.shutdown(Shutdown::Both);
|
||||
}
|
||||
|
||||
// --- TLS: one thread owning the session -------------------------------------
|
||||
|
||||
fn tls_conn(
|
||||
backend: Arc<dyn TlsBackend>,
|
||||
stream: TcpStream,
|
||||
uid: Uid,
|
||||
addr: SocketAddr,
|
||||
core: Sender<Event>,
|
||||
link: bool,
|
||||
) {
|
||||
// Keep a raw handle so the core can force the socket shut later.
|
||||
let Ok(shutdown) = stream.try_clone() else {
|
||||
return;
|
||||
};
|
||||
let mut conn = match backend.accept(stream) {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
let _ = shutdown.shutdown(Shutdown::Both);
|
||||
return; // handshake failed
|
||||
}
|
||||
};
|
||||
let (out_tx, out_rx) = mpsc::channel::<String>();
|
||||
if core
|
||||
.send(Event::Connect {
|
||||
uid,
|
||||
addr,
|
||||
out: out_tx,
|
||||
sock: shutdown,
|
||||
secure: true,
|
||||
link,
|
||||
outbound: false,
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
conn.shutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = conn.set_read_timeout(Some(TLS_POLL));
|
||||
let mut acc: Vec<u8> = Vec::new();
|
||||
let mut chunk = [0u8; 4096];
|
||||
'io: loop {
|
||||
match conn.read(&mut chunk) {
|
||||
Ok(0) => break, // EOF
|
||||
Ok(n) => {
|
||||
acc.extend_from_slice(&chunk[..n]);
|
||||
while let Some(pos) = acc.iter().position(|&b| b == b'\n') {
|
||||
let raw: Vec<u8> = acc.drain(..=pos).collect();
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let l = text.trim_end_matches(['\r', '\n']);
|
||||
if !l.is_empty()
|
||||
&& core
|
||||
.send(Event::Line {
|
||||
uid,
|
||||
line: l.to_string(),
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
break 'io;
|
||||
}
|
||||
}
|
||||
if acc.len() > MAX_LINE {
|
||||
acc.clear(); // overlong line with no newline: drop it
|
||||
}
|
||||
}
|
||||
Err(e)
|
||||
if matches!(
|
||||
e.kind(),
|
||||
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||
) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
|
||||
// drain everything the core queued for this connection
|
||||
loop {
|
||||
match out_rx.try_recv() {
|
||||
Ok(line) => {
|
||||
if conn.write_all(line.as_bytes()).is_err() || conn.write_all(b"\r\n").is_err()
|
||||
{
|
||||
break 'io;
|
||||
}
|
||||
}
|
||||
Err(TryRecvError::Empty) => break,
|
||||
Err(TryRecvError::Disconnected) => {
|
||||
// the core dropped us (user removed); nothing more to do
|
||||
conn.shutdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = conn.flush();
|
||||
}
|
||||
conn.shutdown();
|
||||
let _ = core.send(Event::Disconnect { uid });
|
||||
}
|
||||
81
src/tls.rs
Normal file
81
src/tls.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
//! TLS backends — echoIRCd's answer to InspIRCd's `IOHook` seam and its
|
||||
//! `ssl_openssl` / `ssl_gnutls` modules. A [`TlsBackend`] wraps an accepted
|
||||
//! socket in a TLS session; the socket engine then drives the resulting
|
||||
//! [`TlsConn`] for any listener that has a backend attached.
|
||||
//!
|
||||
//! This backend is openssl. The `openssl` crate keeps all its `unsafe` internal,
|
||||
//! so the daemon itself stays `#![forbid(unsafe_code)]`. A pure-Rust `rustls`
|
||||
//! backend (or a gnutls one) only has to implement these same two traits and it
|
||||
//! slots straight in — exactly the pluggable-provider shape InspIRCd uses.
|
||||
|
||||
use std::io::{self, Read, Write};
|
||||
use std::net::{Shutdown, TcpStream};
|
||||
use std::time::Duration;
|
||||
|
||||
use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslStream};
|
||||
|
||||
/// A live TLS connection: read/write plaintext, tune the read timeout (the
|
||||
/// socket engine polls with one to interleave reads and queued writes), and shut
|
||||
/// it down. The concrete backend type stays hidden behind this.
|
||||
pub trait TlsConn: Send {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
|
||||
fn write_all(&mut self, buf: &[u8]) -> io::Result<()>;
|
||||
fn flush(&mut self) -> io::Result<()>;
|
||||
fn set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()>;
|
||||
fn shutdown(&self);
|
||||
}
|
||||
|
||||
/// A TLS backend: performs the server-side handshake on an accepted socket.
|
||||
pub trait TlsBackend: Send + Sync {
|
||||
fn accept(&self, sock: TcpStream) -> io::Result<Box<dyn TlsConn>>;
|
||||
}
|
||||
|
||||
fn err<E: std::fmt::Display>(e: E) -> io::Error {
|
||||
io::Error::other(e.to_string())
|
||||
}
|
||||
|
||||
// --- openssl backend --------------------------------------------------------
|
||||
|
||||
pub struct OpensslBackend {
|
||||
acceptor: SslAcceptor,
|
||||
}
|
||||
|
||||
impl OpensslBackend {
|
||||
/// Build an acceptor from a PEM certificate chain + private key.
|
||||
pub fn new(cert: &str, key: &str) -> io::Result<OpensslBackend> {
|
||||
let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls()).map_err(err)?;
|
||||
b.set_private_key_file(key, SslFiletype::PEM).map_err(err)?;
|
||||
b.set_certificate_chain_file(cert).map_err(err)?;
|
||||
b.check_private_key().map_err(err)?;
|
||||
Ok(OpensslBackend {
|
||||
acceptor: b.build(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TlsBackend for OpensslBackend {
|
||||
fn accept(&self, sock: TcpStream) -> io::Result<Box<dyn TlsConn>> {
|
||||
let stream = self.acceptor.accept(sock).map_err(err)?;
|
||||
Ok(Box::new(OpensslConn(stream)))
|
||||
}
|
||||
}
|
||||
|
||||
struct OpensslConn(SslStream<TcpStream>);
|
||||
|
||||
impl TlsConn for OpensslConn {
|
||||
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
self.0.read(buf)
|
||||
}
|
||||
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
|
||||
self.0.write_all(buf)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.0.flush()
|
||||
}
|
||||
fn set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()> {
|
||||
self.0.get_ref().set_read_timeout(dur)
|
||||
}
|
||||
fn shutdown(&self) {
|
||||
let _ = self.0.get_ref().shutdown(Shutdown::Both);
|
||||
}
|
||||
}
|
||||
470
src/users.rs
Normal file
470
src/users.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
//! Users: the `User` record plus nick handling, user modes, oper status and the
|
||||
//! registration/welcome burst — the same job InspIRCd splits across users/
|
||||
//! usermanager, written from scratch in Rust (InspIRCd is a behaviour reference).
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::net::{SocketAddr, TcpStream};
|
||||
use std::sync::mpsc::Sender;
|
||||
|
||||
use crate::extensible::Extensible;
|
||||
use crate::module::Hook;
|
||||
use crate::numeric::*;
|
||||
use crate::server::{Server, VERSION};
|
||||
use crate::Uid;
|
||||
|
||||
/// User modes and session flags. Kept in one `Default` bag so adding a mode
|
||||
/// doesn't ripple through every `User { .. }` constructor.
|
||||
#[derive(Default)]
|
||||
pub struct UserFlags {
|
||||
pub oper: bool, // +o (granted by OPER only)
|
||||
pub invisible: bool, // +i
|
||||
pub wallops: bool, // +w (receives WALLOPS)
|
||||
pub cloak: bool, // +x (host cloak shown; see modules::cloak)
|
||||
pub bot: bool, // +B (marked as a bot; WHOIS 335)
|
||||
pub deaf: bool, // +D (doesn't receive channel messages)
|
||||
pub hidechans: bool, // +I (channels hidden in WHOIS)
|
||||
pub hideoper: bool, // +H (oper status hidden in WHOIS)
|
||||
pub logged_in: bool, // +r (logged into an account; services-managed)
|
||||
pub reg_only_pm: bool, // +R (only accept PMs from logged-in users)
|
||||
pub ssl_pm: bool, // +z (only accept PMs from TLS users)
|
||||
pub snomask: bool, // +s (oper: receive server notices)
|
||||
pub away: Option<String>, // AWAY message, if set
|
||||
}
|
||||
|
||||
impl UserFlags {
|
||||
pub fn umodes(&self) -> String {
|
||||
let mut s = String::from("+");
|
||||
if self.invisible {
|
||||
s.push('i');
|
||||
}
|
||||
if self.wallops {
|
||||
s.push('w');
|
||||
}
|
||||
if self.oper {
|
||||
s.push('o');
|
||||
}
|
||||
if self.cloak {
|
||||
s.push('x');
|
||||
}
|
||||
if self.bot {
|
||||
s.push('B');
|
||||
}
|
||||
if self.deaf {
|
||||
s.push('D');
|
||||
}
|
||||
if self.hidechans {
|
||||
s.push('I');
|
||||
}
|
||||
if self.hideoper {
|
||||
s.push('H');
|
||||
}
|
||||
if self.logged_in {
|
||||
s.push('r');
|
||||
}
|
||||
if self.reg_only_pm {
|
||||
s.push('R');
|
||||
}
|
||||
if self.ssl_pm {
|
||||
s.push('z');
|
||||
}
|
||||
if self.snomask {
|
||||
s.push('s');
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// The IRCv3 capabilities echoIRCd advertises. Order = the CAP LS order.
|
||||
pub const SUPPORTED_CAPS: &[&str] = &[
|
||||
"sasl",
|
||||
"server-time",
|
||||
"message-tags",
|
||||
"multi-prefix",
|
||||
"away-notify",
|
||||
"account-notify",
|
||||
"extended-join",
|
||||
"chghost",
|
||||
"userhost-in-names",
|
||||
"echo-message",
|
||||
"invite-notify",
|
||||
"setname",
|
||||
"extended-monitor",
|
||||
"cap-notify",
|
||||
];
|
||||
|
||||
/// Per-connection IRCv3 capability state — echoIRCd's answer to InspIRCd's `m_cap`
|
||||
/// plus the individual `m_ircv3_*` modules, as one flat set (not a plugin per cap).
|
||||
/// Toggled by `CAP REQ`; consulted wherever a line is formatted per-client.
|
||||
#[derive(Default)]
|
||||
pub struct Caps {
|
||||
pub sasl: bool,
|
||||
pub server_time: bool,
|
||||
pub message_tags: bool,
|
||||
pub multi_prefix: bool,
|
||||
pub away_notify: bool,
|
||||
pub account_notify: bool,
|
||||
pub extended_join: bool,
|
||||
pub chghost: bool,
|
||||
pub userhost_in_names: bool,
|
||||
pub echo_message: bool,
|
||||
pub invite_notify: bool,
|
||||
pub setname: bool,
|
||||
pub extended_monitor: bool, // route away/account/chghost/setname for MONITOR targets
|
||||
pub cap_notify: bool,
|
||||
}
|
||||
|
||||
impl Caps {
|
||||
pub fn is_known(name: &str) -> bool {
|
||||
SUPPORTED_CAPS.contains(&name)
|
||||
}
|
||||
|
||||
/// The `CAP LS` token list; `sasl` carries its mechanisms for 302 clients.
|
||||
pub fn ls_line(cap302: bool) -> String {
|
||||
SUPPORTED_CAPS
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if *c == "sasl" && cap302 {
|
||||
"sasl=PLAIN".to_string()
|
||||
} else {
|
||||
(*c).to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
|
||||
pub fn has(&self, name: &str) -> bool {
|
||||
match name {
|
||||
"sasl" => self.sasl,
|
||||
"server-time" => self.server_time,
|
||||
"message-tags" => self.message_tags,
|
||||
"multi-prefix" => self.multi_prefix,
|
||||
"away-notify" => self.away_notify,
|
||||
"account-notify" => self.account_notify,
|
||||
"extended-join" => self.extended_join,
|
||||
"chghost" => self.chghost,
|
||||
"userhost-in-names" => self.userhost_in_names,
|
||||
"echo-message" => self.echo_message,
|
||||
"invite-notify" => self.invite_notify,
|
||||
"setname" => self.setname,
|
||||
"extended-monitor" => self.extended_monitor,
|
||||
"cap-notify" => self.cap_notify,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Enable/disable a cap by name; returns whether the name was recognised.
|
||||
pub fn set(&mut self, name: &str, on: bool) -> bool {
|
||||
let field = match name {
|
||||
"sasl" => &mut self.sasl,
|
||||
"server-time" => &mut self.server_time,
|
||||
"message-tags" => &mut self.message_tags,
|
||||
"multi-prefix" => &mut self.multi_prefix,
|
||||
"away-notify" => &mut self.away_notify,
|
||||
"account-notify" => &mut self.account_notify,
|
||||
"extended-join" => &mut self.extended_join,
|
||||
"chghost" => &mut self.chghost,
|
||||
"userhost-in-names" => &mut self.userhost_in_names,
|
||||
"echo-message" => &mut self.echo_message,
|
||||
"invite-notify" => &mut self.invite_notify,
|
||||
"setname" => &mut self.setname,
|
||||
"extended-monitor" => &mut self.extended_monitor,
|
||||
"cap-notify" => &mut self.cap_notify,
|
||||
_ => return false,
|
||||
};
|
||||
*field = on;
|
||||
true
|
||||
}
|
||||
|
||||
/// Space-separated list of the currently-enabled caps (for `CAP LIST`).
|
||||
pub fn enabled(&self) -> String {
|
||||
SUPPORTED_CAPS
|
||||
.iter()
|
||||
.filter(|c| self.has(c))
|
||||
.copied()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
pub struct User {
|
||||
pub uid: Uid,
|
||||
pub uuid: String, // network-wide id (SID + 6) for S2S
|
||||
pub nick: String, // "" until NICK
|
||||
pub ident: String, // "" until USER
|
||||
pub realname: String,
|
||||
pub host: String, // real host (ip string; no rDNS)
|
||||
pub cloak: String, // masked host shown under +x ("" until computed)
|
||||
pub vhost: Option<String>, // displayed-host override (CHGHOST/SETHOST vhost)
|
||||
pub secure: bool, // connected over TLS (drives WHOIS 671 / sslinfo)
|
||||
pub account: Option<String>, // logged-in account name (set by services)
|
||||
pub signon: u64, // unix secs at registration (WHOIS 317)
|
||||
pub addr: SocketAddr,
|
||||
pub registered: bool,
|
||||
pub cap: bool, // CAP negotiation in progress (holds registration)
|
||||
pub cap_302: bool, // client sent CAP LS 302 (cap-notify aware)
|
||||
pub caps: Caps, // enabled IRCv3 capabilities
|
||||
pub sasl_mech: Option<String>, // SASL mechanism chosen, mid-handshake
|
||||
pub channels: HashSet<String>, // lowercased channel keys
|
||||
pub watch: Vec<String>, // WATCH list — lowercased nicks
|
||||
pub monitor: Vec<String>, // MONITOR list — lowercased nicks
|
||||
pub silence: Vec<String>, // SILENCE masks — nick!user@host globs
|
||||
pub quitting: Option<String>, // set by QUIT; drained by the core
|
||||
pub flags: UserFlags,
|
||||
pub last_active: u64, // unix secs of the last line we received
|
||||
pub ping_sent: bool, // a server PING is outstanding
|
||||
pub ext: Extensible, // typed, module-owned per-user metadata
|
||||
pub out: Sender<String>,
|
||||
pub sock: Option<TcpStream>, // core-side fd handle; dropped on quit so the
|
||||
// writer thread flushes then closes (None in tests)
|
||||
}
|
||||
|
||||
impl User {
|
||||
/// The host others see: an explicit vhost (CHGHOST/SETHOST) wins, then the
|
||||
/// cloak when +x is set (and one was computed), otherwise the real host.
|
||||
/// Everything that broadcasts a prefix — JOIN, QUIT, NICK, PRIVMSG source, ban
|
||||
/// matching — goes through here, so the displayed host is consistent for free.
|
||||
pub fn host_display(&self) -> &str {
|
||||
if let Some(v) = &self.vhost {
|
||||
v
|
||||
} else if self.flags.cloak && !self.cloak.is_empty() {
|
||||
&self.cloak
|
||||
} else {
|
||||
&self.host
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefix(&self) -> String {
|
||||
format!("{}!{}@{}", self.nick, self.ident, self.host_display())
|
||||
}
|
||||
}
|
||||
|
||||
impl Server {
|
||||
pub fn find_nick(&self, nick: &str) -> Option<Uid> {
|
||||
self.nick_index.get(&nick.to_ascii_lowercase()).copied()
|
||||
}
|
||||
|
||||
pub fn is_oper(&self, uid: Uid) -> bool {
|
||||
self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Grant IRC-operator status and tell the user.
|
||||
pub fn oper_up(&mut self, uid: Uid) {
|
||||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.flags.oper = true;
|
||||
u.flags.snomask = true; // opers get server notices by default
|
||||
}
|
||||
let nick = self
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
self.numeric(uid, RPL_YOUREOPER, ":You are now an IRC operator");
|
||||
self.send(uid, format!(":{} MODE {nick} :+os", self.name));
|
||||
self.snotice(&format!("{nick} is now an IRC operator"));
|
||||
}
|
||||
|
||||
/// Send a WALLOPS to every oper and every +w user.
|
||||
pub fn wallops(&self, from: &str, text: &str) {
|
||||
let line = format!(":{from} WALLOPS :{text}");
|
||||
let targets: Vec<Uid> = self
|
||||
.users
|
||||
.iter()
|
||||
.filter(|(_, u)| u.flags.oper || u.flags.wallops)
|
||||
.map(|(&uid, _)| uid)
|
||||
.collect();
|
||||
for uid in targets {
|
||||
self.send(uid, line.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Set or change a user's nick, keeping the index in sync and broadcasting
|
||||
/// the change to the user + everyone in their channels once registered.
|
||||
pub fn set_nick(&mut self, uid: Uid, newnick: &str) {
|
||||
let (old, registered, prefix, ident, host, realname, account) = match self.users.get(&uid) {
|
||||
Some(u) => (
|
||||
u.nick.clone(),
|
||||
u.registered,
|
||||
u.prefix(),
|
||||
u.ident.clone(),
|
||||
u.host_display().to_string(),
|
||||
u.realname.clone(),
|
||||
u.account.clone(),
|
||||
),
|
||||
None => return,
|
||||
};
|
||||
if registered {
|
||||
self.push_whowas(&old, &ident, &host, &realname, account);
|
||||
}
|
||||
if !old.is_empty() {
|
||||
self.nick_index.remove(&old.to_ascii_lowercase());
|
||||
}
|
||||
self.nick_index.insert(newnick.to_ascii_lowercase(), uid);
|
||||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.nick = newnick.to_string();
|
||||
}
|
||||
if registered {
|
||||
let line = format!(":{prefix} NICK :{newnick}");
|
||||
let mut targets: HashSet<Uid> = HashSet::new();
|
||||
targets.insert(uid);
|
||||
let chans: Vec<String> = self.users[&uid].channels.iter().cloned().collect();
|
||||
for key in &chans {
|
||||
if let Some(ch) = self.channels.get(key) {
|
||||
for &m in ch.members.keys() {
|
||||
targets.insert(m);
|
||||
}
|
||||
}
|
||||
}
|
||||
for t in targets {
|
||||
self.send(t, line.clone());
|
||||
}
|
||||
// WATCH/MONITOR: the old nick is now gone, the new one is here
|
||||
self.watch_notify_offline(&old);
|
||||
self.watch_notify_online(newnick);
|
||||
}
|
||||
self.propagate_nick(uid, newnick); // tell linked servers
|
||||
}
|
||||
|
||||
/// Finish registration: send the welcome burst + MOTD and queue the connect
|
||||
/// hook. The core calls this once NICK, USER and CAP are all satisfied.
|
||||
pub fn welcome(&mut self, uid: Uid) {
|
||||
if let Some(u) = self.users.get_mut(&uid) {
|
||||
u.registered = true;
|
||||
}
|
||||
let nick = self
|
||||
.users
|
||||
.get(&uid)
|
||||
.map(|u| u.nick.clone())
|
||||
.unwrap_or_default();
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_WELCOME,
|
||||
&format!(":Welcome to the {} IRC Network, {nick}", self.network),
|
||||
);
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_YOURHOST,
|
||||
&format!(":Your host is {}, running echoircd-{VERSION}", self.name),
|
||||
);
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_CREATED,
|
||||
&format!(":This server was created at unix {}", self.created),
|
||||
);
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_MYINFO,
|
||||
&format!(
|
||||
"{} echoircd-{VERSION} iowxsBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGu",
|
||||
self.name
|
||||
),
|
||||
);
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_ISUPPORT,
|
||||
&format!(
|
||||
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
||||
self.network
|
||||
),
|
||||
);
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_LUSERCLIENT,
|
||||
&format!(":There are {} users on 1 server", self.users.len()),
|
||||
);
|
||||
self.send_motd(uid);
|
||||
self.watch_notify_online(&nick); // tell WATCH/MONITOR watchers
|
||||
self.events.push_back(Hook::Connect(uid));
|
||||
}
|
||||
|
||||
pub fn send_motd(&self, uid: Uid) {
|
||||
if self.motd.is_empty() {
|
||||
self.numeric(uid, ERR_NOMOTD, ":MOTD File is missing");
|
||||
return;
|
||||
}
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_MOTDSTART,
|
||||
&format!(":- {} Message of the day -", self.name),
|
||||
);
|
||||
for line in &self.motd {
|
||||
self.numeric(uid, RPL_MOTD, &format!(":- {line}"));
|
||||
}
|
||||
self.numeric(uid, RPL_ENDOFMOTD, ":End of /MOTD command.");
|
||||
}
|
||||
}
|
||||
|
||||
/// A nick is 1–30 chars: first is a letter or `[]\`_^{}|`, rest add digits/`-`.
|
||||
/// A syntactically valid hostname for CHGHOST/SETHOST: letters, digits, `.` `-`
|
||||
/// `_` `/` (the last two allowed for cloak-style vhosts), 1..=64 chars.
|
||||
pub fn valid_host(h: &str) -> bool {
|
||||
!h.is_empty()
|
||||
&& h.len() <= 64
|
||||
&& h.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '/'))
|
||||
}
|
||||
|
||||
/// A syntactically valid ident/username for CHGIDENT/SETIDENT.
|
||||
pub fn valid_ident(i: &str) -> bool {
|
||||
!i.is_empty()
|
||||
&& i.len() <= 20
|
||||
&& i.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||
}
|
||||
|
||||
pub fn valid_nick(n: &str) -> bool {
|
||||
let special = |c: char| "[]\\`_^{}|".contains(c);
|
||||
let mut chars = n.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_alphabetic() || special(c) => {}
|
||||
_ => return false,
|
||||
}
|
||||
n.len() <= 30
|
||||
&& n.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || special(c) || c == '-')
|
||||
}
|
||||
|
||||
/// Turn a USER-supplied username into a safe ident (≤ 10 chars, no funny bytes).
|
||||
pub fn ident_of(user: &str) -> String {
|
||||
let s: String = user
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
|
||||
.take(10)
|
||||
.collect();
|
||||
if s.is_empty() {
|
||||
"user".to_string()
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn caps_set_has_enabled_and_ls() {
|
||||
let mut c = Caps::default();
|
||||
assert!(c.set("server-time", true));
|
||||
assert!(c.set("multi-prefix", true));
|
||||
assert!(!c.set("bogus-cap", true)); // unknown cap rejected
|
||||
assert!(c.has("server-time") && c.has("multi-prefix") && !c.has("sasl"));
|
||||
assert_eq!(c.enabled(), "server-time multi-prefix"); // SUPPORTED order
|
||||
assert!(Caps::ls_line(true).contains("sasl=PLAIN")); // 302 shows mechs
|
||||
assert!(Caps::ls_line(false).contains("sasl") && !Caps::ls_line(false).contains("sasl="));
|
||||
c.set("server-time", false);
|
||||
assert!(!c.has("server-time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_ident_validators() {
|
||||
assert!(valid_host("cloaked-a1b2.users.echo"));
|
||||
assert!(valid_host("some/vhost"));
|
||||
assert!(!valid_host("bad host")); // space
|
||||
assert!(!valid_host("")); // empty
|
||||
assert!(!valid_host(&"x".repeat(65))); // too long
|
||||
assert!(valid_ident("reverse"));
|
||||
assert!(!valid_ident("re verse")); // space
|
||||
assert!(!valid_ident("")); // empty
|
||||
}
|
||||
}
|
||||
84
src/watch.rs
Normal file
84
src/watch.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
//! WATCH / MONITOR notification plumbing + SILENCE matching.
|
||||
//!
|
||||
//! The lists themselves live on the [`crate::users::User`] (`watch` / `monitor` /
|
||||
//! `silence`); the commands are in [`crate::coremods::core_watch`]. Whenever a
|
||||
//! nick's online-state flips — registration, quit, or a nick change — the
|
||||
//! lifecycle code calls [`Server::watch_notify_online`] / [`Server::watch_notify_offline`],
|
||||
//! which scan for anyone WATCHing/MONITORing that nick and send the right numeric.
|
||||
//!
|
||||
//! An O(users) scan, not a reverse index: correct-by-construction (nothing to keep
|
||||
//! in sync) and fine on a small server — a reverse index can slot in later if it
|
||||
//! ever needs to scale, exactly the kind of change the borrow checker makes safe.
|
||||
|
||||
use crate::channels::glob_match;
|
||||
use crate::numeric::*;
|
||||
use crate::server::{now, Server};
|
||||
use crate::Uid;
|
||||
|
||||
pub const WATCH_MAX: usize = 128;
|
||||
pub const MONITOR_MAX: usize = 128;
|
||||
pub const SILENCE_MAX: usize = 32;
|
||||
|
||||
impl Server {
|
||||
/// A nick just came online (registered, or someone renamed to it): tell its
|
||||
/// WATCHers (600 RPL_LOGON) and MONITORers (730 RPL_MONONLINE).
|
||||
pub fn watch_notify_online(&self, nick: &str) {
|
||||
let low = nick.to_ascii_lowercase();
|
||||
let Some((dnick, ident, host, ts)) = self.find_nick(nick).and_then(|tu| {
|
||||
self.users.get(&tu).map(|x| {
|
||||
(
|
||||
x.nick.clone(),
|
||||
x.ident.clone(),
|
||||
x.host_display().to_string(),
|
||||
x.signon,
|
||||
)
|
||||
})
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
for (&uid, u) in &self.users {
|
||||
if u.watch.contains(&low) {
|
||||
self.numeric(
|
||||
uid,
|
||||
RPL_LOGON,
|
||||
&format!("{dnick} {ident} {host} {ts} :is now online"),
|
||||
);
|
||||
}
|
||||
if u.monitor.contains(&low) {
|
||||
self.numeric(uid, RPL_MONONLINE, &format!(":{dnick}!{ident}@{host}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A nick just went offline (quit, or renamed away): tell its WATCHers
|
||||
/// (601 RPL_LOGOFF) and MONITORers (731 RPL_MONOFFLINE).
|
||||
pub fn watch_notify_offline(&self, nick: &str) {
|
||||
let low = nick.to_ascii_lowercase();
|
||||
let ts = now();
|
||||
for (&uid, u) in &self.users {
|
||||
if u.watch.contains(&low) {
|
||||
self.numeric(uid, RPL_LOGOFF, &format!("{nick} * * {ts} :is now offline"));
|
||||
}
|
||||
if u.monitor.contains(&low) {
|
||||
self.numeric(uid, RPL_MONOFFLINE, &format!(":{nick}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many users currently WATCH `nick` (for `WATCH S` stats).
|
||||
pub fn watchers_of(&self, nick: &str) -> usize {
|
||||
let low = nick.to_ascii_lowercase();
|
||||
self.users
|
||||
.values()
|
||||
.filter(|u| u.watch.contains(&low))
|
||||
.count()
|
||||
}
|
||||
|
||||
/// True if user `by` has silenced someone whose prefix is `sender_mask`.
|
||||
pub fn is_silenced(&self, by: Uid, sender_mask: &str) -> bool {
|
||||
self.users
|
||||
.get(&by)
|
||||
.map(|u| u.silence.iter().any(|m| glob_match(m, sender_mask)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
140
src/xline.rs
Normal file
140
src/xline.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
//! X-lines — server bans (InspIRCd's `m_xline`): KLINE/GLINE on `user@host`,
|
||||
//! ZLINE on an IP. Matched at registration (a banned client is refused) and when
|
||||
//! the line is added (matching clients are killed); expired lines are reaped on
|
||||
//! the tick. Kept in `Server.xlines`.
|
||||
|
||||
use crate::channels::glob_match;
|
||||
use crate::server::{now, Server};
|
||||
use crate::Uid;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum XKind {
|
||||
Kline, // user@host, this server
|
||||
Gline, // user@host, "global" (locally the same until services span it)
|
||||
Zline, // an IP address
|
||||
}
|
||||
|
||||
impl XKind {
|
||||
pub fn tag(&self) -> &'static str {
|
||||
match self {
|
||||
XKind::Kline => "K",
|
||||
XKind::Gline => "G",
|
||||
XKind::Zline => "Z",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct XLine {
|
||||
pub kind: XKind,
|
||||
pub mask: String, // user@host glob (K/G) or ip glob (Z)
|
||||
pub reason: String,
|
||||
pub setter: String,
|
||||
pub expires: u64, // 0 = permanent
|
||||
}
|
||||
|
||||
/// Parse a duration: bare number = seconds; `s`/`m`/`h`/`d`/`w` suffixes; `0`/"" = permanent.
|
||||
pub fn parse_duration(s: &str) -> Option<u64> {
|
||||
if s.is_empty() || s == "0" {
|
||||
return Some(0);
|
||||
}
|
||||
let last = s.chars().last()?;
|
||||
if last.is_ascii_digit() {
|
||||
return s.parse::<u64>().ok();
|
||||
}
|
||||
let n: u64 = s[..s.len() - 1].parse().ok()?;
|
||||
let mul = match last {
|
||||
's' => 1,
|
||||
'm' => 60,
|
||||
'h' => 3600,
|
||||
'd' => 86400,
|
||||
'w' => 604800,
|
||||
_ => return None,
|
||||
};
|
||||
Some(n.saturating_mul(mul))
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// The reason a `user@host` / `ip` is banned by an active x-line, if any.
|
||||
pub fn matched_xline(&self, ident: &str, host: &str, ip: &str) -> Option<String> {
|
||||
let uh = format!("{ident}@{host}");
|
||||
let n = now();
|
||||
for x in &self.xlines {
|
||||
if x.expires != 0 && x.expires <= n {
|
||||
continue;
|
||||
}
|
||||
let hit = match x.kind {
|
||||
XKind::Zline => glob_match(&x.mask, ip),
|
||||
_ => glob_match(&x.mask, &uh),
|
||||
};
|
||||
if hit {
|
||||
return Some(format!("{}-lined: {}", x.kind.tag(), x.reason));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Add (or replace) an x-line, then kill every connected user it matches.
|
||||
pub fn add_xline(
|
||||
&mut self,
|
||||
kind: XKind,
|
||||
mask: &str,
|
||||
duration: u64,
|
||||
setter: &str,
|
||||
reason: &str,
|
||||
) {
|
||||
let n = now();
|
||||
self.xlines.retain(|x| !(x.kind == kind && x.mask == mask));
|
||||
self.xlines.push(XLine {
|
||||
kind,
|
||||
mask: mask.to_string(),
|
||||
reason: reason.to_string(),
|
||||
setter: setter.to_string(),
|
||||
expires: if duration == 0 { 0 } else { n + duration },
|
||||
});
|
||||
self.snotice(&format!(
|
||||
"{setter} added a {}-line on {mask}: {reason}",
|
||||
kind.tag()
|
||||
));
|
||||
self.enforce_xlines();
|
||||
}
|
||||
|
||||
/// Remove an x-line by kind + mask; returns whether one was found.
|
||||
pub fn remove_xline(&mut self, kind: XKind, mask: &str) -> bool {
|
||||
let before = self.xlines.len();
|
||||
self.xlines.retain(|x| !(x.kind == kind && x.mask == mask));
|
||||
self.xlines.len() < before
|
||||
}
|
||||
|
||||
/// Kill every connected local user that now matches an active x-line.
|
||||
pub fn enforce_xlines(&mut self) {
|
||||
let candidates: Vec<(Uid, String, String, String)> = self
|
||||
.users
|
||||
.iter()
|
||||
.filter(|(_, u)| u.registered)
|
||||
.map(|(&uid, u)| {
|
||||
(
|
||||
uid,
|
||||
u.ident.clone(),
|
||||
u.host.clone(),
|
||||
u.addr.ip().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let victims: Vec<(Uid, String)> = candidates
|
||||
.into_iter()
|
||||
.filter_map(|(uid, ident, host, ip)| {
|
||||
self.matched_xline(&ident, &host, &ip).map(|r| (uid, r))
|
||||
})
|
||||
.collect();
|
||||
for (uid, reason) in victims {
|
||||
self.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||
self.remove_user(uid, &reason);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop expired x-lines (called on the background tick).
|
||||
pub fn purge_xlines(&mut self) {
|
||||
let n = now();
|
||||
self.xlines.retain(|x| x.expires == 0 || x.expires > n);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue