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
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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue