import echoircd — from-scratch irc daemon in native rust

This commit is contained in:
Jean Chevronnet 2026-08-05 16:10:31 +00:00
commit 9b12791774
38 changed files with 9757 additions and 0 deletions

View 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 = &params[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 = &params[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) = (&params[0], &params[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) = (&params[0], &params[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) = (&params[0], &params[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 = &params[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
View 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 = &params[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
View 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(&params[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 = &params[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
}
}

View 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) = (&params[0], &params[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 = &params[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
View 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 = &params[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 = &params[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
View 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) = (&params[0], &params[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 = &params[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) = (&params[0], &params[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(&params[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, &params[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(&params[0]) else {
s.numeric(
uid,
ERR_NOSUCHNICK,
&format!("{} :No such nick/channel", params[0]),
);
return CmdResult::Fail;
};
s.join(tuid, &params[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(&params[0]) else {
s.numeric(
uid,
ERR_NOSUCHNICK,
&format!("{} :No such nick/channel", params[0]),
);
return CmdResult::Fail;
};
let chan = &params[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(&params[0]) else {
s.numeric(
uid,
ERR_NOSUCHNICK,
&format!("{} :No such nick/channel", params[0]),
);
return CmdResult::Fail;
};
let newnick = &params[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(&params[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(&params[1]) {
onotice(s, uid, "*** CHGHOST: invalid characters in hostname");
return CmdResult::Fail;
}
let Some(t) = oper_target(s, uid, &params[0]) else {
return CmdResult::Fail;
};
s.change_host_ident(t, None, Some(&params[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(&params[0]) {
onotice(s, uid, "*** SETHOST: invalid characters in hostname");
return CmdResult::Fail;
}
s.change_host_ident(uid, None, Some(&params[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(&params[1]) {
onotice(s, uid, "*** CHGIDENT: invalid characters in ident");
return CmdResult::Fail;
}
let Some(t) = oper_target(s, uid, &params[0]) else {
return CmdResult::Fail;
};
s.change_host_ident(t, Some(&params[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(&params[0]) {
onotice(s, uid, "*** SETIDENT: invalid characters in ident");
return CmdResult::Fail;
}
s.change_host_ident(uid, Some(&params[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 = &params[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) = (&params[0], &params[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
View 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 = &params[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(&params[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
View 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
View 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
}