//! core_user — the client registration & session commands: CAP, NICK, USER, //! PING, PONG, QUIT. use std::net::{IpAddr, SocketAddr}; use crate::channels::glob_match; 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> { vec![ Box::new(Cap), Box::new(Authenticate), Box::new(Nick), Box::new(UserCmd), Box::new(Ping), Box::new(Pong), Box::new(Pass), Box::new(Quit), Box::new(Away), Box::new(SetName), Box::new(WebIrc), Box::new(Vhost), ] } /// VHOST — claim a self-service virtual host with `VHOST ` matching a /// configured `vhost = ` block. struct Vhost; impl Command for Vhost { fn name(&self) -> &'static str { "VHOST" } fn min_params(&self) -> usize { 2 } fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { let (user, pass) = (¶ms[0], ¶ms[1]); // vhost blocks live in the config: `vhost = ` let host = s.conf_all("vhost").iter().find_map(|line| { let mut it = line.split_whitespace(); match (it.next(), it.next(), it.next()) { (Some(u), Some(p), Some(h)) if u == user && crate::modules::password_hash::ct_eq(p.as_bytes(), pass.as_bytes()) => { Some(h.to_string()) } _ => None, } }); let nick = s .users .get(&uid) .map(|u| u.nick.clone()) .unwrap_or_else(|| "*".to_string()); match host { Some(h) => { s.change_host_ident(uid, None, Some(&h)); s.send( uid, format!(":{} NOTICE {nick} :Your vhost is now {h}", s.name), ); } None => { s.send( uid, format!(":{} NOTICE {nick} :Invalid vhost credentials", s.name), ); return CmdResult::Fail; } } CmdResult::Ok } } /// WEBIRC — a trusted web gateway declares the real client's host + IP, so users /// behind it don't all share the gateway's address. `WEBIRC /// [:flags]`; must precede registration and the password must /// match a `webirc` config block whose `ipmask` also matches the gateway's own /// connecting IP. A block with no `ipmask` is rejected: a shared password alone /// would let anyone who learns it spoof any host/IP (bypassing z-lines, DNSBL, /// GeoIP and cloaking). struct WebIrc; impl Command for WebIrc { fn name(&self) -> &'static str { "WEBIRC" } 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) { return CmdResult::Fail; // can't re-spoof a registered session } let (pass, host, ip) = (¶ms[0], ¶ms[2], ¶ms[3]); // the gateway's own connecting IP (before we spoof it below) let from = s .users .get(&uid) .map(|u| u.addr.ip().to_string()) .unwrap_or_default(); let Some(gw) = s .webirc .iter() .find(|g| { crate::modules::password_hash::ct_eq(g.password.as_bytes(), pass.as_bytes()) && !g.ipmask.is_empty() && glob_match(&g.ipmask, &from) }) .map(|g| g.name.clone()) else { s.notice_star(uid, "WEBIRC: invalid credentials"); return CmdResult::Fail; }; let newip = ip.parse::().ok(); if let Some(u) = s.users.get_mut(&uid) { u.flags.via_webirc = true; // securitygroups: webirc criterion u.host = host.clone(); if let Some(a) = newip { u.addr = SocketAddr::new(a, u.addr.port()); } } s.notice_star(uid, &format!("WEBIRC identity accepted via {gw}")); CmdResult::Ok } } struct Away; impl Command for Away { fn name(&self) -> &'static str { "AWAY" } fn before_reg(&self) -> bool { true // draft/pre-away: clients may set AWAY during CAP negotiation } 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); let secure = s.users.get(&uid).map(|u| u.secure).unwrap_or(false); if let Some(u) = s.users.get_mut(&uid) { u.cap = true; // hold registration until CAP END u.cap_302 |= cap302; } let acctreg = crate::modules::account_registration::cap_tokens(s); let mut payload = Caps::ls_line( cap302, secure, &acctreg, crate::modules::multiline::max_bytes(s), crate::modules::multiline::max_lines(s), ); // STS: advertise the TLS-upgrade policy to 302 clients (opt-in via // sts_duration). On the plaintext port it names sts_port to move to; // on a TLS connection it just pins the duration. let sts_dur = s.conf_num("sts_duration", 0u64); if cap302 && sts_dur > 0 { let preload = if s.conf_bool("sts_preload", false) { ",preload" } else { "" }; if secure { payload.push_str(&format!(" sts=duration={sts_dur}{preload}")); } else { let sts_port = s.conf_num("sts_port", 0u16); if sts_port > 0 { payload.push_str(&format!( " sts=port={sts_port},duration={sts_dur}{preload}" )); } } } if !cap302 { s.send(uid, format!(":{} CAP {who} LS :{payload}", s.name)); } else { // 302: fold the token list into ≤512-byte lines, all but the last // carrying the `*` continuation marker. let budget = 500usize.saturating_sub(s.name.len() + who.len() + 12); let mut chunks: Vec = vec![String::new()]; for t in payload.split(' ').filter(|t| !t.is_empty()) { let cur = chunks.last_mut().unwrap(); if !cur.is_empty() && cur.len() + 1 + t.len() > budget { chunks.push(String::new()); } let cur = chunks.last_mut().unwrap(); if !cur.is_empty() { cur.push(' '); } cur.push_str(t); } let last = chunks.len() - 1; for (i, chunk) in chunks.iter().enumerate() { let more = if i < last { "* " } else { "" }; s.send(uid, format!(":{} CAP {who} LS {more}:{chunk}", s.name)); } } } "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. The ircd 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. With no services linked, 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()); // SASL is relayed to a linked services server (see `Server::sasl_relay`); // with none configured/linked it fails cleanly. let have_services = s.sasl_link().is_some(); match mech { // step 1 — the client picks a mechanism None => { if arg == "*" { s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted"); CmdResult::Ok } else if arg.eq_ignore_ascii_case("PLAIN") { if !have_services { s.numeric( uid, ERR_SASLFAIL, ":SASL authentication failed (services are not available)", ); return CmdResult::Fail; } if let Some(u) = s.users.get_mut(&uid) { u.sasl_mech = Some("PLAIN".to_string()); } // start the exchange at services; its `C` challenge is relayed // back to the client as the `AUTHENTICATE +` prompt s.sasl_relay(uid, "S PLAIN"); CmdResult::Ok } else if arg.eq_ignore_ascii_case("EXTERNAL") { // CertFP: only works on TLS with a client cert; the fingerprint // goes to services, which map it to an account. let certfp = s.users.get(&uid).and_then(|u| u.certfp.clone()); match certfp { Some(fp) if have_services => { if let Some(u) = s.users.get_mut(&uid) { u.sasl_mech = Some("EXTERNAL".to_string()); } // services replies with a `C` challenge we relay as the // client's `AUTHENTICATE +` prompt s.sasl_relay(uid, &format!("S EXTERNAL {fp}")); CmdResult::Ok } _ => { s.numeric( uid, ERR_SASLFAIL, ":SASL EXTERNAL requires a client certificate", ); CmdResult::Fail } } } 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 arg == "*" { if have_services { s.sasl_relay(uid, "D A"); } if let Some(u) = s.users.get_mut(&uid) { u.sasl_mech = None; } s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted"); return CmdResult::Ok; } if arg.len() > 400 { if let Some(u) = s.users.get_mut(&uid) { u.sasl_mech = None; } s.numeric(uid, ERR_SASLTOOLONG, ":SASL message too long"); return CmdResult::Fail; } if have_services { // relay the response; the verdict (900/903 or 904) comes back // over S2S in `Server::link_sasl`, which clears `sasl_mech`. s.sasl_relay(uid, &format!("C {arg}")); CmdResult::Ok } else { if let Some(u) = s.users.get_mut(&uid) { u.sasl_mech = None; } 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.conf_num("maxnick", 30usize)) { s.numeric( uid, ERR_ERRONEUSNICKNAME, &format!("{newnick} :Erroneous nickname"), ); return CmdResult::Fail; } // NICKLOCK: a services-held nick can't be changed by the user (opers bypass) if s.users .get(&uid) .map(|u| u.flags.nick_locked) .unwrap_or(false) && !s.is_oper(uid) { s.numeric( uid, ERR_CANTCHANGENICK, ":Your nickname is locked and cannot be changed", ); return CmdResult::Fail; } // Q-line / SVSHOLD: a reserved nick is refused (opers and services bypass) if !s.is_oper(uid) { if let Some(reason) = s.nick_reserved(newnick) { s.numeric( uid, ERR_ERRONEUSNICKNAME, &format!("{newnick} :Nickname is reserved: {reason}"), ); 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); // RLINE matchonnickchange: re-test the R-lines against the new identity if s.conf_bool("rline_matchonnickchange", false) { let info = s.users.get(&uid).map(|u| { ( u.nick.clone(), u.ident.clone(), u.host.clone(), u.addr.ip().to_string(), u.realname.clone(), ) }); if let Some((nk, id, ho, ip, rn)) = info { if let Some(reason) = s.matched_rline(&nk, &id, &ho, &ip, &rn) { s.send(uid, format!("ERROR :Closing link: ({reason})")); s.remove_user(uid, &reason); } } } 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 { // conn_waitpong: a pre-registration PONG may be answering our cookie crate::modules::conn_waitpong::on_pong(s, uid, params); CmdResult::Ok // otherwise just a keepalive } } struct Pass; impl Command for Pass { fn name(&self) -> &'static str { "PASS" } fn min_params(&self) -> usize { 1 } fn before_reg(&self) -> bool { true } fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { // stored for a connectclass password check at registration if let Some(u) = s.users.get_mut(&uid) { if u.registered { return CmdResult::Fail; // can't re-send PASS after registering } u.pass = Some(params[0].clone()); } CmdResult::Ok } } 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 } }