commit 9b1279177474f9977fd9b77f06087647bba54e3e Author: reverse Date: Wed Aug 5 16:10:31 2026 +0000 import echoircd — from-scratch irc daemon in native rust diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..439b82e --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/target +Cargo.lock + +# --- secrets: never commit --- +# real config holds the oper password, cloak_key and link secret +/echoircd.conf +# TLS certificate + private key +/tls/ +*.pem +*.key +# stray logs / local scratch +*.log diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..12caead --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "echoircd" +version = "0.1.0" +edition = "2021" +description = "A small, dependency-light IRC daemon in Rust — InspIRCd-inspired command/module API." +license = "MIT" + +[[bin]] +name = "echoircd" +path = "src/main.rs" + +[lib] +name = "echoircd" +path = "src/lib.rs" + +[dependencies] +# TLS backend (InspIRCd ships ssl_openssl / ssl_gnutls as modules; this is our +# openssl backend — the crate keeps all `unsafe` internal, so the daemon stays +# `#![forbid(unsafe_code)]`). A pure-Rust `rustls` backend can slot in beside it. +openssl = "0.10" + +[profile.release] +opt-level = 3 diff --git a/README.md b/README.md new file mode 100644 index 0000000..170263e --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# echoIRCd + +A from-scratch IRC daemon written in **native Rust**. The architecture is +*inspired by* InspIRCd's shape — commands as objects, modes as handler objects, +modules with lifecycle hooks — but every line is original Rust, not a port or a +translation. Design goals: `#![forbid(unsafe_code)]`, dependency-light (the only +external crate is `openssl`, for TLS), and lock-free (a single core thread owns +all state). + +> Status: early but capable. It boots, registers clients, and speaks a large +> chunk of the IRC + IRCv3 protocol (see **What works**). Not battle-tested yet. + +## Run it + +```sh +cp echoircd.conf.example echoircd.conf # then edit: oper pass, cloak_key, TLS paths +cargo run --release # reads ./echoircd.conf +# point a client at it, e.g. /server 127.0.0.1 6667 +``` + +Config is plain `key = value` (see `echoircd.conf.example`). Your real +`echoircd.conf` is gitignored because it holds secrets (oper password, cloak +key, link password) — never commit it. For TLS, generate a cert/key into `tls/` +(the example config has the one-liner). + +## Architecture + +A single **core thread** owns every `User` and `Channel`, so command and module +code is plain single-threaded logic over `&mut Server` — no `Arc>` +anywhere. I/O lives on cheap per-connection threads that talk to the core over +mpsc channels (a reader turns wire → events, a writer turns lines → wire). TLS +connections use one polling thread each (an `openssl` session can't be split +across reader+writer threads). + +Where this improves on the C++ original it's inspired by: `Uid` handles instead +of raw `User*` (no use-after-free, no cull list), an `Extensible` typemap instead +of `void*` module data (freed automatically on drop), `&str` slices instead of +`char*`, and compiled-in trait objects instead of a fragile `.so` ABI. + +### The two extension points + +- **Commands** (`src/command.rs`, `src/coremods/`) — a handler declares `name`, + `min_params`, `before_reg` and `handle(&mut Server, uid, params)`, registered + in `command_table()`. Adding a command is one struct + one table line. +- **Modes** (`src/mode.rs`) — channel/user modes are handler objects + (`ChanMode` / `UserMode`) in a table; adding a mode never touches the parser. +- **Modules** (`src/module.rs`, `src/modules/`) — lifecycle hooks. *Pre-hooks* + (`on_user_register`, `on_pre_command`, `on_pre_message`) return a `ModResult` + and can **Deny**; *notify-hooks* fire from a queue after the command. + +## What works + +- Registration (`CAP`/`NICK`/`USER`), `PING`/`PONG` with idle + registration + timeouts, welcome burst (001–005) + ISUPPORT. +- `JOIN`/`PART`/`NAMES`/`TOPIC`/`KICK`/`INVITE`, `PRIVMSG`/`NOTICE`/`TAGMSG`, + `NICK`, `WHO`/`WHOIS`/`WHOWAS`, `LIST`, `AWAY`, `QUIT`, `MOTD`/`LUSERS`. +- **Full mode set** as handler objects: prefixes `+qaohv`, lists `+beI`, and + `+klmntispzONCTcSRMGu` plus flood/rate modes `+f/+j/+F`, redirect `+L`, word + filter `+g`, and acting **extbans** `m:`/`c:`/`n:`. +- **IRC operators**: `OPER`/`KILL`/`WALLOPS`/`REHASH`/`GLOBOPS`, `SAJOIN`/`SAPART`/ + `SANICK`/`SAMODE`/`SATOPIC`/`SAKICK`, `CHGHOST`/`CHGIDENT`/`SETHOST`/`SETIDENT`, + `KLINE`/`GLINE`/`ZLINE` + `STATS`, snomasks (`+s`), `DIE`/`RESTART`. +- **IRCv3**: `CAP` negotiation, `server-time`, `message-tags` + **`msgid`**, + `multi-prefix`, `away-notify`, `account-notify`, `extended-join`, `chghost`, + `userhost-in-names`, `echo-message`, `invite-notify`, `setname`, + `extended-monitor`, `SASL` (PLAIN, relayed to services), `WATCH`/`MONITOR`, + `SILENCE`. +- **TLS** (openssl) with `sslinfo`; keyed-SHA-256 host **cloaking** (`+x`); + **services-ready accounts** (`SVSLOGIN`/`SVSLOGOUT`, account-gated `+r/+R/+M`) + — the ircd is *ready* for an external services package, it is not one itself. +- **Server-to-server linking**: handshake, UID/FJOIN netburst, cross-server + users and channels, nick-collision handling, netsplit. +- An **antimixedutf8** anti-spam module (blocks mixed-script look-alike spam). + +## Provenance + +echoIRCd is original Rust. InspIRCd is a reference for *behaviour and API shape* +only — no code is copied or translated. `scripts/native-rust-guard.sh` enforces +this (no `unsafe`, no C/FFI, openssl-only deps, no copy/translation wording). + +## License + +See the repository for licensing. diff --git a/echoircd.conf.example b/echoircd.conf.example new file mode 100644 index 0000000..3819a1c --- /dev/null +++ b/echoircd.conf.example @@ -0,0 +1,43 @@ +# echoIRCd config — simple key = value (repeat `motd` for extra lines). +# Copy this file to `echoircd.conf` and fill in your own values. +# NOTE: echoircd.conf is gitignored because it holds secrets (oper password, +# cloak key, link password). Never commit your real config. + +servername = irc.example.net +network = ExampleNet +bind = 0.0.0.0:6667 + +# TLS listener. Generate a cert/key first, e.g.: +# openssl req -x509 -newkey rsa:2048 -keyout tls/key.pem -out tls/cert.pem \ +# -days 3650 -nodes -subj "/CN=irc.example.net" +bind_tls = 0.0.0.0:6697 +tls_cert = ./tls/cert.pem +tls_key = ./tls/key.pem + +motd = Welcome to echoIRCd — a from-scratch IRC daemon in Rust. +motd = Edit the MOTD in your echoircd.conf. + +# --- server-to-server linking --- +sid = 0AA +serverdesc = echoIRCd server +bind_server = 0.0.0.0:7000 +# link = [autoconnect] (the password is a shared secret) +# link = peer.example.net 203.0.113.5 7000 CHANGE_THIS_LINK_SECRET autoconnect + +# IRC operators — oper = +oper = admin CHANGE_THIS_PASSWORD + +# host-cloaking secret (+x). Use a long random hex string; keep it private. +# Changing it re-cloaks everyone. +cloak_key = CHANGE_THIS_TO_A_LONG_RANDOM_HEX_STRING + +# antimixedutf8 — block spam that mixes look-alike scripts within words. +# action = block | kill | gline | kline | zline ; target = both | channel | private +antimixedutf8 = off +amu_threshold = 8 +amu_minlen = 10 +amu_action = block +amu_target = both + +# +G censor words: `badword = [replacement]` (omit replacement to block). +# badword = examplebadword *** diff --git a/scripts/native-rust-guard.sh b/scripts/native-rust-guard.sh new file mode 100755 index 0000000..e56286b --- /dev/null +++ b/scripts/native-rust-guard.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# native-rust-guard — echoIRCd's standing invariant. +# +# echoIRCd is ORIGINAL Rust. InspIRCd (and any other ircd) is a reference for +# BEHAVIOUR / protocol / API shape ONLY — never copied, never translated. Every +# module, command and core function is written natively in Rust. This guard fails +# if that slips. Run it any time: bash scripts/native-rust-guard.sh +# It is also wired into a Claude Code hook so it runs automatically on edits. +set -u +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" || exit 2 +fail=0 +flag() { echo "VIOLATION: $1" >&2; shift; [ "$#" -gt 0 ] && printf '%s\n' "$*" | sed 's/^/ /' >&2; fail=1; } + +# 1. no unsafe — the crate roots must forbid it (the compiler then enforces it) +for f in src/lib.rs src/main.rs; do + grep -q 'forbid(unsafe_code)' "$f" 2>/dev/null || flag "$f is missing #![forbid(unsafe_code)]" +done + +# 2. no copy / translation provenance language (inspiration words are fine: +# mirror / inspired / reference / same behaviour / answer to / unlike C++) +copy=$(grep -rniE 'faithful|transliterat|translated from|ported from|a port of|copied from|copy of the|line.?by.?line|1:1 (port|cop|translat|clone|rewrite)|verbatim (copy|from|port)' src/ 2>/dev/null) +[ -n "$copy" ] && flag "copy/translation wording (echoIRCd is original Rust, not a port):" "$copy" + +# 3. no C/C++ sources and no FFI — this is pure Rust +cpp=$(find src -type f \( -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.cxx' -o -name '*.c' -o -name '*.h' \) 2>/dev/null) +[ -n "$cpp" ] && flag "C/C++ source files present:" "$cpp" +ffi=$(grep -rnE 'extern[[:space:]]+"C"|\blibc::|std::ffi|#\[no_mangle\]' src/ 2>/dev/null) +[ -n "$ffi" ] && flag "FFI / foreign-function interface found:" "$ffi" + +# 4. dependency-light — only openssl is allowed as an external crate +deps=$(awk '/^\[dependencies\]/{f=1;next} /^\[/{f=0} f && NF {print}' Cargo.toml 2>/dev/null \ + | grep -vE '^[[:space:]]*#' | sed -E 's/[[:space:]=].*//' | grep -vE '^(openssl)?$') +[ -n "$deps" ] && flag "unexpected dependency (only openssl allowed):" "$deps" + +if [ "$fail" -eq 0 ]; then + echo "native-rust-guard: OK — original Rust, no-unsafe, no C/FFI, openssl-only." + exit 0 +fi +echo "native-rust-guard: FAILED — see violations above." >&2 +exit 2 diff --git a/src/accounts.rs b/src/accounts.rs new file mode 100644 index 0000000..9d9e329 --- /dev/null +++ b/src/accounts.rs @@ -0,0 +1,59 @@ +//! Account layer — the ircd's *services-ready* account support, modelled on +//! InspIRCd's `m_services_account`. **This is NOT a services daemon.** +//! +//! echoIRCd stores no passwords and runs no NickServ — registering nicks/channels +//! is a **services package**'s job (Anope/Atheme), linked in over S2S. What the +//! ircd owns is only the plumbing a service plugs into: +//! * a per-user **account name** (`User.account`) — the extension a service sets +//! or clears (InspIRCd's `accountname` metadata), which flips user mode `+r`; +//! * the account-gated **modes** (chan `+R`/`+M`, user `+r`/`+R`) that key off it +//! and live in [`crate::mode`]; +//! * the **interface** a service drives it through: [`Server::set_login`] / +//! [`Server::logout`], reached today via the oper/`SVSLOGIN` command and, once +//! S2S + SASL land, by a linked services pseudoserver. +//! +//! So a real network runs Anope *beside* echoIRCd; the ircd just has to be ready. + +use crate::server::Server; +use crate::Uid; + +impl Server { + pub fn is_logged_in(&self, uid: Uid) -> bool { + self.users + .get(&uid) + .map(|u| u.account.is_some()) + .unwrap_or(false) + } + + /// Log `uid` into `account` (services-driven): sets the account name, flips + /// `+r`, and reflects the mode back to the user. + pub fn set_login(&mut self, uid: Uid, account: &str) { + let (nick, prefix) = match self.users.get_mut(&uid) { + Some(u) => { + u.account = Some(account.to_string()); + u.flags.logged_in = true; + (u.nick.clone(), u.prefix()) + } + None => return, + }; + self.send(uid, format!(":{} MODE {nick} :+r", self.name)); + // account-notify + self.notify_peers(uid, &format!(":{prefix} ACCOUNT {account}"), |c| { + c.account_notify + }); + } + + /// Log `uid` out of any account (services-driven): clears `+r`. + pub fn logout(&mut self, uid: Uid) { + let (nick, prefix) = match self.users.get_mut(&uid) { + Some(u) if u.account.is_some() => { + u.account = None; + u.flags.logged_in = false; + (u.nick.clone(), u.prefix()) + } + _ => return, + }; + self.send(uid, format!(":{} MODE {nick} :-r", self.name)); + self.notify_peers(uid, &format!(":{prefix} ACCOUNT *"), |c| c.account_notify); + } +} diff --git a/src/channels.rs b/src/channels.rs new file mode 100644 index 0000000..3b88fb2 --- /dev/null +++ b/src/channels.rs @@ -0,0 +1,792 @@ +//! Channels: the `Channel` record, membership, channel modes, bans, invites and +//! JOIN/NAMES — the same job InspIRCd splits across channels/channelmanager, but +//! written from scratch in Rust (InspIRCd is a behaviour reference, not a source). + +use std::collections::{HashMap, HashSet}; + +use crate::module::Hook; +use crate::numeric::*; +use crate::server::{now, Server}; +use crate::Uid; + +/// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`]. +#[derive(Default)] +pub struct Member { + pub owner: bool, // +q (~) + pub admin: bool, // +a (&) + pub op: bool, // +o (@) + pub halfop: bool, // +h (%) + pub voice: bool, // +v (+) +} + +/// Prefix ranks, high→low — gate who may grant a prefix / kick whom. +pub const RANK_OWNER: u8 = 5; +pub const RANK_ADMIN: u8 = 4; +pub const RANK_OP: u8 = 3; +pub const RANK_HALFOP: u8 = 2; +pub const RANK_VOICE: u8 = 1; + +impl Member { + /// This member's numeric rank (0 = plain member). + pub fn rank(&self) -> u8 { + if self.owner { + RANK_OWNER + } else if self.admin { + RANK_ADMIN + } else if self.op { + RANK_OP + } else if self.halfop { + RANK_HALFOP + } else if self.voice { + RANK_VOICE + } else { + 0 + } + } + + /// Highest prefix char for NAMES (`""` for a plain member). + pub fn prefix_char(&self) -> &'static str { + if self.owner { + "~" + } else if self.admin { + "&" + } else if self.op { + "@" + } else if self.halfop { + "%" + } else if self.voice { + "+" + } else { + "" + } + } + + /// Set/clear a prefix mode by its letter (used by the S2S mode applier). + pub fn set_prefix(&mut self, letter: char, on: bool) { + match letter { + 'q' => self.owner = on, + 'a' => self.admin = on, + 'o' => self.op = on, + 'h' => self.halfop = on, + 'v' => self.voice = on, + _ => {} + } + } + + /// Every prefix char this member holds, high→low (for the `multi-prefix` cap). + pub fn all_prefixes(&self) -> String { + let mut s = String::new(); + for (on, c) in [ + (self.owner, '~'), + (self.admin, '&'), + (self.op, '@'), + (self.halfop, '%'), + (self.voice, '+'), + ] { + if on { + s.push(c); + } + } + s + } +} + +pub struct Topic { + pub text: String, + pub setter: String, + pub ts: u64, +} + +/// A +b ban: a `nick!user@host` glob, who set it and when. +pub struct Ban { + pub mask: String, + pub setter: String, + pub ts: u64, +} + +/// +f message flood: `[*]lines:secs` — kick past `lines` msgs in `secs` (and set +/// a +b ban too when `ban`, from the leading `*`). +#[derive(Clone)] +pub struct MsgFlood { + pub lines: u32, + pub secs: u64, + pub ban: bool, +} + +/// A `count:secs` rate, shared by +j (join flood) and +F (nick-change flood). +#[derive(Clone)] +pub struct Rate { + pub count: u32, + pub secs: u64, +} + +/// Channel modes other than the per-member prefixes. +#[derive(Default)] +pub struct ChanModes { + pub moderated: bool, // +m — only +o/+v may speak + pub topic_ops: bool, // +t — only ops may set the topic + pub no_external: bool, // +n — must be a member to message it + pub invite_only: bool, // +i + pub secret: bool, // +s + pub key: Option, // +k + pub limit: Option, // +l + pub secure_only: bool, // +z — only TLS-connected users may join + pub private: bool, // +p — private (hidden from WHOIS channel list) + pub oper_only: bool, // +O — only IRC operators may join + pub no_nick: bool, // +N — members can't change nick while here + pub no_ctcp: bool, // +C — block CTCP to the channel + pub no_notice: bool, // +T — block NOTICEs to the channel + pub no_color: bool, // +c — reject messages with formatting/colour + pub strip_color: bool, // +S — strip formatting/colour from messages + pub reg_only: bool, // +R — only logged-in (account) users may join + pub reg_moderated: bool, // +M — only logged-in users may speak + pub censor: bool, // +G — replace configured bad words + pub auditorium: bool, // +u — hide non-ops from non-ops + pub flood: Option, // +f + pub joinflood: Option, // +j + pub nickflood: Option, // +F + pub redirect: Option, // +L <#target> — when full, send there +} + +impl ChanModes { + /// Set/clear a no-parameter flag mode by its letter (the S2S mode applier). + pub fn set_by_letter(&mut self, c: char, on: bool) { + match c { + 'm' => self.moderated = on, + 'n' => self.no_external = on, + 't' => self.topic_ops = on, + 'i' => self.invite_only = on, + 's' => self.secret = on, + 'z' => self.secure_only = on, + 'p' => self.private = on, + 'O' => self.oper_only = on, + 'N' => self.no_nick = on, + 'C' => self.no_ctcp = on, + 'T' => self.no_notice = on, + 'c' => self.no_color = on, + 'S' => self.strip_color = on, + 'R' => self.reg_only = on, + 'M' => self.reg_moderated = on, + 'G' => self.censor = on, + 'u' => self.auditorium = on, + _ => {} + } + } + + /// `+mnt`, or with `params` the +k/+l arguments too: `+ntkl secret 20`. + pub fn render(&self, params: bool) -> String { + let mut s = String::from("+"); + for (on, ch) in [ + (self.invite_only, 'i'), + (self.moderated, 'm'), + (self.no_external, 'n'), + (self.secret, 's'), + (self.topic_ops, 't'), + (self.secure_only, 'z'), + (self.private, 'p'), + (self.oper_only, 'O'), + (self.no_nick, 'N'), + (self.no_ctcp, 'C'), + (self.no_notice, 'T'), + (self.no_color, 'c'), + (self.strip_color, 'S'), + (self.reg_only, 'R'), + (self.reg_moderated, 'M'), + (self.censor, 'G'), + (self.auditorium, 'u'), + ] { + if on { + s.push(ch); + } + } + if self.key.is_some() { + s.push('k'); + } + if self.limit.is_some() { + s.push('l'); + } + if self.flood.is_some() { + s.push('f'); + } + if self.joinflood.is_some() { + s.push('j'); + } + if self.nickflood.is_some() { + s.push('F'); + } + if self.redirect.is_some() { + s.push('L'); + } + if params { + if let Some(k) = &self.key { + s.push(' '); + s.push_str(k); + } + if let Some(l) = self.limit { + s.push(' '); + s.push_str(&l.to_string()); + } + if let Some(f) = &self.flood { + let star = if f.ban { "*" } else { "" }; + s.push_str(&format!(" {star}{}:{}", f.lines, f.secs)); + } + if let Some(j) = &self.joinflood { + s.push_str(&format!(" {}:{}", j.count, j.secs)); + } + if let Some(n) = &self.nickflood { + s.push_str(&format!(" {}:{}", n.count, n.secs)); + } + if let Some(t) = &self.redirect { + s.push(' '); + s.push_str(t); + } + } + s + } +} + +pub struct Channel { + pub name: String, // display casing + pub topic: Option, + pub members: HashMap, + pub rmembers: HashMap, // remote members, by network uuid (S2S) + pub modes: ChanModes, + pub bans: Vec, + pub excepts: Vec, // +e ban exceptions + pub invex: Vec, // +I invite exceptions + pub filters: Vec, // +g word/glob message filters (mask = the glob) + pub invites: HashSet, // uids allowed past +i + pub created: u64, + // --- ephemeral flood counters (not modes; never rendered or synced) ------- + pub msgflood_hits: HashMap>, // +f per-user message times + pub joinflood_hits: Vec, // +j recent join times + pub joinflood_until: u64, // +j locked out until this unix ts + pub nickflood_hits: Vec, // +F recent nick-change times + pub nickflood_until: u64, // +F locked out until this unix ts +} + +impl Channel { + /// A fresh channel with the default `+nt` modes. + pub fn new(name: &str) -> Channel { + Channel { + name: name.to_string(), + topic: None, + members: HashMap::new(), + rmembers: HashMap::new(), + modes: ChanModes { + no_external: true, + topic_ops: true, + ..Default::default() + }, + bans: Vec::new(), + excepts: Vec::new(), + invex: Vec::new(), + filters: Vec::new(), + invites: HashSet::new(), + created: now(), + msgflood_hits: HashMap::new(), + joinflood_hits: Vec::new(), + joinflood_until: 0, + nickflood_hits: Vec::new(), + nickflood_until: 0, + } + } + + /// True once no local *and* no remote members remain (safe to drop). + pub fn is_empty(&self) -> bool { + self.members.is_empty() && self.rmembers.is_empty() + } +} + +impl Server { + /// A member's channel rank (0 if not a member). + pub fn rank(&self, uid: Uid, key: &str) -> u8 { + // SAMODE/SAKICK run as the server: every access check keys off rank(), + // so a transient sudo makes them bypass the ladder cleanly. + if self.mode_sudo { + return RANK_OWNER; + } + self.channels + .get(key) + .and_then(|c| c.members.get(&uid)) + .map(|m| m.rank()) + .unwrap_or(0) + } + + pub fn is_op(&self, uid: Uid, key: &str) -> bool { + self.rank(uid, key) >= RANK_OP + } + + pub fn is_member(&self, uid: Uid, key: &str) -> bool { + self.channels + .get(key) + .map(|c| c.members.contains_key(&uid)) + .unwrap_or(false) + } + + /// Join a user to a channel (creating it if new, giving the creator +o), + /// then broadcast JOIN and send TOPIC + NAMES. Queues the join hook. + pub fn join(&mut self, uid: Uid, name: &str, key_arg: Option<&str>) { + if !valid_chan(name) { + self.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{name} :No such channel")); + return; + } + let key = name.to_ascii_lowercase(); + if self + .users + .get(&uid) + .map(|u| u.channels.contains(&key)) + .unwrap_or(true) + { + return; // unknown user, or already joined + } + // an existing channel can refuse the join (+k / +b / +i / +l) + if let Some(ch) = self.channels.get(&key) { + if let Some(k) = &ch.modes.key { + if key_arg != Some(k.as_str()) { + self.numeric( + uid, + ERR_BADCHANNELKEY, + &format!("{name} :Cannot join channel (+k)"), + ); + return; + } + } + // +b — bans block even an invited user, unless a +e exception matches + let mask = self.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); + if ch.bans.iter().any(|b| glob_match(&b.mask, &mask)) + && !ch.excepts.iter().any(|e| glob_match(&e.mask, &mask)) + { + self.numeric( + uid, + ERR_BANNEDFROMCHAN, + &format!("{name} :Cannot join channel (+b)"), + ); + return; + } + // +i — unless invited or matched by a +I invite exception + if ch.modes.invite_only + && !ch.invites.contains(&uid) + && !ch.invex.iter().any(|e| glob_match(&e.mask, &mask)) + { + self.numeric( + uid, + ERR_INVITEONLYCHAN, + &format!("{name} :Cannot join channel (+i)"), + ); + return; + } + // +z — TLS-connected users only + if ch.modes.secure_only && !self.users.get(&uid).map(|u| u.secure).unwrap_or(false) { + self.numeric( + uid, + ERR_SECUREONLYCHAN, + &format!("{name} :Cannot join channel; TLS users only (+z is set)"), + ); + return; + } + // +O — IRC operators only + if ch.modes.oper_only && !self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false) { + self.numeric( + uid, + ERR_CANTJOINOPERSONLY, + &format!("{name} :Cannot join channel; IRC operators only (+O is set)"), + ); + return; + } + // +R — must be logged into an account (services-registered) + if ch.modes.reg_only + && self + .users + .get(&uid) + .map(|u| u.account.is_none()) + .unwrap_or(true) + { + self.numeric( + uid, + ERR_NEEDREGGEDNICK, + &format!("{name} :Cannot join channel; you must be logged in (+R is set)"), + ); + return; + } + } + // +l full — with +L redirect, bounce the user to the target instead + if let Some(ch) = self.channels.get(&key) { + let full = ch.modes.limit.is_some_and(|l| ch.members.len() as u32 >= l); + let redirect = ch.modes.redirect.clone(); + if full { + match redirect { + Some(t) + if !self.in_redirect + && t.to_ascii_lowercase() != key + && !self.is_member(uid, &t.to_ascii_lowercase()) => + { + self.numeric( + uid, + ERR_LINKCHANNEL, + &format!("{name} {t} :Cannot join channel (+l), redirecting"), + ); + self.in_redirect = true; + self.join(uid, &t, None); + self.in_redirect = false; + return; + } + _ => { + self.numeric( + uid, + ERR_CHANNELISFULL, + &format!("{name} :Cannot join channel (+l)"), + ); + return; + } + } + } + } + // +j join flood — once tripped, the channel locks new joins out for 60s + if self.channels.contains_key(&key) && self.joinflood_check(&key) { + self.numeric( + uid, + ERR_UNAVAILRESOURCE, + &format!("{name} :This channel is temporarily unavailable (+j join flood)"), + ); + return; + } + let is_new = !self.channels.contains_key(&key); + let ch = self + .channels + .entry(key.clone()) + .or_insert_with(|| Channel::new(name)); + ch.members.insert( + uid, + Member { + op: is_new, + ..Default::default() + }, + ); + ch.invites.remove(&uid); // consume any pending invite + if let Some(u) = self.users.get_mut(&uid) { + u.channels.insert(key.clone()); + } + + // JOIN broadcast — extended-join clients also get the account + realname + let (prefix, acct, realname) = { + let u = &self.users[&uid]; + ( + u.prefix(), + u.account.clone().unwrap_or_else(|| "*".to_string()), + u.realname.clone(), + ) + }; + let plain = format!(":{prefix} JOIN {name}"); + let extended = format!(":{prefix} JOIN {name} {acct} :{realname}"); + let aud = self.channels[&key].modes.auditorium; + let members: Vec = self.channels[&key].members.keys().copied().collect(); + for m in members { + // +u auditorium: non-op members don't see other users join + if aud && m != uid && self.rank(m, &key) < RANK_OP { + continue; + } + let ext = self + .users + .get(&m) + .map(|u| u.caps.extended_join) + .unwrap_or(false); + self.send(m, if ext { extended.clone() } else { plain.clone() }); + } + if let Some(t) = self.channels[&key].topic.as_ref() { + let text = t.text.clone(); + self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}")); + } + self.send_names(uid, &key); + self.propagate_join(uid, name); // tell linked servers this user joined + self.events.push_back(Hook::Join(uid, key)); + } + + pub fn send_names(&self, uid: Uid, key: &str) { + let Some(ch) = self.channels.get(key) else { + self.numeric(uid, RPL_ENDOFNAMES, &format!("{key} :End of /NAMES list")); + return; + }; + // multi-prefix → all prefixes; userhost-in-names → full nick!user@host + let (multi, uhost) = self + .users + .get(&uid) + .map(|u| (u.caps.multi_prefix, u.caps.userhost_in_names)) + .unwrap_or((false, false)); + // +u auditorium: a non-op viewer only sees ops (plus themselves) + let hide = + ch.modes.auditorium && ch.members.get(&uid).map(|m| m.rank()).unwrap_or(0) < RANK_OP; + let mut names = String::new(); + for (m, flags) in &ch.members { + if hide && *m != uid && flags.rank() < RANK_OP { + continue; + } + let p = if multi { + flags.all_prefixes() + } else { + flags.prefix_char().to_string() + }; + if let Some(u) = self.users.get(m) { + names.push_str(&p); + let shown = if uhost { u.prefix() } else { u.nick.clone() }; + names.push_str(&shown); + names.push(' '); + } + } + // remote members (users on linked servers) + for (ruuid, mem) in &ch.rmembers { + if hide && mem.rank() < RANK_OP { + continue; + } + if let Some(ru) = self.remote_users.get(ruuid) { + let p = if multi { + mem.all_prefixes() + } else { + mem.prefix_char().to_string() + }; + names.push_str(&p); + let shown = if uhost { ru.prefix() } else { ru.nick.clone() }; + names.push_str(&shown); + names.push(' '); + } + } + self.numeric( + uid, + RPL_NAMREPLY, + &format!("= {} :{}", ch.name, names.trim_end()), + ); + self.numeric( + uid, + RPL_ENDOFNAMES, + &format!("{} :End of /NAMES list", ch.name), + ); + } + + /// True if `uid` is caught by an acting extban of type `kind` (`m`/`c`/`n`) on + /// `key` with no matching `kind:` exception in +e. The stored mask is + /// `kind:`; we glob the hostmask part against the user's prefix. + pub fn extban_active(&self, uid: Uid, key: &str, kind: char) -> bool { + let Some(ch) = self.channels.get(key) else { + return false; + }; + let Some(who) = self.users.get(&uid).map(|u| u.prefix()) else { + return false; + }; + let pfx = format!("{kind}:"); + let hit = |list: &Vec| { + list.iter() + .filter(|b| b.mask.starts_with(&pfx)) + .any(|b| glob_match(&b.mask[2..], &who)) + }; + hit(&ch.bans) && !hit(&ch.excepts) + } + + /// Broadcast a membership line, honouring +u auditorium: when set, non-op + /// members other than `actor` don't see it. `actor` always receives it. + pub fn to_channel_vis(&self, key: &str, line: &str, actor: Uid) { + if let Some(ch) = self.channels.get(key) { + let aud = ch.modes.auditorium; + for (&uid, m) in &ch.members { + if aud && uid != actor && m.rank() < RANK_OP { + continue; + } + self.send(uid, line.to_string()); + } + } + } + + /// +f: record a channel message from `uid`; `Some(ban)` when they just went + /// over the limit (ban ⇒ also set a +b). Members at half-op+ and opers are + /// exempt (checked by the caller). + pub fn messageflood_hit(&mut self, uid: Uid, key: &str) -> Option { + let n = now(); + let ch = self.channels.get_mut(key)?; + let f = ch.modes.flood.clone()?; + let v = ch.msgflood_hits.entry(uid).or_default(); + v.retain(|&t| n.saturating_sub(t) < f.secs); + v.push(n); + if v.len() as u32 > f.lines { + ch.msgflood_hits.remove(&uid); + Some(f.ban) + } else { + None + } + } + + /// Kick `uid` from `key` for flooding (optionally banning `*!*@host` first), + /// broadcasting the KICK and queuing the part hook. + pub fn flood_kick(&mut self, uid: Uid, key: &str, ban: bool) { + let (nick, host) = match self.users.get(&uid) { + Some(u) => (u.nick.clone(), u.host_display().to_string()), + None => return, + }; + let cname = self + .channels + .get(key) + .map(|c| c.name.clone()) + .unwrap_or_else(|| key.to_string()); + if ban { + let mask = format!("*!*@{host}"); + if let Some(c) = self.channels.get_mut(key) { + if !c.bans.iter().any(|b| b.mask == mask) { + c.bans.push(Ban { + mask, + setter: self.name.clone(), + ts: now(), + }); + } + } + } + self.to_channel( + key, + &format!(":{} KICK {cname} {nick} :Flood", self.name), + None, + ); + self.propagate_from_user(uid, &format!("KICK {cname} {nick} :Flood")); + if let Some(c) = self.channels.get_mut(key) { + c.members.remove(&uid); + } + if let Some(u) = self.users.get_mut(&uid) { + u.channels.remove(key); + } + self.channels.retain(|_, c| !c.is_empty()); + self.events + .push_back(Hook::Part(uid, key.to_string(), "flood".to_string())); + } + + /// +j: record a join attempt on `key`; true if joins are (now) locked out. + pub fn joinflood_check(&mut self, key: &str) -> bool { + let n = now(); + let Some(ch) = self.channels.get_mut(key) else { + return false; + }; + let Some(f) = ch.modes.joinflood.clone() else { + return false; + }; + if n < ch.joinflood_until { + return true; // still locked out + } + ch.joinflood_hits.retain(|&t| n.saturating_sub(t) < f.secs); + ch.joinflood_hits.push(n); + if ch.joinflood_hits.len() as u32 > f.count { + ch.joinflood_until = n + 60; // lock the channel for 60s + ch.joinflood_hits.clear(); + return true; + } + false + } + + /// +F: record a nick change and return the display name of any channel that + /// is (now) locked out — the caller denies the change if so. Opers exempt. + pub fn nickflood_blocked(&mut self, uid: Uid) -> Option { + let n = now(); + let keys: Vec = self + .users + .get(&uid) + .map(|u| u.channels.iter().cloned().collect()) + .unwrap_or_default(); + let mut blocked = None; + for key in keys { + let Some(ch) = self.channels.get_mut(&key) else { + continue; + }; + let Some(f) = ch.modes.nickflood.clone() else { + continue; + }; + if n < ch.nickflood_until { + blocked.get_or_insert_with(|| ch.name.clone()); + continue; + } + ch.nickflood_hits.retain(|&t| n.saturating_sub(t) < f.secs); + ch.nickflood_hits.push(n); + if ch.nickflood_hits.len() as u32 > f.count { + ch.nickflood_until = n + 60; + ch.nickflood_hits.clear(); + blocked.get_or_insert_with(|| ch.name.clone()); + } + } + blocked + } +} + +/// A channel name starts with `#`, is ≤ 50 chars, and has no space/comma/control. +pub fn valid_chan(name: &str) -> bool { + name.starts_with('#') + && name.len() > 1 + && name.len() <= 50 + && !name + .chars() + .any(|c| c == ' ' || c == ',' || (c as u32) < 0x20) +} + +/// Case-insensitive glob (`*` = any run, `?` = one char) — for +b mask matching. +pub fn glob_match(pat: &str, s: &str) -> bool { + let p: Vec = pat.to_lowercase().chars().collect(); + let t: Vec = s.to_lowercase().chars().collect(); + let (mut pi, mut ti) = (0usize, 0usize); + let mut star: Option = None; + let mut mark = 0usize; + while ti < t.len() { + if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == '*' { + star = Some(pi); + mark = ti; + pi += 1; + } else if let Some(sp) = star { + pi = sp + 1; + mark += 1; + ti = mark; + } else { + return false; + } + } + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + pi == p.len() +} + +/// Fill out a ban mask to `nick!user@host` form (`bob` → `bob!*@*`). +pub fn normalize_mask(m: &str) -> String { + match (m.contains('!'), m.contains('@')) { + (true, true) => m.to_string(), + (false, true) => format!("*!{m}"), + (true, false) => format!("{m}@*"), + (false, false) => format!("{m}!*@*"), + } +} + +/// Like [`normalize_mask`] but aware of extbans: `m:bob` normalises only the +/// value after the `X:` prefix, so acting bans (`m:` mute, `c:` nocolor, +/// `n:` nonick) keep their type while their hostmask is filled out. +pub fn normalize_ban_mask(m: &str) -> String { + let b = m.as_bytes(); + if b.len() >= 2 && b[1] == b':' && (b[0] as char).is_ascii_alphabetic() { + return format!("{}:{}", &m[..1], normalize_mask(&m[2..])); + } + normalize_mask(m) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn member_rank_and_prefix_char_take_the_highest() { + let mut m = Member::default(); + assert_eq!(m.rank(), 0); + assert_eq!(m.prefix_char(), ""); + m.voice = true; + assert_eq!((m.rank(), m.prefix_char()), (RANK_VOICE, "+")); + m.halfop = true; + assert_eq!((m.rank(), m.prefix_char()), (RANK_HALFOP, "%")); + m.op = true; + assert_eq!((m.rank(), m.prefix_char()), (RANK_OP, "@")); + m.admin = true; + assert_eq!((m.rank(), m.prefix_char()), (RANK_ADMIN, "&")); + m.owner = true; + assert_eq!((m.rank(), m.prefix_char()), (RANK_OWNER, "~")); + } +} diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..3c0bb09 --- /dev/null +++ b/src/command.rs @@ -0,0 +1,29 @@ +//! The command API — echoIRCd's answer to InspIRCd's `Command` class. +//! +//! A command is a stateless handler registered by name. The core validates +//! `min_params` and the registration gate (`before_reg`) before calling +//! [`Command::handle`], which gets `&mut Server` and does the work. + +use crate::server::Server; +use crate::Uid; + +/// Outcome of a command (mirrors InspIRCd's `CmdResult`, minus server-only bits). +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum CmdResult { + Ok, + Fail, +} + +pub trait Command: Send { + /// The command name, upper-case (also its registry key). + fn name(&self) -> &'static str; + /// Minimum parameters; fewer ⇒ the core replies `461` and skips the handler. + fn min_params(&self) -> usize { + 0 + } + /// May this run before the client has registered (NICK/USER/CAP/PING/QUIT)? + fn before_reg(&self) -> bool { + false + } + fn handle(&self, srv: &mut Server, uid: Uid, params: &[String]) -> CmdResult; +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8a8ecbd --- /dev/null +++ b/src/config.rs @@ -0,0 +1,193 @@ +//! Tiny `key = value` config, same spirit as rubot.conf (no XML, no deps). +//! +//! ```text +//! servername = echo.devtronic.pro +//! network = echoNet +//! bind = 127.0.0.1:6767 +//! motd = Welcome to echoIRCd +//! oper = god secret +//! ``` + +/// A server-link block: how to authenticate a peer named `name` (and, if +/// `autoconnect`, where to dial it). Passwords are the shared link secret. +#[derive(Clone)] +pub struct LinkBlock { + pub name: String, + pub ip: String, + pub port: u16, + pub password: String, + pub autoconnect: bool, +} + +/// Config for the `antimixedutf8` module (blocks mixed-script look-alike spam). +#[derive(Clone)] +pub struct AntiMixedCfg { + pub enable: bool, + pub threshold: u32, + pub minlen: usize, + pub action: String, // block | kill | gline | kline | zline + pub duration: u64, // seconds, for the *line actions + pub reason: String, + pub block_msg: String, // notice text sent on the "block" action + pub check_channel: bool, + pub check_private: bool, +} + +impl Default for AntiMixedCfg { + fn default() -> AntiMixedCfg { + AntiMixedCfg { + enable: false, + threshold: 8, + minlen: 10, + action: "block".to_string(), + duration: 3600, + reason: "Mixed-script text (spam).".to_string(), + block_msg: "Your message contains mixed look-alike characters often used by \ + spam. Please rewrite it and try again." + .to_string(), + check_channel: true, + check_private: true, + } + } +} + +#[derive(Clone)] +pub struct Config { + pub servername: String, + pub network: String, + pub bind: String, + pub bind_tls: Option, // e.g. 0.0.0.0:6697 — the TLS listener + pub tls_cert: Option, // PEM certificate chain + pub tls_key: Option, // PEM private key + pub motd: Vec, + pub opers: Vec<(String, String)>, // (name, password) + pub cloak_key: Option, // secret key for host cloaking (+x); None = off + pub sid: String, // this server's 3-char server id (S2S) + pub serverdesc: String, // this server's description + pub bind_server: Option, // the server-to-server link listener + pub links: Vec, // peers we accept / dial + pub conf_path: String, // where this was loaded from (for REHASH) + pub censor: Vec<(String, String)>, // +G bad words: (find, replace); empty replace = block + pub amu: AntiMixedCfg, // antimixedutf8 module config +} + +impl Default for Config { + fn default() -> Config { + Config { + servername: "echo.local".to_string(), + network: "echoNet".to_string(), + bind: "127.0.0.1:6767".to_string(), + bind_tls: None, + tls_cert: None, + tls_key: None, + motd: Vec::new(), + opers: Vec::new(), + cloak_key: None, + sid: "0AA".to_string(), + serverdesc: "echoIRCd server".to_string(), + bind_server: None, + links: Vec::new(), + conf_path: "echoircd.conf".to_string(), + censor: Vec::new(), + amu: AntiMixedCfg::default(), + } + } +} + +impl Config { + /// Read a config file, falling back to defaults for anything missing. A + /// missing file is not an error — you get the defaults. + pub fn load(path: &str) -> Config { + let mut c = Config { + conf_path: path.to_string(), + ..Config::default() + }; + let Ok(text) = std::fs::read_to_string(path) else { + return c; + }; + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + let Some((k, v)) = line.split_once('=') else { + continue; + }; + let (k, v) = (k.trim(), v.trim()); + match k { + "servername" | "server" => c.servername = v.to_string(), + "network" => c.network = v.to_string(), + "bind" => c.bind = v.to_string(), + "bind_tls" => c.bind_tls = Some(v.to_string()), + "tls_cert" => c.tls_cert = Some(v.to_string()), + "tls_key" => c.tls_key = Some(v.to_string()), + "cloak_key" => c.cloak_key = Some(v.to_string()), + "sid" => c.sid = v.to_string(), + "serverdesc" | "description" => c.serverdesc = v.to_string(), + "bind_server" => c.bind_server = Some(v.to_string()), + "link" => { + // link = [autoconnect] + let t: Vec<&str> = v.split_whitespace().collect(); + if t.len() >= 4 { + if let Ok(port) = t[2].parse::() { + c.links.push(LinkBlock { + name: t[0].to_string(), + ip: t[1].to_string(), + port, + password: t[3].to_string(), + autoconnect: t.get(4).is_some_and(|a| { + a.eq_ignore_ascii_case("autoconnect") + || a.eq_ignore_ascii_case("connect") + }), + }); + } + } + } + "motd" => c.motd.push(v.to_string()), + "oper" => { + let mut it = v.split_whitespace(); + if let (Some(n), Some(p)) = (it.next(), it.next()) { + c.opers.push((n.to_string(), p.to_string())); + } + } + // +G censor word: `badword = [replace]` (no replace ⇒ block) + "badword" => { + let mut it = v.splitn(2, char::is_whitespace); + if let Some(find) = it.next().filter(|f| !f.is_empty()) { + let replace = it.next().unwrap_or("").trim().to_string(); + c.censor.push((find.to_string(), replace)); + } + } + "antimixedutf8" | "amu" => { + c.amu.enable = + matches!(v.to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1") + } + "amu_threshold" => { + if let Ok(n) = v.parse() { + c.amu.threshold = n; + } + } + "amu_minlen" => { + if let Ok(n) = v.parse() { + c.amu.minlen = n; + } + } + "amu_action" => c.amu.action = v.to_string(), + "amu_duration" => { + if let Some(d) = crate::xline::parse_duration(v) { + c.amu.duration = d; + } + } + "amu_reason" => c.amu.reason = v.to_string(), + "amu_message" => c.amu.block_msg = v.to_string(), + "amu_target" => { + let t = v.to_ascii_lowercase(); + c.amu.check_channel = t == "both" || t == "channel"; + c.amu.check_private = t == "both" || t == "private"; + } + _ => {} + } + } + c + } +} diff --git a/src/coremods/core_channel.rs b/src/coremods/core_channel.rs new file mode 100644 index 0000000..9575090 --- /dev/null +++ b/src/coremods/core_channel.rs @@ -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> { + vec![ + Box::new(Join), + Box::new(Part), + Box::new(Kick), + Box::new(TopicCmd), + Box::new(Names), + Box::new(Invite), + Box::new(Knock), + Box::new(Cycle), + Box::new(Remove), + ] +} + +/// KNOCK — ask for an invite to an invite-only channel. +struct Knock; +impl Command for Knock { + fn name(&self) -> &'static str { + "KNOCK" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let chan = ¶ms[0]; + let key = chan.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel")); + return CmdResult::Fail; + } + let can = s.channels[&key].modes.invite_only && !s.is_member(uid, &key); + if !can { + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + s.send( + uid, + format!( + ":{} NOTICE {nick} :Can't KNOCK on {chan} (not invite-only, or you're on it)", + s.name + ), + ); + return CmdResult::Fail; + } + let reason = params + .get(1) + .cloned() + .unwrap_or_else(|| "requesting an invite".to_string()); + let who = s.users[&uid].prefix(); + s.to_channel( + &key, + &format!( + ":{} NOTICE {chan} :[Knock] {who} is knocking: {reason}", + s.name + ), + None, + ); + s.numeric( + uid, + RPL_KNOCKDLVR, + &format!("{chan} :Your KNOCK has been delivered"), + ); + CmdResult::Ok + } +} + +/// CYCLE — part and immediately rejoin a channel. +struct Cycle; +impl Command for Cycle { + fn name(&self) -> &'static str { + "CYCLE" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let chan = ¶ms[0]; + let key = chan.to_ascii_lowercase(); + if !s.is_member(uid, &key) { + s.numeric( + uid, + ERR_NOTONCHANNEL, + &format!("{chan} :You're not on that channel"), + ); + return CmdResult::Fail; + } + let prefix = s.users[&uid].prefix(); + s.to_channel(&key, &format!(":{prefix} PART {chan} :cycling"), None); + s.propagate_part(uid, chan, "cycling"); + if let Some(ch) = s.channels.get_mut(&key) { + ch.members.remove(&uid); + } + if let Some(u) = s.users.get_mut(&uid) { + u.channels.remove(&key); + } + s.channels.retain(|_, c| !c.is_empty()); + s.join(uid, chan, None); + CmdResult::Ok + } +} + +/// REMOVE — like KICK, but the target sees a PART (a softer removal). +struct Remove; +impl Command for Remove { + fn name(&self) -> &'static str { + "REMOVE" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let (chan, victim) = (¶ms[0], ¶ms[1]); + let key = chan.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel")); + return CmdResult::Fail; + } + if s.rank(uid, &key) < RANK_HALFOP { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You're not a channel operator"), + ); + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(victim) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{victim} :No such nick/channel"), + ); + return CmdResult::Fail; + }; + if !s.channels[&key].members.contains_key(&tuid) { + s.numeric( + uid, + ERR_USERNOTINCHANNEL, + &format!("{victim} {chan} :They aren't on that channel"), + ); + return CmdResult::Fail; + } + if s.rank(uid, &key) < s.rank(tuid, &key) { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You cannot remove a user of higher rank"), + ); + return CmdResult::Fail; + } + let by = s.users[&uid].nick.clone(); + let reason = match params.get(2) { + Some(r) => format!("Removed by {by}: {r}"), + None => format!("Removed by {by}"), + }; + let prefix = s.users[&tuid].prefix(); + s.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), None); + s.propagate_part(tuid, chan, &reason); + if let Some(ch) = s.channels.get_mut(&key) { + ch.members.remove(&tuid); + } + if let Some(u) = s.users.get_mut(&tuid) { + u.channels.remove(&key); + } + s.channels.retain(|_, c| !c.is_empty()); + CmdResult::Ok + } +} + +struct Invite; +impl Command for Invite { + fn name(&self) -> &'static str { + "INVITE" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let (tnick, chan) = (¶ms[0], ¶ms[1]); + let key = chan.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel")); + return CmdResult::Fail; + } + if !s.is_member(uid, &key) { + s.numeric( + uid, + ERR_NOTONCHANNEL, + &format!("{chan} :You're not on that channel"), + ); + return CmdResult::Fail; + } + // only ops may invite into an +i channel + if s.channels[&key].modes.invite_only && !s.is_op(uid, &key) { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You're not a channel operator"), + ); + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(tnick) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{tnick} :No such nick/channel"), + ); + return CmdResult::Fail; + }; + if s.channels[&key].members.contains_key(&tuid) { + s.numeric( + uid, + ERR_USERONCHANNEL, + &format!("{tnick} {chan} :is already on channel"), + ); + return CmdResult::Fail; + } + if let Some(ch) = s.channels.get_mut(&key) { + ch.invites.insert(tuid); + } + let who = s.users[&tuid].nick.clone(); + s.numeric(uid, RPL_INVITING, &format!("{who} {chan}")); + let prefix = s.users[&uid].prefix(); + s.send(tuid, format!(":{prefix} INVITE {who} :{chan}")); + // invite-notify: tell capable channel members about the invite + let notify = format!(":{prefix} INVITE {who} {chan}"); + let members: Vec = s.channels[&key].members.keys().copied().collect(); + for m in members { + if m != uid + && s.users + .get(&m) + .map(|u| u.caps.invite_notify) + .unwrap_or(false) + { + s.send(m, notify.clone()); + } + } + CmdResult::Ok + } +} + +struct Join; +impl Command for Join { + fn name(&self) -> &'static str { + "JOIN" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let keys: Vec<&str> = params + .get(1) + .map(|k| k.split(',').collect()) + .unwrap_or_default(); + for (i, name) in params[0].split(',').filter(|x| !x.is_empty()).enumerate() { + s.join(uid, name, keys.get(i).copied()); + } + CmdResult::Ok + } +} + +struct Part; +impl Command for Part { + fn name(&self) -> &'static str { + "PART" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let reason = params.get(1).cloned().unwrap_or_default(); + for target in params[0].split(',').filter(|x| !x.is_empty()) { + let key = target.to_ascii_lowercase(); + let on = s + .users + .get(&uid) + .map(|u| u.channels.contains(&key)) + .unwrap_or(false); + if !on { + s.numeric( + uid, + ERR_NOTONCHANNEL, + &format!("{target} :You're not on that channel"), + ); + continue; + } + let prefix = s.users[&uid].prefix(); + let line = if reason.is_empty() { + format!(":{prefix} PART {target}") + } else { + format!(":{prefix} PART {target} :{reason}") + }; + s.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part + s.propagate_part(uid, target, &reason); // tell linked servers + if let Some(ch) = s.channels.get_mut(&key) { + ch.members.remove(&uid); + } + if let Some(u) = s.users.get_mut(&uid) { + u.channels.remove(&key); + } + s.channels.retain(|_, c| !c.is_empty()); + s.events.push_back(Hook::Part(uid, key, reason.clone())); + } + CmdResult::Ok + } +} + +struct Kick; +impl Command for Kick { + fn name(&self) -> &'static str { + "KICK" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let (chan, victim) = (¶ms[0], ¶ms[1]); + let key = chan.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel")); + return CmdResult::Fail; + } + if s.rank(uid, &key) < RANK_HALFOP { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You're not a channel operator"), + ); + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(victim) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{victim} :No such nick/channel"), + ); + return CmdResult::Fail; + }; + if !s.channels[&key].members.contains_key(&tuid) { + s.numeric( + uid, + ERR_USERNOTINCHANNEL, + &format!("{victim} {chan} :They aren't on that channel"), + ); + return CmdResult::Fail; + } + // can't kick someone who out-ranks you + if s.rank(uid, &key) < s.rank(tuid, &key) { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You cannot kick a user of higher rank"), + ); + return CmdResult::Fail; + } + let kicker = s.users[&uid].nick.clone(); + let reason = params.get(2).cloned().unwrap_or(kicker); + let prefix = s.users[&uid].prefix(); + s.to_channel( + &key, + &format!(":{prefix} KICK {chan} {victim} :{reason}"), + None, + ); + s.propagate_from_user(uid, &format!("KICK {chan} {victim} :{reason}")); // tell links + if let Some(ch) = s.channels.get_mut(&key) { + ch.members.remove(&tuid); + } + if let Some(u) = s.users.get_mut(&tuid) { + u.channels.remove(&key); + } + s.channels.retain(|_, c| !c.is_empty()); + s.events + .push_back(Hook::Part(tuid, key, "kicked".to_string())); + CmdResult::Ok + } +} + +struct TopicCmd; +impl Command for TopicCmd { + fn name(&self) -> &'static str { + "TOPIC" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let target = ¶ms[0]; + let key = target.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric( + uid, + ERR_NOSUCHCHANNEL, + &format!("{target} :No such channel"), + ); + return CmdResult::Fail; + } + if params.len() < 2 { + match s.channels[&key].topic.as_ref() { + Some(t) => { + let text = t.text.clone(); + s.numeric(uid, RPL_TOPIC, &format!("{target} :{text}")); + } + None => s.numeric(uid, RPL_NOTOPIC, &format!("{target} :No topic is set")), + } + return CmdResult::Ok; + } + let on = s + .users + .get(&uid) + .map(|u| u.channels.contains(&key)) + .unwrap_or(false); + if !on { + s.numeric( + uid, + ERR_NOTONCHANNEL, + &format!("{target} :You're not on that channel"), + ); + return CmdResult::Fail; + } + // +t: only ops may set the topic + if s.channels[&key].modes.topic_ops && !s.is_op(uid, &key) { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{target} :You're not a channel operator"), + ); + return CmdResult::Fail; + } + let text = params[1].clone(); + let (prefix, setter) = { + let u = &s.users[&uid]; + (u.prefix(), u.nick.clone()) + }; + if let Some(ch) = s.channels.get_mut(&key) { + ch.topic = Some(Topic { + text: text.clone(), + setter, + ts: now(), + }); + } + s.to_channel(&key, &format!(":{prefix} TOPIC {target} :{text}"), None); + s.propagate_from_user(uid, &format!("TOPIC {target} :{text}")); // tell links + CmdResult::Ok + } +} + +struct Names; +impl Command for Names { + fn name(&self) -> &'static str { + "NAMES" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + for target in params[0].split(',').filter(|x| !x.is_empty()) { + s.send_names(uid, &target.to_ascii_lowercase()); + } + CmdResult::Ok + } +} diff --git a/src/coremods/core_extra.rs b/src/coremods/core_extra.rs new file mode 100644 index 0000000..b0bcbb1 --- /dev/null +++ b/src/coremods/core_extra.rs @@ -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> { + 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 = s.channels.keys().cloned().collect(); + for key in keys { + let ch = &s.channels[&key]; + // hide secret / private channels from non-members + if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) { + continue; + } + let count = ch.members.len() + ch.rmembers.len(); + let topic = ch + .topic + .as_ref() + .map(|t| t.text.clone()) + .unwrap_or_default(); + let name = ch.name.clone(); + s.numeric(uid, RPL_LIST, &format!("{name} {count} :{topic}")); + } + s.numeric(uid, RPL_LISTEND, ":End of /LIST"); + CmdResult::Ok + } +} + +struct Whowas; +impl Command for Whowas { + fn name(&self) -> &'static str { + "WHOWAS" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let target = ¶ms[0]; + let want = target.to_ascii_lowercase(); + let limit = params + .get(1) + .and_then(|c| c.parse::().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 = 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 = 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 = 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 = 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 = 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 + } +} diff --git a/src/coremods/core_info.rs b/src/coremods/core_info.rs new file mode 100644 index 0000000..7cbdff1 --- /dev/null +++ b/src/coremods/core_info.rs @@ -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> { + vec![ + Box::new(Whois), + Box::new(Who), + Box::new(Lusers), + Box::new(Motd), + Box::new(VersionCmd), + Box::new(Links), + ] +} + +/// LINKS — the servers this one knows about (itself + every linked peer). +struct Links; +impl Command for Links { + fn name(&self) -> &'static str { + "LINKS" + } + fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult { + s.numeric( + uid, + RPL_LINKS, + &format!("{} {} :0 {}", s.name, s.name, s.server_desc), + ); + let mut rows: Vec<(String, String)> = s + .servers + .values() + .map(|sv| (sv.name.clone(), sv.desc.clone())) + .collect(); + rows.sort(); + for (name, desc) in rows { + s.numeric(uid, RPL_LINKS, &format!("{name} {} :1 {desc}", s.name)); + } + s.numeric(uid, RPL_ENDOFLINKS, "* :End of /LINKS list"); + CmdResult::Ok + } +} + +struct Whois; +impl Command for Whois { + fn name(&self) -> &'static str { + "WHOIS" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let tnick = params[0] + .split(',') + .next() + .unwrap_or(¶ms[0]) + .to_string(); + let Some(tuid) = s.find_nick(&tnick) else { + // maybe they're on another server + if let Some((uuid, _)) = s.find_remote(&tnick) { + if let Some(ru) = s.remote_users.get(&uuid) { + let srv = s + .servers + .get(&ru.sid) + .map(|sv| sv.name.clone()) + .unwrap_or_else(|| ru.sid.clone()); + s.numeric( + uid, + RPL_WHOISUSER, + &format!("{} {} {} * :{}", ru.nick, ru.ident, ru.host, ru.realname), + ); + s.numeric( + uid, + RPL_WHOISSERVER, + &format!("{} {srv} :remote user", ru.nick), + ); + if let Some(a) = &ru.account { + s.numeric( + uid, + RPL_WHOISACCOUNT, + &format!("{} {a} :is logged in as", ru.nick), + ); + } + s.numeric( + uid, + RPL_ENDOFWHOIS, + &format!("{} :End of /WHOIS list", ru.nick), + ); + return CmdResult::Ok; + } + } + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{tnick} :No such nick/channel"), + ); + s.numeric(uid, RPL_ENDOFWHOIS, &format!("{tnick} :End of /WHOIS list")); + return CmdResult::Fail; + }; + let asker_oper = s.is_oper(uid); + let is_self = tuid == uid; + let keys: Vec = 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 = keys + .iter() + .filter_map(|k| s.channels.get(k).map(|c| c.name.clone())) + .collect(); + s.numeric( + uid, + RPL_WHOISUSER, + &format!("{nick} {ident} {disp} * :{realname}"), + ); + if bot { + s.numeric(uid, RPL_WHOISBOT, &format!("{nick} :is a bot")); + } + s.numeric( + uid, + RPL_WHOISSERVER, + &format!("{nick} {} :echoIRCd", s.name), + ); + // +I hides the channel list from everyone but the user themselves + opers + if !chans.is_empty() && (is_self || asker_oper || !hidechans) { + s.numeric( + uid, + RPL_WHOISCHANNELS, + &format!("{nick} :{}", chans.join(" ")), + ); + } + // 313: is an IRC operator (hidden by +H unless the asker is an oper) + if oper && (!hideoper || asker_oper) { + s.numeric( + uid, + RPL_WHOISOPERATOR, + &format!("{nick} :is an IRC operator"), + ); + } + // opers can see through the cloak to the real host/ip + if asker_oper && disp != realhost { + s.numeric( + uid, + RPL_WHOISHOST, + &format!("{nick} :is connecting from {ident}@{realhost} {realip}"), + ); + } + // 330: logged in to a services account + if let Some(acct) = &account { + s.numeric( + uid, + RPL_WHOISACCOUNT, + &format!("{nick} {acct} :is logged in as"), + ); + } + // sslinfo: advertise a secure (TLS) connection + if secure { + s.numeric( + uid, + RPL_WHOISSECURE, + &format!("{nick} :is using a secure connection"), + ); + } + // 317: idle time + signon time + let idle = crate::server::now().saturating_sub(last_active); + s.numeric( + uid, + RPL_WHOISIDLE, + &format!("{nick} {idle} {signon} :seconds idle, signon time"), + ); + s.numeric(uid, RPL_ENDOFWHOIS, &format!("{nick} :End of /WHOIS list")); + CmdResult::Ok + } +} + +struct Who; +impl Command for Who { + fn name(&self) -> &'static str { + "WHO" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let target = ¶ms[0]; + if target.starts_with('#') { + let key = target.to_ascii_lowercase(); + let multi = s + .users + .get(&uid) + .map(|u| u.caps.multi_prefix) + .unwrap_or(false); + let rows: Vec<(Uid, String, String)> = match s.channels.get(&key) { + Some(ch) => { + let name = ch.name.clone(); + ch.members + .iter() + .map(|(&m, mem)| { + let p = if multi { + mem.all_prefixes() + } else { + mem.prefix_char().to_string() + }; + (m, name.clone(), p) + }) + .collect() + } + None => Vec::new(), + }; + for (m, name, pfx) in rows { + if let Some(u) = s.users.get(&m) { + let row = format!( + "{name} {} {} {} {} H{pfx} :0 {}", + u.ident, + u.host_display(), + s.name, + u.nick, + u.realname + ); + s.numeric(uid, RPL_WHOREPLY, &row); + } + } + } else if let Some(tuid) = s.find_nick(target) { + let u = &s.users[&tuid]; + let row = format!( + "* {} {} {} {} H :0 {}", + u.ident, + u.host_display(), + s.name, + u.nick, + u.realname + ); + s.numeric(uid, RPL_WHOREPLY, &row); + } + s.numeric(uid, RPL_ENDOFWHO, &format!("{target} :End of /WHO list")); + CmdResult::Ok + } +} + +struct Lusers; +impl Command for Lusers { + fn name(&self) -> &'static str { + "LUSERS" + } + fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult { + s.numeric( + uid, + RPL_LUSERCLIENT, + &format!(":There are {} users on 1 server", s.users.len()), + ); + CmdResult::Ok + } +} + +struct Motd; +impl Command for Motd { + fn name(&self) -> &'static str { + "MOTD" + } + fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult { + s.send_motd(uid); + CmdResult::Ok + } +} + +struct VersionCmd; +impl Command for VersionCmd { + fn name(&self) -> &'static str { + "VERSION" + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult { + s.send( + uid, + format!(":{} 351 * echoircd-{VERSION} {} :", s.name, s.name), + ); + CmdResult::Ok + } +} diff --git a/src/coremods/core_message.rs b/src/coremods/core_message.rs new file mode 100644 index 0000000..c89a998 --- /dev/null +++ b/src/coremods/core_message.rs @@ -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 = 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 = hay.chars().collect(); + let f: Vec = 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 = hay.chars().collect(); + let f: Vec = 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 { + 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> { + vec![Box::new(PrivMsg), Box::new(Notice), Box::new(TagMsg)] +} + +/// Shared PRIVMSG/NOTICE delivery. NOTICE never generates automatic replies. +fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResult { + let cmd = if notice { "NOTICE" } else { "PRIVMSG" }; + if params.is_empty() { + if !notice { + s.numeric( + uid, + ERR_NORECIPIENT, + &format!(":No recipient given ({cmd})"), + ); + } + return CmdResult::Fail; + } + if params.len() < 2 || params[1].is_empty() { + if !notice { + s.numeric(uid, ERR_NOTEXTTOSEND, ":No text to send"); + } + return CmdResult::Fail; + } + let (target, text) = (¶ms[0], ¶ms[1]); + let Some(prefix) = s.users.get(&uid).map(|u| u.prefix()) else { + return CmdResult::Fail; + }; + if target.starts_with('#') { + let key = target.to_ascii_lowercase(); + let member = s + .channels + .get(&key) + .map(|c| c.members.contains_key(&uid)) + .unwrap_or(false); + if !member { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Cannot send to channel"), + ); + } + return CmdResult::Fail; + } + // +m: only voiced-or-above may speak + let moderated = s + .channels + .get(&key) + .map(|c| c.modes.moderated) + .unwrap_or(false); + if moderated && s.rank(uid, &key) < RANK_VOICE { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Cannot send to channel (+m)"), + ); + } + return CmdResult::Fail; + } + // +M: only logged-in (account) users may speak (voiced-or-above exempt) + let reg_moderated = s + .channels + .get(&key) + .map(|c| c.modes.reg_moderated) + .unwrap_or(false); + if reg_moderated && s.rank(uid, &key) < RANK_VOICE && !s.is_logged_in(uid) { + if !notice { + s.numeric( + uid, + ERR_NEEDREGGEDNICK, + &format!("{target} :You must be logged into an account to speak here (+M)"), + ); + } + return CmdResult::Fail; + } + // extban `m:` mute — matched users can't speak unless voiced-or-above + if s.extban_active(uid, &key, 'm') && s.rank(uid, &key) < RANK_VOICE { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Cannot send to channel (you're muted, +b m:)"), + ); + } + return CmdResult::Fail; + } + // +f message flood — ops/half-ops and opers are exempt; others get kicked + let flood_exempt = s.rank(uid, &key) >= RANK_HALFOP + || s.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false); + if !flood_exempt { + if let Some(ban) = s.messageflood_hit(uid, &key) { + s.flood_kick(uid, &key, ban); + return CmdResult::Fail; + } + } + // content-based modes: +C no CTCP, +T no notices, +c no colour, +S strip + let (no_ctcp, no_notice, no_color, strip) = s + .channels + .get(&key) + .map(|c| { + ( + c.modes.no_ctcp, + c.modes.no_notice, + c.modes.no_color, + c.modes.strip_color, + ) + }) + .unwrap_or_default(); + if notice && no_notice { + return CmdResult::Fail; // +T — NOTICEs are silently dropped + } + if no_ctcp && is_ctcp(text) && !is_action(text) { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :CTCP is disabled (+C)"), + ); + } + return CmdResult::Fail; + } + if no_color && has_formatting(text) { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Formatting/colour is disabled (+c)"), + ); + } + return CmdResult::Fail; + } + // extban `c:` no-colour — matched users can't send formatting + if s.extban_active(uid, &key, 'c') && has_formatting(text) { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Formatting/colour is disabled for you (+b c:)"), + ); + } + return CmdResult::Fail; + } + // +g channel filter — block messages matching any word/glob (use *word*) + let filtered = s + .channels + .get(&key) + .map(|c| c.filters.iter().any(|f| glob_match(&f.mask, text))) + .unwrap_or(false); + if filtered { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Cannot send to channel (blocked by +g filter)"), + ); + } + return CmdResult::Fail; + } + let mut body = if strip { + strip_formatting(text) + } else { + text.clone() + }; + // +G censor — replace configured bad words (empty replacement ⇒ block) + let censor_on = s + .channels + .get(&key) + .map(|c| c.modes.censor) + .unwrap_or(false); + if censor_on && !s.censor.is_empty() { + match apply_censor(&body, &s.censor) { + Some(b) => body = b, + None => { + if !notice { + s.numeric( + uid, + ERR_CANNOTSENDTOCHAN, + &format!("{target} :Cannot send to channel (+G censor)"), + ); + } + return CmdResult::Fail; + } + } + } + // deliver to every member except the sender and +D (deaf) users, tagging + // per-recipient (server-time + any client-only tags on the line) + let line = format!(":{prefix} {cmd} {target} :{body}"); + let ctags = s.line_ctags.clone(); + let msgid = s.next_msgid(); // one id shared by every recipient of this message + let members: Vec = s + .channels + .get(&key) + .map(|c| c.members.keys().copied().collect()) + .unwrap_or_default(); + for m in members { + if m == uid || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) { + continue; + } + s.send_tagged(m, &ctags, &msgid, &line); + } + // echo-message: give the sender their own copy if they asked for one + if s.users + .get(&uid) + .map(|u| u.caps.echo_message) + .unwrap_or(false) + { + s.send_tagged(uid, &ctags, &msgid, &line); + } + // propagate to linked servers that have members in this channel + s.send_channel_to_links(uid, &key, target, cmd, &body); + } else if let Some(tuid) = s.find_nick(target) { + // user +R (regdeaf): drop messages from users not logged into an account + if s.users + .get(&tuid) + .map(|u| u.flags.reg_only_pm) + .unwrap_or(false) + && !s.is_logged_in(uid) + { + if !notice { + let tn = s + .users + .get(&tuid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + s.numeric( + uid, + ERR_NEEDREGGEDNICK, + &format!("{tn} :You must be logged into an account to message this user (+R)"), + ); + } + return CmdResult::Fail; + } + // user +z (sslqueries): only TLS users may PM them + if s.users.get(&tuid).map(|u| u.flags.ssl_pm).unwrap_or(false) + && !s.users.get(&uid).map(|u| u.secure).unwrap_or(false) + { + if !notice { + let (tn, sn) = ( + s.users + .get(&tuid) + .map(|u| u.nick.clone()) + .unwrap_or_default(), + s.users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(), + ); + s.send( + uid, + format!( + ":{} NOTICE {sn} :Cannot message {tn}: a TLS connection is required (+z)", + s.name + ), + ); + } + return CmdResult::Fail; + } + // SILENCE: if the recipient silenced the sender, drop it silently — the + // sender is never told (that's the point), but still gets their own echo. + let silenced = s.is_silenced(tuid, &prefix); + let pm = format!(":{prefix} {cmd} {target} :{text}"); + let ctags = s.line_ctags.clone(); + let msgid = s.next_msgid(); + if !silenced { + s.send_tagged(tuid, &ctags, &msgid, &pm); + } + if s.users + .get(&uid) + .map(|u| u.caps.echo_message) + .unwrap_or(false) + { + s.send_tagged(uid, &ctags, &msgid, &pm); + } + // if the recipient is away, tell the sender (PRIVMSG only, not if silenced) + if !notice && !silenced { + if let Some(msg) = s.users.get(&tuid).and_then(|u| u.flags.away.clone()) { + s.numeric(uid, RPL_AWAY, &format!("{target} :{msg}")); + } + } + } else if let Some((uuid, via)) = s.find_remote(target) { + // the target is a user on another server — route it across the link + s.send_to_remote(uid, &uuid, via, cmd, text); + if s.users + .get(&uid) + .map(|u| u.caps.echo_message) + .unwrap_or(false) + { + let ctags = s.line_ctags.clone(); + let msgid = s.next_msgid(); + s.send_tagged( + uid, + &ctags, + &msgid, + &format!(":{prefix} {cmd} {target} :{text}"), + ); + } + } else if !notice { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{target} :No such nick/channel"), + ); + } + CmdResult::Ok +} + +struct PrivMsg; +impl Command for PrivMsg { + fn name(&self) -> &'static str { + "PRIVMSG" + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + deliver(s, uid, params, false) + } +} + +struct Notice; +impl Command for Notice { + fn name(&self) -> &'static str { + "NOTICE" + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + deliver(s, uid, params, true) + } +} + +/// TAGMSG — an IRCv3 message that carries only client tags (typing, reactions, …) +/// and no text. Relayed to targets whose clients enabled `message-tags`; clients +/// without it never see it. Mirrors PRIVMSG's target / membership / +m rules. +struct TagMsg; +impl Command for TagMsg { + fn name(&self) -> &'static str { + "TAGMSG" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let ctags = s.line_ctags.clone(); + if ctags.is_empty() { + return CmdResult::Ok; // no client tags -> nothing to relay + } + let target = ¶ms[0]; + let Some(prefix) = s.users.get(&uid).map(|u| u.prefix()) else { + return CmdResult::Fail; + }; + let body = format!(":{prefix} TAGMSG {target}"); + let msgid = s.next_msgid(); // shared across this TAGMSG's recipients + if target.starts_with('#') { + let key = target.to_ascii_lowercase(); + if !s + .channels + .get(&key) + .map(|c| c.members.contains_key(&uid)) + .unwrap_or(false) + { + return CmdResult::Fail; + } + // +m: only voiced-or-above may emit tags + let moderated = s + .channels + .get(&key) + .map(|c| c.modes.moderated) + .unwrap_or(false); + if moderated && s.rank(uid, &key) < RANK_VOICE { + return CmdResult::Fail; + } + let echo = s + .users + .get(&uid) + .map(|u| u.caps.echo_message) + .unwrap_or(false); + let members: Vec = 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")); + } +} diff --git a/src/coremods/core_mode.rs b/src/coremods/core_mode.rs new file mode 100644 index 0000000..bcc91ea --- /dev/null +++ b/src/coremods/core_mode.rs @@ -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> { + vec![Box::new(Mode)] +} + +/// Append one mode change to the echo string, emitting the +/- only when it flips. +fn emit(applied: &mut String, last: &mut char, sign: char, c: char) { + if *last != sign { + applied.push(sign); + *last = sign; + } + applied.push(c); +} + +struct Mode; +impl Command for Mode { + fn name(&self) -> &'static str { + "MODE" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + apply_mode(s, uid, params) + } +} + +/// The MODE body, shared with SAMODE (which wraps it in `Server::mode_sudo` so +/// the rank gates below all pass — see `core_oper::SaMode`). +pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let target = ¶ms[0]; + if !target.starts_with('#') { + return apply_user_modes(s, uid, target, params); + } + let key = target.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric( + uid, + ERR_NOSUCHCHANNEL, + &format!("{target} :No such channel"), + ); + return CmdResult::Fail; + } + // query: `MODE #c` + if params.len() < 2 { + let modestr = s.channels[&key].modes.render(s.is_member(uid, &key)); + s.numeric(uid, RPL_CHANNELMODEIS, &format!("{target} {modestr}")); + let created = s.channels[&key].created; + s.numeric(uid, RPL_CREATIONTIME, &format!("{target} {created}")); + return CmdResult::Ok; + } + // setting modes needs at least half-op; each handler then enforces its + // own finer rule (prefixes need enough rank, +z needs all-secure, …) + if s.rank(uid, &key) < RANK_HALFOP { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{target} :You're not a channel operator"), + ); + return CmdResult::Fail; + } + + // dispatch each mode letter to its handler + let modestring = params[1].clone(); + let args = ¶ms[2..]; + let mut argi = 0usize; + let mut sign = '+'; + let mut applied = String::new(); + let mut last = ' '; + let mut echoed: Vec = 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 +} diff --git a/src/coremods/core_oper.rs b/src/coremods/core_oper.rs new file mode 100644 index 0000000..95acbe4 --- /dev/null +++ b/src/coremods/core_oper.rs @@ -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> { + vec![ + Box::new(Oper), + Box::new(Kill), + Box::new(Wallops), + Box::new(SvsLogin), + Box::new(SvsLogout), + Box::new(Rehash), + Box::new(GlobOps), + Box::new(SaJoin), + Box::new(SaPart), + Box::new(SaNick), + Box::new(Die), + Box::new(Restart), + Box::new(Kline), + Box::new(Gline), + Box::new(Zline), + Box::new(ChgHost), + Box::new(ChgIdent), + Box::new(SetHost), + Box::new(SetIdent), + Box::new(SaMode), + Box::new(SaTopic), + Box::new(SaKick), + ] +} + +/// Reject non-opers with 481; returns whether the caller is an oper. +fn require_oper(s: &mut Server, uid: Uid) -> bool { + if s.is_oper(uid) { + return true; + } + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- You're not an IRC operator", + ); + false +} + +struct Oper; +impl Command for Oper { + fn name(&self) -> &'static str { + "OPER" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let (name, pass) = (¶ms[0], ¶ms[1]); + if s.opers.iter().any(|(n, p)| n == name && p == pass) { + s.oper_up(uid); + CmdResult::Ok + } else { + s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect"); + CmdResult::Fail + } + } +} + +struct Kill; +impl Command for Kill { + fn name(&self) -> &'static str { + "KILL" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.is_oper(uid) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- You're not an IRC operator", + ); + return CmdResult::Fail; + } + let target = ¶ms[0]; + let reason = params + .get(1) + .cloned() + .unwrap_or_else(|| "Killed".to_string()); + let Some(tuid) = s.find_nick(target) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{target} :No such nick/channel"), + ); + return CmdResult::Fail; + }; + let killer = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + s.send( + tuid, + format!(":{} KILL {target} :{killer} ({reason})", s.name), + ); + s.remove_user(tuid, &format!("Killed by {killer}: {reason}")); + CmdResult::Ok + } +} + +/// SVSLOGIN / SVSLOGOUT — the **services interface** to the account layer +/// ([`crate::accounts`]). Over S2S these arrive from a services pseudoserver +/// (Anope/Atheme); until S2S exists an oper may invoke them to drive `+r` and the +/// account-gated channel modes. `SVSLOGIN ` logs a user in +/// (`account` of `*`/`0` logs out); `SVSLOGOUT ` logs them out. +struct SvsLogin; +impl Command for SvsLogin { + fn name(&self) -> &'static str { + "SVSLOGIN" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.is_oper(uid) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- SVSLOGIN is a services command", + ); + return CmdResult::Fail; + } + let (target, account) = (¶ms[0], ¶ms[1]); + let Some(tuid) = s.find_nick(target) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{target} :No such nick/channel"), + ); + return CmdResult::Fail; + }; + if account == "*" || account == "0" { + s.logout(tuid); + } else { + s.set_login(tuid, account); + } + CmdResult::Ok + } +} + +struct SvsLogout; +impl Command for SvsLogout { + fn name(&self) -> &'static str { + "SVSLOGOUT" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.is_oper(uid) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- SVSLOGOUT is a services command", + ); + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(¶ms[0]) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{} :No such nick/channel", params[0]), + ); + return CmdResult::Fail; + }; + s.logout(tuid); + CmdResult::Ok + } +} + +struct Wallops; +impl Command for Wallops { + fn name(&self) -> &'static str { + "WALLOPS" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.is_oper(uid) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Permission Denied- You're not an IRC operator", + ); + return CmdResult::Fail; + } + let from = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + s.wallops(&from, ¶ms[0]); + CmdResult::Ok + } +} + +/// REHASH — reload the config file (MOTD, oper blocks, cloak key). +struct Rehash; +impl Command for Rehash { + fn name(&self) -> &'static str { + "REHASH" + } + fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let fresh = Config::load(&s.conf_path); + s.motd = fresh.motd; + s.opers = fresh.opers; + s.cloak_key = fresh.cloak_key; + s.censor = fresh.censor; + s.amu = fresh.amu; + s.numeric(uid, RPL_REHASHING, &format!("{} :Rehashing", s.conf_path)); + CmdResult::Ok + } +} + +/// GLOBOPS — a message to every IRC operator. +struct GlobOps; +impl Command for GlobOps { + fn name(&self) -> &'static str { + "GLOBOPS" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let from = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + let opers: Vec = s + .users + .iter() + .filter(|(_, u)| u.flags.oper) + .map(|(&u, _)| u) + .collect(); + for o in opers { + let nick = s.users.get(&o).map(|u| u.nick.clone()).unwrap_or_default(); + s.send( + o, + format!( + ":{} NOTICE {nick} :*** GLOBOPS from {from}: {}", + s.name, params[0] + ), + ); + } + CmdResult::Ok + } +} + +/// SAJOIN — force a user into a channel. +struct SaJoin; +impl Command for SaJoin { + fn name(&self) -> &'static str { + "SAJOIN" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(¶ms[0]) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{} :No such nick/channel", params[0]), + ); + return CmdResult::Fail; + }; + s.join(tuid, ¶ms[1], None); + CmdResult::Ok + } +} + +/// SAPART — force a user out of a channel. +struct SaPart; +impl Command for SaPart { + fn name(&self) -> &'static str { + "SAPART" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(¶ms[0]) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{} :No such nick/channel", params[0]), + ); + return CmdResult::Fail; + }; + let chan = ¶ms[1]; + let key = chan.to_ascii_lowercase(); + let reason = params + .get(2) + .cloned() + .unwrap_or_else(|| "Removed".to_string()); + if s.is_member(tuid, &key) { + let prefix = s.users[&tuid].prefix(); + s.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), None); + s.propagate_part(tuid, chan, &reason); + if let Some(ch) = s.channels.get_mut(&key) { + ch.members.remove(&tuid); + } + if let Some(u) = s.users.get_mut(&tuid) { + u.channels.remove(&key); + } + s.channels.retain(|_, c| !c.is_empty()); + } + CmdResult::Ok + } +} + +/// SANICK — force a user's nickname. +struct SaNick; +impl Command for SaNick { + fn name(&self) -> &'static str { + "SANICK" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(¶ms[0]) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{} :No such nick/channel", params[0]), + ); + return CmdResult::Fail; + }; + let newnick = ¶ms[1]; + if !valid_nick(newnick) { + s.numeric( + uid, + ERR_ERRONEUSNICKNAME, + &format!("{newnick} :Erroneous nickname"), + ); + return CmdResult::Fail; + } + if s.find_nick(newnick).is_some() + || s.remote_nick.contains_key(&newnick.to_ascii_lowercase()) + { + s.numeric( + uid, + ERR_NICKNAMEINUSE, + &format!("{newnick} :Nickname is already in use"), + ); + return CmdResult::Fail; + } + s.set_nick(tuid, newnick); + CmdResult::Ok + } +} + +/// DIE — shut the server down (requires the server name as confirmation). +struct Die; +impl Command for Die { + fn name(&self) -> &'static str { + "DIE" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + if params[0] != s.name { + s.numeric(uid, ERR_NOPRIVILEGES, ":DIE requires the server name"); + return CmdResult::Fail; + } + let by = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + eprintln!("[oper] DIE by {by}"); + std::process::exit(0); + } +} + +/// RESTART — like DIE (a supervisor is expected to relaunch us). +struct Restart; +impl Command for Restart { + fn name(&self) -> &'static str { + "RESTART" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + if params[0] != s.name { + s.numeric(uid, ERR_NOPRIVILEGES, ":RESTART requires the server name"); + return CmdResult::Fail; + } + let by = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + eprintln!("[oper] RESTART by {by}"); + std::process::exit(0); + } +} + +/// Shared KLINE/GLINE/ZLINE handling: the mask alone removes, mask+duration adds. +fn do_xline(s: &mut Server, uid: Uid, params: &[String], kind: XKind) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let mask = params[0].clone(); + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + if params.len() < 2 { + let word = if s.remove_xline(kind, &mask) { + "removed" + } else { + "not found" + }; + s.send( + uid, + format!( + ":{} NOTICE {nick} :{}-line {word}: {mask}", + s.name, + kind.tag() + ), + ); + return CmdResult::Ok; + } + let dur = parse_duration(¶ms[1]).unwrap_or(0); + let reason = params + .get(2) + .cloned() + .unwrap_or_else(|| "No reason given".to_string()); + s.add_xline(kind, &mask, dur, &nick, &reason); + CmdResult::Ok +} + +/// KLINE — ban a `user@host` mask on this server. +struct Kline; +impl Command for Kline { + fn name(&self) -> &'static str { + "KLINE" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + do_xline(s, uid, params, XKind::Kline) + } +} + +/// GLINE — a network-wide `user@host` ban. +struct Gline; +impl Command for Gline { + fn name(&self) -> &'static str { + "GLINE" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + do_xline(s, uid, params, XKind::Gline) + } +} + +/// ZLINE — ban an IP address (glob). +struct Zline; +impl Command for Zline { + fn name(&self) -> &'static str { + "ZLINE" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + do_xline(s, uid, params, XKind::Zline) + } +} + +/// Resolve a nick to a uid, sending ERR_NOSUCHNICK if it's unknown. +fn oper_target(s: &mut Server, uid: Uid, nick: &str) -> Option { + match s.find_nick(nick) { + Some(t) => Some(t), + None => { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{nick} :No such nick/channel"), + ); + None + } + } +} + +/// A server NOTICE to the invoking oper (soft errors for the CHG*/SA* set). +fn onotice(s: &mut Server, uid: Uid, msg: &str) { + let nick = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_else(|| "*".to_string()); + s.send(uid, format!(":{} NOTICE {nick} :{msg}", s.name)); +} + +/// The oper's nick, for audit snotices. +fn oper_nick(s: &Server, uid: Uid) -> String { + s.users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default() +} + +/// CHGHOST — change another user's displayed host. +struct ChgHost; +impl Command for ChgHost { + fn name(&self) -> &'static str { + "CHGHOST" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + if !valid_host(¶ms[1]) { + onotice(s, uid, "*** CHGHOST: invalid characters in hostname"); + return CmdResult::Fail; + } + let Some(t) = oper_target(s, uid, ¶ms[0]) else { + return CmdResult::Fail; + }; + s.change_host_ident(t, None, Some(¶ms[1])); + let by = oper_nick(s, uid); + s.snotice(&format!( + "{by} used CHGHOST on {}: {}", + params[0], params[1] + )); + CmdResult::Ok + } +} + +/// SETHOST — change your own displayed host. +struct SetHost; +impl Command for SetHost { + fn name(&self) -> &'static str { + "SETHOST" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + if !valid_host(¶ms[0]) { + onotice(s, uid, "*** SETHOST: invalid characters in hostname"); + return CmdResult::Fail; + } + s.change_host_ident(uid, None, Some(¶ms[0])); + CmdResult::Ok + } +} + +/// CHGIDENT — change another user's ident/username. +struct ChgIdent; +impl Command for ChgIdent { + fn name(&self) -> &'static str { + "CHGIDENT" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + if !valid_ident(¶ms[1]) { + onotice(s, uid, "*** CHGIDENT: invalid characters in ident"); + return CmdResult::Fail; + } + let Some(t) = oper_target(s, uid, ¶ms[0]) else { + return CmdResult::Fail; + }; + s.change_host_ident(t, Some(¶ms[1]), None); + let by = oper_nick(s, uid); + s.snotice(&format!( + "{by} used CHGIDENT on {}: {}", + params[0], params[1] + )); + CmdResult::Ok + } +} + +/// SETIDENT — change your own ident/username. +struct SetIdent; +impl Command for SetIdent { + fn name(&self) -> &'static str { + "SETIDENT" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + if !valid_ident(¶ms[0]) { + onotice(s, uid, "*** SETIDENT: invalid characters in ident"); + return CmdResult::Fail; + } + s.change_host_ident(uid, Some(¶ms[0]), None); + CmdResult::Ok + } +} + +/// SAMODE — apply a channel MODE as the server, bypassing the rank ladder. +struct SaMode; +impl Command for SaMode { + fn name(&self) -> &'static str { + "SAMODE" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + s.mode_sudo = true; + let r = apply_mode(s, uid, params); + s.mode_sudo = false; + let by = oper_nick(s, uid); + s.snotice(&format!("{by} used SAMODE: {}", params.join(" "))); + r + } +} + +/// SATOPIC — set a channel topic as the server, bypassing +t / op checks. +struct SaTopic; +impl Command for SaTopic { + fn name(&self) -> &'static str { + "SATOPIC" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let chan = ¶ms[0]; + let key = chan.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel")); + return CmdResult::Fail; + } + let text = params[1].clone(); + let (prefix, setter) = { + let u = &s.users[&uid]; + (u.prefix(), u.nick.clone()) + }; + if let Some(ch) = s.channels.get_mut(&key) { + ch.topic = Some(Topic { + text: text.clone(), + setter, + ts: now(), + }); + } + s.to_channel(&key, &format!(":{prefix} TOPIC {chan} :{text}"), None); + s.propagate_from_user(uid, &format!("TOPIC {chan} :{text}")); + let by = oper_nick(s, uid); + s.snotice(&format!("{by} used SATOPIC on {chan}")); + CmdResult::Ok + } +} + +/// SAKICK — kick a user as the server, bypassing rank checks. +struct SaKick; +impl Command for SaKick { + fn name(&self) -> &'static str { + "SAKICK" + } + fn min_params(&self) -> usize { + 2 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !require_oper(s, uid) { + return CmdResult::Fail; + } + let (chan, victim) = (¶ms[0], ¶ms[1]); + let key = chan.to_ascii_lowercase(); + if !s.channels.contains_key(&key) { + s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel")); + return CmdResult::Fail; + } + let Some(tuid) = s.find_nick(victim) else { + s.numeric( + uid, + ERR_NOSUCHNICK, + &format!("{victim} :No such nick/channel"), + ); + return CmdResult::Fail; + }; + if !s.channels[&key].members.contains_key(&tuid) { + s.numeric( + uid, + ERR_USERNOTINCHANNEL, + &format!("{victim} {chan} :They aren't on that channel"), + ); + return CmdResult::Fail; + } + let reason = params + .get(2) + .cloned() + .unwrap_or_else(|| "Kicked by services".to_string()); + let prefix = s.users[&uid].prefix(); + s.to_channel( + &key, + &format!(":{prefix} KICK {chan} {victim} :{reason}"), + None, + ); + s.propagate_from_user(uid, &format!("KICK {chan} {victim} :{reason}")); + if let Some(ch) = s.channels.get_mut(&key) { + ch.members.remove(&tuid); + } + if let Some(u) = s.users.get_mut(&tuid) { + u.channels.remove(&key); + } + s.channels.retain(|_, c| !c.is_empty()); + s.events + .push_back(Hook::Part(tuid, key, "kicked".to_string())); + let by = oper_nick(s, uid); + s.snotice(&format!("{by} used SAKICK on {victim} in {chan}")); + CmdResult::Ok + } +} diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs new file mode 100644 index 0000000..6e8d789 --- /dev/null +++ b/src/coremods/core_user.rs @@ -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> { + vec![ + Box::new(Cap), + Box::new(Authenticate), + Box::new(Nick), + Box::new(UserCmd), + Box::new(Ping), + Box::new(Pong), + Box::new(Quit), + Box::new(Away), + Box::new(SetName), + ] +} + +struct Away; +impl Command for Away { + fn name(&self) -> &'static str { + "AWAY" + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let msg = params.first().cloned().filter(|m| !m.is_empty()); + let now_away = msg.is_some(); + let prefix = match s.users.get_mut(&uid) { + Some(u) => { + u.flags.away = msg.clone(); + u.prefix() + } + None => return CmdResult::Fail, + }; + // away-notify: tell capable peers we went away / came back + let line = match &msg { + Some(m) => format!(":{prefix} AWAY :{m}"), + None => format!(":{prefix} AWAY"), + }; + s.notify_peers(uid, &line, |c| c.away_notify); + if now_away { + s.numeric(uid, RPL_NOWAWAY, ":You have been marked as being away"); + } else { + s.numeric(uid, RPL_UNAWAY, ":You are no longer marked as being away"); + } + CmdResult::Ok + } +} + +struct Cap; +impl Command for Cap { + fn name(&self) -> &'static str { + "CAP" + } + fn min_params(&self) -> usize { + 1 + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let who = cap_target(s, uid); + match params[0].to_ascii_uppercase().as_str() { + "LS" => { + let cap302 = params.get(1).map(|v| v == "302").unwrap_or(false); + if let Some(u) = s.users.get_mut(&uid) { + u.cap = true; // hold registration until CAP END + u.cap_302 |= cap302; + } + s.send( + uid, + format!(":{} CAP {who} LS :{}", s.name, Caps::ls_line(cap302)), + ); + } + "REQ" => { + if let Some(u) = s.users.get_mut(&uid) { + u.cap = true; + } + let req = params.get(1).cloned().unwrap_or_default(); + let wanted: Vec<(&str, bool)> = req + .split_whitespace() + .map(|t| match t.strip_prefix('-') { + Some(rest) => (rest, false), + None => (t, true), + }) + .collect(); + // CAP REQ is atomic: ACK the whole set or NAK the whole set + if !wanted.is_empty() && wanted.iter().all(|(n, _)| Caps::is_known(n)) { + for (name, on) in &wanted { + if let Some(u) = s.users.get_mut(&uid) { + u.caps.set(name, *on); + } + } + s.send(uid, format!(":{} CAP {who} ACK :{req}", s.name)); + } else { + s.send(uid, format!(":{} CAP {who} NAK :{req}", s.name)); + } + } + "LIST" => { + let list = s + .users + .get(&uid) + .map(|u| u.caps.enabled()) + .unwrap_or_default(); + s.send(uid, format!(":{} CAP {who} LIST :{list}", s.name)); + } + "END" => { + if let Some(u) = s.users.get_mut(&uid) { + u.cap = false; + } + } + _ => {} + } + CmdResult::Ok + } +} + +/// CAP reply target: the nick, or `*` before one is set. +fn cap_target(s: &Server, uid: Uid) -> String { + s.users + .get(&uid) + .map(|u| { + if u.nick.is_empty() { + "*".to_string() + } else { + u.nick.clone() + } + }) + .unwrap_or_else(|| "*".to_string()) +} + +/// AUTHENTICATE — the SASL handshake. echoIRCd verifies nothing itself (it has no +/// accounts); once a services server is linked over S2S the payload is relayed to +/// it and `set_login` applied on success. Until then — exactly like InspIRCd with +/// no services — SASL fails cleanly. +struct Authenticate; +impl Command for Authenticate { + fn name(&self) -> &'static str { + "AUTHENTICATE" + } + fn min_params(&self) -> usize { + 1 + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if !s.users.get(&uid).map(|u| u.caps.sasl).unwrap_or(false) { + s.numeric( + uid, + ERR_SASLFAIL, + ":You must request the sasl capability first", + ); + return CmdResult::Fail; + } + let arg = ¶ms[0]; + let mech = s.users.get(&uid).and_then(|u| u.sasl_mech.clone()); + match mech { + // step 1 — the client picks a mechanism + None => { + if arg.eq_ignore_ascii_case("PLAIN") { + if let Some(u) = s.users.get_mut(&uid) { + u.sasl_mech = Some("PLAIN".to_string()); + } + s.send(uid, "AUTHENTICATE +".to_string()); + CmdResult::Ok + } else if arg == "*" { + s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted"); + CmdResult::Ok + } else { + s.numeric(uid, RPL_SASLMECHS, "PLAIN :are available SASL mechanisms"); + s.numeric(uid, ERR_SASLFAIL, ":Unsupported SASL mechanism"); + CmdResult::Fail + } + } + // step 2 — the client sends the base64 payload (or aborts with `*`) + Some(_) => { + if let Some(u) = s.users.get_mut(&uid) { + u.sasl_mech = None; + } + if arg == "*" { + s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted"); + return CmdResult::Ok; + } + if arg.len() > 400 { + s.numeric(uid, ERR_SASLTOOLONG, ":SASL message too long"); + return CmdResult::Fail; + } + // base64(authzid \0 authcid \0 passwd) — would be relayed to services + let _creds = openssl::base64::decode_block(arg).unwrap_or_default(); + s.numeric( + uid, + ERR_SASLFAIL, + ":SASL authentication failed (services are not available)", + ); + CmdResult::Fail + } + } + } +} + +/// SETNAME — change your realname (IRCv3). Broadcast to `setname`-capable peers. +struct SetName; +impl Command for SetName { + fn name(&self) -> &'static str { + "SETNAME" + } + fn min_params(&self) -> usize { + 1 + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let realname = params[0].clone(); + let prefix = match s.users.get_mut(&uid) { + Some(u) => { + u.realname = realname.clone(); + u.prefix() + } + None => return CmdResult::Fail, + }; + let line = format!(":{prefix} SETNAME :{realname}"); + if s.users.get(&uid).map(|u| u.caps.setname).unwrap_or(false) { + s.send(uid, line.clone()); + } + s.notify_peers(uid, &line, |c| c.setname); + CmdResult::Ok + } +} + +struct Nick; +impl Command for Nick { + fn name(&self) -> &'static str { + "NICK" + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let Some(newnick) = params.first() else { + s.numeric(uid, ERR_NONICKNAMEGIVEN, ":No nickname given"); + return CmdResult::Fail; + }; + if !valid_nick(newnick) { + s.numeric( + uid, + ERR_ERRONEUSNICKNAME, + &format!("{newnick} :Erroneous nickname"), + ); + return CmdResult::Fail; + } + if let Some(other) = s.find_nick(newnick) { + if other != uid { + s.numeric( + uid, + ERR_NICKNAMEINUSE, + &format!("{newnick} :Nickname is already in use"), + ); + return CmdResult::Fail; + } + return CmdResult::Ok; // same nick, no-op + } + // a nick already held by a user on a linked server is taken too + if s.remote_nick.contains_key(&newnick.to_ascii_lowercase()) { + s.numeric( + uid, + ERR_NICKNAMEINUSE, + &format!("{newnick} :Nickname is already in use"), + ); + return CmdResult::Fail; + } + // +N — can't change nick while on a no-nick-change channel (opers bypass) + if !s.is_oper(uid) { + let blocked = s + .users + .get(&uid) + .map(|u| u.channels.clone()) + .unwrap_or_default() + .iter() + .find_map(|k| { + s.channels + .get(k) + .filter(|c| c.modes.no_nick) + .map(|c| c.name.clone()) + }); + if let Some(cn) = blocked { + s.numeric( + uid, + ERR_CANTCHANGENICK, + &format!("{cn} :Cannot change nick while on this channel (+N is set)"), + ); + return CmdResult::Fail; + } + // extban `n:` — a matched user can't change nick on that channel + let chans = s + .users + .get(&uid) + .map(|u| u.channels.clone()) + .unwrap_or_default(); + if let Some(k) = chans.into_iter().find(|k| s.extban_active(uid, k, 'n')) { + let cn = s.channels.get(&k).map(|c| c.name.clone()).unwrap_or(k); + s.numeric( + uid, + ERR_CANTCHANGENICK, + &format!("{cn} :Cannot change nick here (+b n:)"), + ); + return CmdResult::Fail; + } + // +F nick-change flood — locks nick changes on the channel for 60s + if let Some(cn) = s.nickflood_blocked(uid) { + s.numeric( + uid, + ERR_CANTCHANGENICK, + &format!("{cn} :Too many nick changes, try later (+F is set)"), + ); + return CmdResult::Fail; + } + } + s.set_nick(uid, newnick); + CmdResult::Ok + } +} + +struct UserCmd; +impl Command for UserCmd { + fn name(&self) -> &'static str { + "USER" + } + fn min_params(&self) -> usize { + 4 + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + if s.users.get(&uid).map(|u| u.registered).unwrap_or(false) { + s.numeric(uid, ERR_ALREADYREGISTERED, ":You may not reregister"); + return CmdResult::Fail; + } + let ident = ident_of(¶ms[0]); + let realname = params[3].clone(); + if let Some(u) = s.users.get_mut(&uid) { + u.ident = format!("~{ident}"); + u.realname = realname; + } + CmdResult::Ok + } +} + +struct Ping; +impl Command for Ping { + fn name(&self) -> &'static str { + "PING" + } + fn min_params(&self) -> usize { + 1 + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + s.send(uid, format!(":{} PONG {} :{}", s.name, s.name, params[0])); + CmdResult::Ok + } +} + +struct Pong; +impl Command for Pong { + fn name(&self) -> &'static str { + "PONG" + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, _s: &mut Server, _uid: Uid, _params: &[String]) -> CmdResult { + CmdResult::Ok // keepalive; nothing to do yet + } +} + +struct Quit; +impl Command for Quit { + fn name(&self) -> &'static str { + "QUIT" + } + fn before_reg(&self) -> bool { + true + } + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + let reason = params + .first() + .cloned() + .unwrap_or_else(|| "Client quit".to_string()); + s.mark_quit(uid, format!("Quit: {reason}")); + CmdResult::Ok + } +} diff --git a/src/coremods/core_watch.rs b/src/coremods/core_watch.rs new file mode 100644 index 0000000..3b338d9 --- /dev/null +++ b/src/coremods/core_watch.rs @@ -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> { + 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 = 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 = 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 + } +} diff --git a/src/coremods/mod.rs b/src/coremods/mod.rs new file mode 100644 index 0000000..fd58778 --- /dev/null +++ b/src/coremods/mod.rs @@ -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> { + let mut m: HashMap<&'static str, Box> = 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 +} diff --git a/src/extensible.rs b/src/extensible.rs new file mode 100644 index 0000000..4c4ff3a --- /dev/null +++ b/src/extensible.rs @@ -0,0 +1,79 @@ +//! Typed per-object metadata — echoIRCd's answer to InspIRCd's `Extensible` / +//! `ExtensionItem`. +//! +//! In C++ InspIRCd, a module attaches data to a user/channel through a `void*` +//! `ExtensionItem`: it registers the item, casts on every access, and must supply +//! a `free()` callback — a well-worn source of leaks, type-confusion and +//! use-after-free (the reason the core carries a whole "cull list"). +//! +//! Here it's a `TypeId`-keyed typemap. A module stores its own concrete type and +//! gets it back type-checked; the value is owned by the object it hangs off, so +//! it's dropped automatically when that object is — no registry, no `unsafe`, no +//! manual free, no dangling data. + +use std::any::{Any, TypeId}; +use std::collections::HashMap; + +#[derive(Default)] +pub struct Extensible { + map: HashMap>, +} + +impl Extensible { + pub fn get(&self) -> Option<&T> { + self.map + .get(&TypeId::of::()) + .and_then(|b| b.downcast_ref::()) + } + + pub fn get_mut(&mut self) -> Option<&mut T> { + self.map + .get_mut(&TypeId::of::()) + .and_then(|b| b.downcast_mut::()) + } + + pub fn set(&mut self, value: T) { + self.map.insert(TypeId::of::(), Box::new(value)); + } + + /// Get the stored `T`, inserting `f()`'s value first if it's not there yet. + pub fn get_or_insert_with(&mut self, f: impl FnOnce() -> T) -> &mut T { + self.map + .entry(TypeId::of::()) + .or_insert_with(|| Box::new(f())) + .downcast_mut::() + .expect("each TypeId keys exactly its own type") + } + + pub fn take(&mut self) -> Option { + self.map + .remove(&TypeId::of::()) + .and_then(|b| b.downcast::().ok()) + .map(|b| *b) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Default, PartialEq, Debug)] + struct A(u32); + struct B(&'static str); + + #[test] + fn typed_storage_is_isolated_and_recoverable() { + let mut e = Extensible::default(); + assert!(e.get::().is_none()); + e.set(A(7)); + e.set(B("hi")); + // two different types coexist, each recovered as itself + assert_eq!(e.get::(), Some(&A(7))); + assert_eq!(e.get::().map(|b| b.0), Some("hi")); + e.get_mut::().unwrap().0 += 1; + assert_eq!(e.get::(), Some(&A(8))); + assert_eq!(e.take::(), Some(A(8))); + assert!(e.get::().is_none()); + assert_eq!(*e.get_or_insert_with(|| A(100)), A(100)); + } +} diff --git a/src/ircd.rs b/src/ircd.rs new file mode 100644 index 0000000..5c01dca --- /dev/null +++ b/src/ircd.rs @@ -0,0 +1,277 @@ +//! The core: owns the [`Server`] state, the command table and the module list, +//! and turns a stream of [`Event`]s into IRC. Everything here runs on one +//! thread, so no state is ever locked. + +use std::collections::HashMap; +use std::net::{SocketAddr, TcpStream}; +use std::sync::mpsc::{Receiver, Sender}; + +use crate::command::Command; +use crate::config::Config; +use crate::coremods::command_table; +use crate::message; +use crate::module::{Hook, ModResult, Module}; +use crate::numeric::{ERR_NEEDMOREPARAMS, ERR_NOTREGISTERED, ERR_UNKNOWNCOMMAND}; +use crate::server::Server; +use crate::Uid; + +/// What the I/O threads hand to the core. +pub enum Event { + Connect { + uid: Uid, + addr: SocketAddr, + out: Sender, + sock: TcpStream, + secure: bool, + link: bool, // a server-to-server connection, not a client + outbound: bool, // (link) we dialed them + }, + Line { + uid: Uid, + line: String, + }, + Disconnect { + uid: Uid, + }, + /// Background timer tick — drives ping/idle timeouts. + Tick, +} + +pub struct Ircd { + server: Server, + commands: HashMap<&'static str, Box>, + modules: Vec>, +} + +impl Ircd { + pub fn new(cfg: Config) -> Ircd { + Ircd { + server: Server::new(cfg), + commands: command_table(), + modules: crate::modules::default_modules(), + } + } + + /// Run until the event channel closes (i.e. the listener is gone). + pub fn run(mut self, rx: Receiver) { + for ev in rx { + match ev { + Event::Connect { + uid, + addr, + out, + sock, + secure, + link, + outbound, + } => { + if link { + self.server.add_link(uid, addr, out, sock, outbound); + } else { + self.server.add_conn(uid, addr, out, sock, secure); + } + } + Event::Line { uid, line } => { + if self.server.links.contains_key(&uid) { + if let Some(msg) = message::parse(&line) { + self.server.on_link(uid, &msg); + } + } else { + self.on_line(uid, &line); + } + } + Event::Disconnect { uid } => { + if self.server.links.contains_key(&uid) { + self.server.close_link(uid, "Connection closed"); + } else { + self.quit_user(uid, "Connection closed"); + } + } + Event::Tick => self.on_tick(), + } + self.drain_hooks(); + } + } + + fn on_line(&mut self, uid: Uid, line: &str) { + let Some(msg) = message::parse(line) else { + return; + }; + // stash this line's client-only tags for TAGMSG / PRIVMSG relay + self.server.line_ctags = msg.ctags.clone(); + // any valid line means the connection is alive + if let Some(u) = self.server.users.get_mut(&uid) { + u.last_active = crate::server::now(); + u.ping_sent = false; + } + let cmd = msg.command.as_str(); + let registered = self + .server + .users + .get(&uid) + .map(|u| u.registered) + .unwrap_or(false); + + // module pre-command gate + for m in &mut self.modules { + if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny { + return; + } + } + // message pre-hook (PRIVMSG/NOTICE) + if matches!(cmd, "PRIVMSG" | "NOTICE") && msg.params.len() >= 2 { + let (target, text) = (msg.params[0].clone(), msg.params[1].clone()); + for m in &mut self.modules { + if m.on_pre_message(&mut self.server, uid, &target, &text) == ModResult::Deny { + return; + } + } + } + + let Some(handler) = self.commands.get(cmd) else { + if registered { + self.server + .numeric(uid, ERR_UNKNOWNCOMMAND, &format!("{cmd} :Unknown command")); + } + return; + }; + if !registered && !handler.before_reg() { + self.server + .numeric(uid, ERR_NOTREGISTERED, ":You have not registered"); + return; + } + if msg.params.len() < handler.min_params() { + self.server.numeric( + uid, + ERR_NEEDMOREPARAMS, + &format!("{cmd} :Not enough parameters"), + ); + return; + } + let _ = handler.handle(&mut self.server, uid, &msg.params); + + for m in &mut self.modules { + m.on_post_command(&mut self.server, uid, cmd); + } + + // a command may have asked to quit (QUIT) + if let Some(reason) = self.server.take_quit(uid) { + self.quit_user(uid, &reason); + return; + } + // …or completed the registration handshake + if !registered { + let ready = self + .server + .users + .get(&uid) + .map(|u| !u.registered && !u.nick.is_empty() && !u.ident.is_empty() && !u.cap) + .unwrap_or(false); + if ready { + self.complete_registration(uid); + } + } + } + + fn complete_registration(&mut self, uid: Uid) { + for m in &mut self.modules { + if m.on_user_register(&mut self.server, uid) == ModResult::Deny { + self.server.send( + uid, + "ERROR :Closing link (registration refused)".to_string(), + ); + self.server.remove_user(uid, "Registration refused"); + return; + } + } + // x-line: refuse a banned host / ip before welcoming + let (ident, host, ip) = { + let u = &self.server.users[&uid]; + (u.ident.clone(), u.host.clone(), u.addr.ip().to_string()) + }; + if let Some(reason) = self.server.matched_xline(&ident, &host, &ip) { + self.server + .send(uid, format!("ERROR :Closing link: ({reason})")); + self.server.remove_user(uid, &reason); + return; + } + self.server.welcome(uid); + } + + fn quit_user(&mut self, uid: Uid, reason: &str) { + if !self.server.users.contains_key(&uid) { + return; + } + let registered = self.server.users[&uid].registered; + if registered { + // fire the quit hook while the user still exists + for m in &mut self.modules { + m.on_user_quit(&mut self.server, uid, reason); + } + } + self.server.remove_user(uid, reason); + } + + /// Background timer: PING idle clients, reap the unresponsive and the + /// never-registered. + fn on_tick(&mut self) { + self.server.ping_links(); // keepalive on every server link + self.server.purge_xlines(); // drop expired server bans + let now = crate::server::now(); + let (to_ping, to_quit) = self.server.idle_check(now); + for uid in to_ping { + let token = self.server.name.clone(); + self.server.send(uid, format!("PING :{token}")); + if let Some(u) = self.server.users.get_mut(&uid) { + u.ping_sent = true; + } + } + for uid in to_quit { + let reg = self + .server + .users + .get(&uid) + .map(|u| u.registered) + .unwrap_or(false); + let reason = if reg { + "Ping timeout" + } else { + "Registration timeout" + }; + self.server + .send(uid, format!("ERROR :Closing link: ({reason})")); + self.quit_user(uid, reason); + } + } + + /// Fire queued notify-hooks. Draining a queue (not iterating in place) lets a + /// hook enqueue more work (e.g. a module forcing a join) without surprises. + fn drain_hooks(&mut self) { + while let Some(hook) = self.server.events.pop_front() { + match hook { + Hook::Connect(uid) => { + for m in &mut self.modules { + m.on_user_connect(&mut self.server, uid); + } + // burst to links after modules (so the cloak is already set) + self.server.introduce_to_links(uid); + } + Hook::Join(uid, chan) => { + for m in &mut self.modules { + m.on_join(&mut self.server, uid, &chan); + } + } + Hook::Part(uid, chan, reason) => { + for m in &mut self.modules { + m.on_part(&mut self.server, uid, &chan, &reason); + } + } + Hook::Quit(uid, reason) => { + for m in &mut self.modules { + m.on_user_quit(&mut self.server, uid, &reason); + } + } + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..7df8a39 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,33 @@ +//! echoIRCd — a small, dependency-light IRC daemon, laid out like InspIRCd: +//! +//! - **engine** — `server` (the core + state), `users`, `channels`, `message`, +//! `numeric`, `config`. +//! - **`coremods`** — the built-in commands, grouped the way InspIRCd groups its +//! `coremods/` (core_user, core_channel, core_message, core_mode, core_info). +//! - **`modules`** — optional, pluggable behaviour via lifecycle hooks. +//! - **`socketengine`** — the I/O edge (accept + per-connection threads). +//! - **`ircd`** — the single-threaded core loop that ties it together. +#![forbid(unsafe_code)] + +/// A local connection id. (Server linking will later need real UUIDs.) +pub type Uid = u64; + +pub mod accounts; +pub mod channels; +pub mod command; +pub mod config; +pub mod coremods; +pub mod extensible; +pub mod ircd; +pub mod link; +pub mod message; +pub mod mode; +pub mod module; +pub mod modules; +pub mod numeric; +pub mod server; +pub mod socketengine; +pub mod tls; +pub mod users; +pub mod watch; +pub mod xline; diff --git a/src/link.rs b/src/link.rs new file mode 100644 index 0000000..bd3855c --- /dev/null +++ b/src/link.rs @@ -0,0 +1,996 @@ +//! Server-to-server linking — echoIRCd's answer to InspIRCd's `m_spanningtree`. +//! +//! A link connection is a first-class peer, *not* a client `User`: it lives in +//! `Server.links` and is driven by [`Server::on_link`] instead of the client +//! command table. What's here: +//! * **handshake** — `SERVER :` (shared per-block key), +//! then `BURST`/`ENDBURST`; a registry of linked servers (`Server.servers`). +//! * **users** — local users get network **UIDs**; on link-up they're burst as +//! `UID`, and NICK/QUIT propagate; remote users live in `remote_users`. +//! * **channels** — JOIN/PART/TOPIC/KICK/MODE (incl. ban/except/invex lists) +//! propagate; every server tracks a channel's full membership (`Channel.rmembers`, +//! with prefix modes), modes and bans; channel messages fan out **one copy per +//! link** (not per remote member), forwarded on but the origin; `FJOIN` bursts +//! channels (members + bans) on link-up. +//! * **collisions** — a nick already on the network is refused; an incoming `UID` +//! that clashes with a local user kills the local (both sides ⇒ both vanish). +//! * **netsplit** — dropping a link QUITs every user behind it. +//! +//! Toward full InspIRCd interop still: TS6 tie-breaking and the exact +//! CAPAB/FJOIN/metadata wire format. Also: SASL relays here once a services links in. + +use std::net::{SocketAddr, TcpStream}; +use std::sync::mpsc::Sender; + +use std::collections::HashSet; + +use crate::channels::{Ban, Channel, Member, Topic}; +use crate::message::Message; +use crate::server::{now, Server}; +use crate::users::User; +use crate::Uid; + +/// A local server-link connection (one hop away). Distinct from a client `User`. +pub struct Link { + pub uid: Uid, + pub out: Sender, + pub outbound: bool, // we dialed them (so we introduce ourselves first) + pub registered: bool, // handshake complete + pub sent_server: bool, // we've sent our own SERVER line + pub sid: Option, + pub name: Option, + pub bursting: bool, // between the peer's BURST and ENDBURST +} + +/// A server known on the network, for LINKS / MAP / routing. +pub struct RemoteServer { + pub sid: String, + pub name: String, + pub desc: String, + pub via: Uid, // the local link uid it is reachable through +} + +/// A user living on another server, reached via a link — not a local `User`. +pub struct RemoteUser { + pub uuid: String, + pub nick: String, + pub ident: String, + pub host: String, + pub realname: String, + pub account: Option, + pub sid: String, // origin server id + pub via: Uid, // local link uid it is reached through +} + +impl RemoteUser { + pub fn prefix(&self) -> String { + format!("{}!{}@{}", self.nick, self.ident, self.host) + } +} + +/// A valid 3-char SID: digit, then two upper-case alphanumerics (InspIRCd's rule). +pub fn valid_sid(s: &str) -> bool { + let b = s.as_bytes(); + b.len() == 3 + && b[0].is_ascii_digit() + && b.iter() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) +} + +impl Server { + /// Mint the next network-wide UID for a local user: our SID + 6 base-26 chars + /// (InspIRCd-style, e.g. `0AAAAAAAB`). + pub fn next_uuid(&mut self) -> String { + let mut x = self.uuid_counter; + self.uuid_counter += 1; + let mut suffix = [b'A'; 6]; + for c in suffix.iter_mut().rev() { + *c = b'A' + (x % 26) as u8; + x /= 26; + } + format!( + "{}{}", + self.sid, + std::str::from_utf8(&suffix).unwrap_or("AAAAAA") + ) + } + + /// Register a new server-link connection. An **outbound** link introduces + /// itself right away with our `SERVER` line (using the dialled block's key). + pub fn add_link( + &mut self, + uid: Uid, + addr: SocketAddr, + out: Sender, + _sock: TcpStream, // held by the reader/writer threads; closed gracefully + outbound: bool, + ) { + let mut sent_server = false; + if outbound { + let pass = self + .link_blocks + .iter() + .find(|b| b.ip == addr.ip().to_string()) + .map(|b| b.password.clone()); + if let Some(pass) = pass { + let _ = out.send(format!( + "SERVER {} {} {} :{}", + self.name, pass, self.sid, self.server_desc + )); + sent_server = true; + } + } + self.links.insert( + uid, + Link { + uid, + out, + outbound, + registered: false, + sent_server, + sid: None, + name: None, + bursting: false, + }, + ); + } + + fn link_out(&self, uid: Uid, line: String) { + if let Some(l) = self.links.get(&uid) { + let _ = l.out.send(line); + } + } + + /// Dispatch one parsed S2S line from link `uid`. + pub fn on_link(&mut self, uid: Uid, msg: &Message) { + let registered = self.links.get(&uid).map(|l| l.registered).unwrap_or(false); + match msg.command.as_str() { + "SERVER" if !registered => self.link_server(uid, msg), + "PING" if registered => { + let token = msg.params.first().cloned().unwrap_or_default(); + self.link_out(uid, format!("PONG :{token}")); + } + "UID" if registered => self.link_uid_recv(uid, msg), + "NICK" if registered => self.link_nick_recv(uid, msg), + "QUIT" if registered => self.link_quit_recv(uid, msg), + "PRIVMSG" if registered => self.link_message_recv(uid, msg, false), + "NOTICE" if registered => self.link_message_recv(uid, msg, true), + "JOIN" if registered => self.link_join_recv(uid, msg), + "PART" if registered => self.link_part_recv(uid, msg), + "TOPIC" if registered => self.link_topic_recv(uid, msg), + "KICK" if registered => self.link_kick_recv(uid, msg), + "MODE" | "FMODE" if registered => self.link_mode_recv(uid, msg), + "FJOIN" if registered => self.link_fjoin_recv(uid, msg), + "BURST" => { + if let Some(l) = self.links.get_mut(&uid) { + l.bursting = true; + } + } + "ENDBURST" => { + if let Some(l) = self.links.get_mut(&uid) { + l.bursting = false; + } + } + "SQUIT" => self.close_link(uid, "SQUIT"), + "ERROR" => { + eprintln!("[link] {uid} ERROR: {}", msg.params.join(" ")); + self.close_link(uid, "peer error"); + } + _ => {} + } + } + + /// Handle the `SERVER :` handshake line. + fn link_server(&mut self, uid: Uid, msg: &Message) { + if msg.params.len() < 4 { + self.reject_link(uid, "Not enough SERVER parameters"); + return; + } + let (name, pass, sid, desc) = ( + msg.params[0].clone(), + msg.params[1].clone(), + msg.params[2].clone(), + msg.params[3].clone(), + ); + let Some(block) = self.link_blocks.iter().find(|b| b.name == name).cloned() else { + self.reject_link(uid, "No link block for that server name"); + return; + }; + if block.password != pass { + self.reject_link(uid, "Invalid link password"); + return; + } + if !valid_sid(&sid) || sid == self.sid || self.servers.contains_key(&sid) { + self.reject_link(uid, "Bad or already-present SID"); + return; + } + + let already_sent = self.links.get(&uid).map(|l| l.sent_server).unwrap_or(false); + if let Some(l) = self.links.get_mut(&uid) { + l.registered = true; + l.sid = Some(sid.clone()); + l.name = Some(name.clone()); + } + self.servers.insert( + sid.clone(), + RemoteServer { + sid: sid.clone(), + name: name.clone(), + desc: desc.clone(), + via: uid, + }, + ); + // if we accepted (inbound) we still owe them our SERVER line + if !already_sent { + self.link_out( + uid, + format!( + "SERVER {} {} {} :{}", + self.name, block.password, self.sid, self.server_desc + ), + ); + if let Some(l) = self.links.get_mut(&uid) { + l.sent_server = true; + } + } + // netburst: introduce our local users (channels/FJOIN are phase 2b) + self.link_out(uid, format!("BURST {}", now())); + self.burst_users(uid); + self.burst_channels(uid); + self.link_out(uid, "ENDBURST".to_string()); + eprintln!("[link] linked {name} ({sid}) — {desc}"); + } + + fn reject_link(&mut self, uid: Uid, why: &str) { + self.link_out(uid, format!("ERROR :Link denied: {why}")); + eprintln!("[link] rejected {uid}: {why}"); + self.close_link(uid, why); + } + + /// Drop a link and every server reachable through it (a netsplit). We don't + /// force the socket shut: dropping the `Link` drops its `out` sender, so the + /// writer thread first flushes any queued line (e.g. an `ERROR`) and *then* + /// closes the socket — otherwise a rejection races its own disconnect. + pub fn close_link(&mut self, uid: Uid, reason: &str) { + let mut peer = String::new(); + if let Some(l) = self.links.remove(&uid) { + peer = l.name.clone().unwrap_or_default(); + if let Some(sid) = l.sid { + eprintln!("[link] netsplit {peer} ({sid}): {reason}"); + } + } + self.servers.retain(|_, s| s.via != uid); + // every remote user reached through this link is now gone (netsplit) — + // drop them from channels and QUIT them to any local channel-mates. + let netreason = format!("{} {peer}", self.name); + let gone: Vec = self + .remote_users + .iter() + .filter(|(_, ru)| ru.via == uid) + .map(|(k, _)| k.clone()) + .collect(); + for uuid in &gone { + self.drop_remote_user(uuid, &netreason); + } + } + + /// Periodic keepalive: PING every registered link. + pub fn ping_links(&self) { + let token = self.sid.clone(); + let uids: Vec = self + .links + .iter() + .filter(|(_, l)| l.registered) + .map(|(&u, _)| u) + .collect(); + for u in uids { + self.link_out(u, format!("PING :{token}")); + } + } + + /// Relay `line` to every registered link except `except` (the origin). + pub fn propagate(&self, line: &str, except: Option) { + let targets: Vec = self + .links + .iter() + .filter(|(u, l)| l.registered && Some(**u) != except) + .map(|(&u, _)| u) + .collect(); + for u in targets { + self.link_out(u, line.to_string()); + } + } + + /// The `UID` introduction line for a local user. + fn uid_line(&self, u: &User) -> String { + let acct = u.account.clone().unwrap_or_else(|| "*".to_string()); + format!( + ":{} UID {} {} {} {} {} :{}", + self.sid, + u.uuid, + u.nick, + u.ident, + u.host_display(), + acct, + u.realname + ) + } + + /// Burst all local registered users to a freshly-linked peer. + fn burst_users(&self, link_uid: Uid) { + let lines: Vec = self + .users + .values() + .filter(|u| u.registered) + .map(|u| self.uid_line(u)) + .collect(); + for l in lines { + self.link_out(link_uid, l); + } + } + + /// Announce a newly-registered local user to every link. + pub fn introduce_to_links(&self, uid: Uid) { + if self.links.is_empty() { + return; + } + if let Some(u) = self.users.get(&uid) { + let line = self.uid_line(u); + self.propagate(&line, None); + } + } + + /// Propagate a local user's nick change. + pub fn propagate_nick(&self, uid: Uid, newnick: &str) { + if let Some(u) = self.users.get(&uid) { + if u.registered && !self.links.is_empty() { + self.propagate(&format!(":{} NICK {newnick}", u.uuid), None); + } + } + } + + /// Find a remote user by nick: returns `(uuid, via-link)`. + pub fn find_remote(&self, nick: &str) -> Option<(String, Uid)> { + let uuid = self.remote_nick.get(&nick.to_ascii_lowercase())?; + let ru = self.remote_users.get(uuid)?; + Some((uuid.clone(), ru.via)) + } + + /// Resolve any network uuid (remote or local) to a `nick!user@host` prefix. + pub fn uuid_prefix(&self, uuid: &str) -> Option { + if let Some(ru) = self.remote_users.get(uuid) { + return Some(ru.prefix()); + } + self.uuid_local + .get(uuid) + .and_then(|&uid| self.users.get(&uid)) + .map(|u| u.prefix()) + } + + /// Route a message from a local sender to a remote user across its link. + pub fn send_to_remote(&self, sender: Uid, target_uuid: &str, via: Uid, cmd: &str, text: &str) { + if let Some(u) = self.users.get(&sender) { + self.link_out(via, format!(":{} {cmd} {target_uuid} :{text}", u.uuid)); + } + } + + // --- inbound S2S records -------------------------------------------------- + + fn link_uid_recv(&mut self, via: Uid, msg: &Message) { + // : UID : + if msg.params.len() < 6 { + return; + } + let sid = msg.source.clone().unwrap_or_default(); + let uuid = msg.params[0].clone(); + let nick = msg.params[1].clone(); + // nick collision: a local holder is killed (both sides do this, so both + // vanish deterministically); an existing remote holder simply wins. + if let Some(luid) = self.find_nick(&nick) { + self.send(luid, "ERROR :Closing link: Nick collision".to_string()); + self.remove_user(luid, "Nick collision"); + return; + } + if self.remote_nick.contains_key(&nick.to_ascii_lowercase()) { + return; + } + let account = if msg.params[4] == "*" { + None + } else { + Some(msg.params[4].clone()) + }; + self.remote_nick + .insert(nick.to_ascii_lowercase(), uuid.clone()); + self.remote_users.insert( + uuid.clone(), + RemoteUser { + uuid, + nick, + ident: msg.params[2].clone(), + host: msg.params[3].clone(), + realname: msg.params[5].clone(), + account, + sid: sid.clone(), + via, + }, + ); + let line = format!( + ":{sid} UID {} {} {} {} {} :{}", + msg.params[0], + msg.params[1], + msg.params[2], + msg.params[3], + msg.params[4], + msg.params[5] + ); + self.propagate(&line, Some(via)); + } + + fn link_nick_recv(&mut self, via: Uid, msg: &Message) { + // : NICK + let Some(uuid) = msg.source.clone() else { + return; + }; + let Some(newnick) = msg.params.first().cloned() else { + return; + }; + let old = match self.remote_users.get_mut(&uuid) { + Some(ru) => { + let old = ru.nick.clone(); + ru.nick = newnick.clone(); + old + } + None => return, + }; + self.remote_nick.remove(&old.to_ascii_lowercase()); + self.remote_nick + .insert(newnick.to_ascii_lowercase(), uuid.clone()); + self.propagate(&format!(":{uuid} NICK {newnick}"), Some(via)); + } + + fn link_quit_recv(&mut self, via: Uid, msg: &Message) { + // : QUIT : + let Some(uuid) = msg.source.clone() else { + return; + }; + let reason = msg.params.first().cloned().unwrap_or_default(); + self.drop_remote_user(&uuid, &reason); + self.propagate(&format!(":{uuid} QUIT :{reason}"), Some(via)); + } + + fn link_message_recv(&mut self, via: Uid, msg: &Message, notice: bool) { + // : PRIVMSG <#chan|dstuuid> : + let cmd = if notice { "NOTICE" } else { "PRIVMSG" }; + let Some(src) = msg.source.clone() else { + return; + }; + if msg.params.len() < 2 { + return; + } + let (target, text) = (msg.params[0].clone(), msg.params[1].clone()); + let Some(prefix) = self.uuid_prefix(&src) else { + return; + }; + if target.starts_with('#') { + let key = target.to_ascii_lowercase(); + if !self.channels.contains_key(&key) { + return; + } + let line = format!(":{prefix} {cmd} {target} :{text}"); + let members: Vec = self.channels[&key].members.keys().copied().collect(); + for m in members { + if self.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) { + continue; + } + self.send(m, line.clone()); + } + // forward to the other links that have members in this channel + for l in self.channel_link_targets(&key, Some(via)) { + self.link_out(l, format!(":{src} {cmd} {target} :{text}")); + } + } else if let Some(&dst) = self.uuid_local.get(&target) { + let nick = self + .users + .get(&dst) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + self.send(dst, format!(":{prefix} {cmd} {nick} :{text}")); + } + } + + /// Tell linked servers a local user joined a channel. + pub fn propagate_join(&self, uid: Uid, chan: &str) { + if self.links.is_empty() { + return; + } + if let Some(u) = self.users.get(&uid) { + if u.registered { + self.propagate(&format!(":{} JOIN {chan}", u.uuid), None); + } + } + } + + /// Tell linked servers a local user parted a channel. + pub fn propagate_part(&self, uid: Uid, chan: &str, reason: &str) { + if self.links.is_empty() { + return; + } + if let Some(u) = self.users.get(&uid) { + if u.registered { + let line = if reason.is_empty() { + format!(":{} PART {chan}", u.uuid) + } else { + format!(":{} PART {chan} :{reason}", u.uuid) + }; + self.propagate(&line, None); + } + } + } + + /// The distinct links a channel's remote members sit behind (minus `except`). + fn channel_link_targets(&self, key: &str, except: Option) -> Vec { + let mut set: HashSet = HashSet::new(); + if let Some(ch) = self.channels.get(key) { + for uuid in ch.rmembers.keys() { + if let Some(ru) = self.remote_users.get(uuid) { + if Some(ru.via) != except { + set.insert(ru.via); + } + } + } + } + set.into_iter().collect() + } + + /// Relay a local user's channel message to every link with members there. + pub fn send_channel_to_links( + &self, + sender: Uid, + key: &str, + target: &str, + cmd: &str, + text: &str, + ) { + let Some(uuid) = self.users.get(&sender).map(|u| u.uuid.clone()) else { + return; + }; + let line = format!(":{uuid} {cmd} {target} :{text}"); + for l in self.channel_link_targets(key, None) { + self.link_out(l, line.clone()); + } + } + + fn link_join_recv(&mut self, via: Uid, msg: &Message) { + // : JOIN #chan + let Some(uuid) = msg.source.clone() else { + return; + }; + let Some(chan) = msg.params.first().cloned() else { + return; + }; + if !self.remote_users.contains_key(&uuid) { + return; + } + let key = chan.to_ascii_lowercase(); + self.channels + .entry(key.clone()) + .or_insert_with(|| Channel::new(&chan)) + .rmembers + .insert(uuid.clone(), Member::default()); + let prefix = self + .remote_users + .get(&uuid) + .map(|r| r.prefix()) + .unwrap_or_default(); + self.to_channel(&key, &format!(":{prefix} JOIN {chan}"), None); + self.propagate(&format!(":{uuid} JOIN {chan}"), Some(via)); + } + + fn link_part_recv(&mut self, via: Uid, msg: &Message) { + // : PART #chan [:reason] + let Some(uuid) = msg.source.clone() else { + return; + }; + let Some(chan) = msg.params.first().cloned() else { + return; + }; + let reason = msg.params.get(1).cloned().unwrap_or_default(); + let key = chan.to_ascii_lowercase(); + let removed = self + .channels + .get_mut(&key) + .map(|c| c.rmembers.remove(&uuid).is_some()) + .unwrap_or(false); + if !removed { + return; + } + let prefix = self + .remote_users + .get(&uuid) + .map(|r| r.prefix()) + .unwrap_or_default(); + let line = if reason.is_empty() { + format!(":{prefix} PART {chan}") + } else { + format!(":{prefix} PART {chan} :{reason}") + }; + self.to_channel(&key, &line, None); + self.channels.retain(|_, c| !c.is_empty()); + let fwd = if reason.is_empty() { + format!(":{uuid} PART {chan}") + } else { + format!(":{uuid} PART {chan} :{reason}") + }; + self.propagate(&fwd, Some(via)); + } + + /// Remove a remote user everywhere (channels + registries) and QUIT them to + /// any local users who shared a channel. + fn drop_remote_user(&mut self, uuid: &str, reason: &str) { + let prefix = match self.remote_users.get(uuid) { + Some(ru) => ru.prefix(), + None => return, + }; + let mut notify: HashSet = HashSet::new(); + let chans: Vec = self + .channels + .iter() + .filter(|(_, c)| c.rmembers.contains_key(uuid)) + .map(|(k, _)| k.clone()) + .collect(); + for key in &chans { + if let Some(c) = self.channels.get_mut(key) { + c.rmembers.remove(uuid); + for &m in c.members.keys() { + notify.insert(m); + } + } + } + let line = format!(":{prefix} QUIT :{reason}"); + for m in notify { + self.send(m, line.clone()); + } + self.channels.retain(|_, c| !c.is_empty()); + if let Some(ru) = self.remote_users.remove(uuid) { + self.remote_nick.remove(&ru.nick.to_ascii_lowercase()); + } + } + + /// Relay `: ` to every link (MODE/TOPIC/KICK propagation). + pub fn propagate_from_user(&self, uid: Uid, rest: &str) { + if self.links.is_empty() { + return; + } + if let Some(u) = self.users.get(&uid) { + if u.registered { + self.propagate(&format!(":{} {rest}", u.uuid), None); + } + } + } + + /// Set a prefix mode on `nick` in `key`, be they a local or remote member. + fn set_member_prefix(&mut self, key: &str, nick: &str, letter: char, adding: bool) { + if let Some(uid) = self.find_nick(nick) { + if let Some(m) = self + .channels + .get_mut(key) + .and_then(|c| c.members.get_mut(&uid)) + { + m.set_prefix(letter, adding); + } + } else if let Some((uuid, _)) = self.find_remote(nick) { + if let Some(m) = self + .channels + .get_mut(key) + .and_then(|c| c.rmembers.get_mut(&uuid)) + { + m.set_prefix(letter, adding); + } + } + } + + fn link_topic_recv(&mut self, via: Uid, msg: &Message) { + // : TOPIC #chan : + let Some(src) = msg.source.clone() else { + return; + }; + if msg.params.len() < 2 { + return; + } + let chan = msg.params[0].clone(); + let key = chan.to_ascii_lowercase(); + let text = msg.params[1].clone(); + if !self.channels.contains_key(&key) { + return; + } + let setter = self + .remote_users + .get(&src) + .map(|r| r.nick.clone()) + .unwrap_or_default(); + if let Some(c) = self.channels.get_mut(&key) { + c.topic = Some(Topic { + text: text.clone(), + setter, + ts: now(), + }); + } + let prefix = self.uuid_prefix(&src).unwrap_or_default(); + self.to_channel(&key, &format!(":{prefix} TOPIC {chan} :{text}"), None); + self.propagate(&format!(":{src} TOPIC {chan} :{text}"), Some(via)); + } + + fn link_kick_recv(&mut self, via: Uid, msg: &Message) { + // : KICK #chan : + let Some(src) = msg.source.clone() else { + return; + }; + if msg.params.len() < 2 { + return; + } + let chan = msg.params[0].clone(); + let key = chan.to_ascii_lowercase(); + let victim = msg.params[1].clone(); + let reason = msg.params.get(2).cloned().unwrap_or_else(|| victim.clone()); + let prefix = self.uuid_prefix(&src).unwrap_or_default(); + let mut removed = false; + if let Some(vuid) = self.find_nick(&victim) { + if let Some(c) = self.channels.get_mut(&key) { + removed = c.members.remove(&vuid).is_some(); + } + if removed { + if let Some(u) = self.users.get_mut(&vuid) { + u.channels.remove(&key); + } + } + } else if let Some((vuuid, _)) = self.find_remote(&victim) { + if let Some(c) = self.channels.get_mut(&key) { + removed = c.rmembers.remove(&vuuid).is_some(); + } + } + if !removed { + return; + } + self.to_channel( + &key, + &format!(":{prefix} KICK {chan} {victim} :{reason}"), + None, + ); + self.channels.retain(|_, c| !c.is_empty()); + self.propagate(&format!(":{src} KICK {chan} {victim} :{reason}"), Some(via)); + } + + fn link_mode_recv(&mut self, via: Uid, msg: &Message) { + // : MODE #chan [params...] (applied without re-checking) + let Some(src) = msg.source.clone() else { + return; + }; + if msg.params.len() < 2 || !msg.params[0].starts_with('#') { + return; + } + let chan = msg.params[0].clone(); + let key = chan.to_ascii_lowercase(); + if !self.channels.contains_key(&key) { + return; + } + let modestring = msg.params[1].clone(); + let args: Vec = msg.params[2..].to_vec(); + let mut argi = 0usize; + let mut sign = '+'; + for c in modestring.chars() { + if c == '+' || c == '-' { + sign = c; + continue; + } + let adding = sign == '+'; + match c { + 'q' | 'a' | 'o' | 'h' | 'v' => { + if let Some(n) = args.get(argi).cloned() { + argi += 1; + self.set_member_prefix(&key, &n, c, adding); + } + } + 'k' => { + let p = args.get(argi).cloned(); + if p.is_some() { + argi += 1; + } + if let Some(ch) = self.channels.get_mut(&key) { + ch.modes.key = if adding { p } else { None }; + } + } + 'l' => { + if adding { + if let Some(n) = args.get(argi).and_then(|s| s.parse::().ok()) { + argi += 1; + if let Some(ch) = self.channels.get_mut(&key) { + ch.modes.limit = Some(n); + } + } + } else if let Some(ch) = self.channels.get_mut(&key) { + ch.modes.limit = None; + } + } + 'b' | 'e' | 'I' => { + if let Some(mask) = args.get(argi).cloned() { + argi += 1; + let setter = self + .remote_users + .get(&src) + .map(|r| r.nick.clone()) + .unwrap_or_else(|| src.clone()); + if let Some(ch) = self.channels.get_mut(&key) { + let list = match c { + 'b' => &mut ch.bans, + 'e' => &mut ch.excepts, + _ => &mut ch.invex, + }; + if adding { + if !list.iter().any(|b| b.mask == mask) { + list.push(Ban { + mask, + setter, + ts: now(), + }); + } + } else { + list.retain(|b| b.mask != mask); + } + } + } + } + _ => { + if let Some(ch) = self.channels.get_mut(&key) { + ch.modes.set_by_letter(c, adding); + } + } + } + } + // source is a user (uuid) or, for a burst, a server (sid) + let prefix = self + .uuid_prefix(&src) + .or_else(|| self.servers.get(&src).map(|s| s.name.clone())) + .unwrap_or_else(|| self.name.clone()); + let paramstr = if args.is_empty() { + String::new() + } else { + format!(" {}", args.join(" ")) + }; + self.to_channel( + &key, + &format!(":{prefix} MODE {chan} {modestring}{paramstr}"), + None, + ); + self.propagate( + &format!(":{src} MODE {chan} {modestring}{paramstr}"), + Some(via), + ); + } + + /// Burst every channel (name, ts, modes, prefixed members) to a new peer. + fn burst_channels(&self, link_uid: Uid) { + let mut lines = Vec::new(); + for ch in self.channels.values() { + let mut mem: Vec = Vec::new(); + for (uid, m) in &ch.members { + if let Some(u) = self.users.get(uid) { + mem.push(format!("{}{}", m.all_prefixes(), u.uuid)); + } + } + for (uuid, m) in &ch.rmembers { + mem.push(format!("{}{}", m.all_prefixes(), uuid)); + } + if mem.is_empty() { + continue; + } + lines.push(format!( + ":{} FJOIN {} {} {} :{}", + self.sid, + ch.name, + ch.created, + ch.modes.render(false), + mem.join(" ") + )); + // burst the ban / except / invite-exception lists too + for (letter, list) in [('b', &ch.bans), ('e', &ch.excepts), ('I', &ch.invex)] { + for b in list { + lines.push(format!( + ":{} MODE {} +{letter} {}", + self.sid, ch.name, b.mask + )); + } + } + } + for l in lines { + self.link_out(link_uid, l); + } + } + + fn link_fjoin_recv(&mut self, via: Uid, msg: &Message) { + // : FJOIN #chan :uuid uuid ... + if msg.params.len() < 4 { + return; + } + let chan = msg.params[0].clone(); + let key = chan.to_ascii_lowercase(); + let ts: u64 = msg.params[1].parse().unwrap_or_else(|_| now()); + let modes = msg.params[2].clone(); + let memberlist = msg.params[3].clone(); + let is_new = !self.channels.contains_key(&key); + { + let ch = self + .channels + .entry(key.clone()) + .or_insert_with(|| Channel::new(&chan)); + if is_new { + ch.created = ts; + let mut sign = '+'; + for c in modes.chars() { + match c { + '+' => sign = '+', + '-' => sign = '-', + _ => ch.modes.set_by_letter(c, sign == '+'), + } + } + } + } + let mut adds: Vec<(String, Member)> = Vec::new(); + for tok in memberlist.split_whitespace() { + let (pfx, uuid) = split_member(tok); + if self.uuid_local.contains_key(&uuid) || !self.remote_users.contains_key(&uuid) { + continue; // our own user, or one we don't know yet + } + let mut m = Member::default(); + for pc in pfx.chars() { + m.set_prefix(prefix_letter(pc), true); + } + adds.push((uuid, m)); + } + if let Some(ch) = self.channels.get_mut(&key) { + for (uuid, m) in adds { + ch.rmembers.insert(uuid, m); + } + } + let raw = format!( + ":{} FJOIN {chan} {ts} {modes} :{memberlist}", + msg.source.clone().unwrap_or_default() + ); + self.propagate(&raw, Some(via)); + } +} + +/// Split a bursted member token `@+0AAAAAAAB` into its prefix chars and uuid. +fn split_member(tok: &str) -> (String, String) { + let idx = tok + .find(|c: char| !"~&@%+".contains(c)) + .unwrap_or(tok.len()); + (tok[..idx].to_string(), tok[idx..].to_string()) +} + +/// Map a prefix char to its mode letter (`@` → `o`, …). +fn prefix_letter(c: char) -> char { + match c { + '~' => 'q', + '&' => 'a', + '@' => 'o', + '%' => 'h', + '+' => 'v', + _ => ' ', + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sid_validation() { + assert!(valid_sid("0AA")); + assert!(valid_sid("1Z9")); + assert!(valid_sid("9ZZ")); + assert!(!valid_sid("AAA")); // must start with a digit + assert!(!valid_sid("0a1")); // no lowercase + assert!(!valid_sid("0A")); // too short + assert!(!valid_sid("0ABC")); // too long + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..ad23b3f --- /dev/null +++ b/src/main.rs @@ -0,0 +1,106 @@ +//! echoIRCd entry point: read config, bind the plaintext (and, if configured, +//! the TLS) listener, then run the single-threaded core while the accept loops +//! feed it connections. +#![forbid(unsafe_code)] + +use std::net::TcpListener; +use std::sync::atomic::AtomicU64; +use std::sync::mpsc; +use std::sync::Arc; +use std::thread; + +use echoircd::config::Config; +use echoircd::ircd::{Event, Ircd}; +use echoircd::socketengine; +use echoircd::tls::{OpensslBackend, TlsBackend}; + +fn main() { + let path = std::env::args() + .nth(1) + .unwrap_or_else(|| "echoircd.conf".to_string()); + let cfg = Config::load(&path); + + let listener = match TcpListener::bind(&cfg.bind) { + Ok(l) => l, + Err(e) => { + eprintln!("echoircd: cannot bind {}: {e}", cfg.bind); + std::process::exit(1); + } + }; + eprintln!( + "echoircd {} on {} (network {}, server {})", + env!("CARGO_PKG_VERSION"), + cfg.bind, + cfg.network, + cfg.servername + ); + + let (tx, rx) = mpsc::channel(); + let core_cfg = cfg.clone(); + let core = thread::spawn(move || Ircd::new(core_cfg).run(rx)); + + // background timer: drives ping/idle timeouts + let tick_tx = tx.clone(); + thread::spawn(move || loop { + thread::sleep(std::time::Duration::from_secs(echoircd::server::TICK_SECS)); + if tick_tx.send(Event::Tick).is_err() { + break; + } + }); + + // one uid counter shared by every listener so ids stay unique + let counter = Arc::new(AtomicU64::new(1)); + + // optional TLS listener (bind_tls + tls_cert + tls_key). A cert/bind problem + // disables TLS but never takes the plaintext listener down. + if let (Some(bind_tls), Some(cert), Some(key)) = (&cfg.bind_tls, &cfg.tls_cert, &cfg.tls_key) { + match OpensslBackend::new(cert, key) { + Ok(backend) => match TcpListener::bind(bind_tls) { + Ok(tls_listener) => { + eprintln!("echoircd TLS on {bind_tls} (openssl)"); + let backend: Arc = Arc::new(backend); + let tls_tx = tx.clone(); + let tls_counter = counter.clone(); + thread::spawn(move || { + socketengine::accept_loop( + tls_listener, + tls_tx, + Some(backend), + tls_counter, + false, + ) + }); + } + Err(e) => eprintln!("echoircd: cannot bind TLS {bind_tls}: {e}"), + }, + Err(e) => eprintln!("echoircd: TLS disabled (cert/key error): {e}"), + } + } + + // server-to-server link listener (see crate::link) + if let Some(bind_srv) = &cfg.bind_server { + match TcpListener::bind(bind_srv) { + Ok(sl) => { + eprintln!("echoircd S2S link listener on {bind_srv} (sid {})", cfg.sid); + let s_tx = tx.clone(); + let s_counter = counter.clone(); + thread::spawn(move || socketengine::accept_loop(sl, s_tx, None, s_counter, true)); + } + Err(e) => eprintln!("echoircd: cannot bind server port {bind_srv}: {e}"), + } + } + + // dial any autoconnect uplinks (after a short delay so the peer can boot) + for block in cfg.links.iter().filter(|b| b.autoconnect) { + let addr = format!("{}:{}", block.ip, block.port); + let u_tx = tx.clone(); + let u_counter = counter.clone(); + thread::spawn(move || { + thread::sleep(std::time::Duration::from_secs(2)); + socketengine::connect_link(&addr, u_tx, u_counter); + }); + } + + socketengine::accept_loop(listener, tx, None, counter, false); + let _ = core.join(); +} diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..0dda7b9 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,124 @@ +//! IRC line parsing (RFC 1459 + IRCv3 message tags). One line in → an optional +//! [`Message`] out. Client-only tags (the `+`-prefixed ones) are captured so +//! TAGMSG / PRIVMSG can relay them onward; server tags from clients are dropped. + +#[derive(Debug, PartialEq)] +pub struct Message { + /// The `:source` prefix, if any (clients rarely send one). + pub source: Option, + /// Command, upper-cased (`PRIVMSG`, `JOIN`, …) or a 3-digit numeric. + pub command: String, + /// Parameters, with the trailing `:param` unwrapped into the last element. + pub params: Vec, + /// Client-only IRCv3 tags (`+key=val;…`) re-serialised for relay; `""` if none. + pub ctags: String, +} + +/// Parse one wire line. Returns `None` for an empty/garbage line. +pub fn parse(line: &str) -> Option { + let mut rest = line.trim_start(); + + // IRCv3 message tags — keep the client-only (`+`) tags for relay, drop the rest. + let mut ctags = String::new(); + if let Some(after_at) = rest.strip_prefix('@') { + let (tags, r) = after_at.split_once(' ')?; + ctags = tags + .split(';') + .filter(|t| t.starts_with('+')) + .collect::>() + .join(";"); + rest = r.trim_start(); + } + + let mut source = None; + if let Some(after_colon) = rest.strip_prefix(':') { + let (src, r) = after_colon.split_once(' ')?; + source = Some(src.to_string()); + rest = r.trim_start(); + } + + let (cmd, mut rest) = match rest.split_once(' ') { + Some((c, r)) => (c, r.trim_start()), + None => (rest, ""), + }; + if cmd.is_empty() { + return None; + } + + let mut params = Vec::new(); + while !rest.is_empty() { + if let Some(trailing) = rest.strip_prefix(':') { + params.push(trailing.to_string()); + break; + } + match rest.split_once(' ') { + Some((p, r)) => { + params.push(p.to_string()); + rest = r.trim_start(); + } + None => { + params.push(rest.to_string()); + break; + } + } + } + + Some(Message { + source, + command: cmd.to_ascii_uppercase(), + params, + ctags, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn simple_command() { + let m = parse("NICK reverse").unwrap(); + assert_eq!(m.command, "NICK"); + assert_eq!(m.params, vec!["reverse"]); + assert!(m.source.is_none()); + } + + #[test] + fn trailing_keeps_spaces() { + let m = parse("PRIVMSG #argentina :hola que tal").unwrap(); + assert_eq!(m.command, "PRIVMSG"); + assert_eq!(m.params, vec!["#argentina", "hola que tal"]); + } + + #[test] + fn source_and_lowercase_command_upcased() { + let m = parse(":nick!u@h privmsg x :y").unwrap(); + assert_eq!(m.source.as_deref(), Some("nick!u@h")); + assert_eq!(m.command, "PRIVMSG"); + assert_eq!(m.params, vec!["x", "y"]); + } + + #[test] + fn tags_are_skipped() { + let m = parse("@id=1;time=x PING :token").unwrap(); + assert_eq!(m.command, "PING"); + assert_eq!(m.params, vec!["token"]); + assert_eq!(m.ctags, ""); // no client-only tags here + } + + #[test] + fn client_only_tags_kept_for_relay() { + let m = parse("@time=x;+typing=done;account=z TAGMSG #devs").unwrap(); + assert_eq!(m.command, "TAGMSG"); + assert_eq!(m.params, vec!["#devs"]); + assert_eq!(m.ctags, "+typing=done"); // server tags dropped, `+` kept + } + + #[test] + fn empty_and_junk() { + assert!(parse("").is_none()); + assert!(parse(" ").is_none()); + // a lone colon prefix with nothing after is not a message + assert!(parse(":only").is_none()); + } +} diff --git a/src/mode.rs b/src/mode.rs new file mode 100644 index 0000000..6c72d11 --- /dev/null +++ b/src/mode.rs @@ -0,0 +1,1002 @@ +//! Mode handlers — echoIRCd's answer to InspIRCd's C++ `ModeHandler`. +//! +//! Channel modes implement [`ChanMode`] and user modes implement [`UserMode`]; +//! the MODE command parses the modestring and dispatches to the handler for each +//! letter, so adding a mode is a new handler + one line in a table — never an +//! edit to the parser. +//! +//! Where this improves on the C++ original: the mode set is an ordinary slice, +//! so there's no fixed cap (InspIRCd's `ModeParser` packs modes into a bitmask); +//! every handler is a zero-sized `&'static`, so there's no per-mode allocation, +//! no global mutable registry to lock, and no `unsafe` — the borrow checker +//! rules out the dangling-handler bugs a C++ ircd has to guard against by hand. + +use crate::channels::{ + normalize_ban_mask, Ban, ChanModes, Channel, MsgFlood, Rate, RANK_ADMIN, RANK_HALFOP, RANK_OP, + RANK_OWNER, +}; +use crate::numeric::*; +use crate::server::{now, Server}; +use crate::users::UserFlags; +use crate::Uid; + +/// Outcome of applying one mode letter. +pub enum Applied { + /// Nothing to echo (a no-op, a rejected change, or a list query). + No, + /// Echo this change back to the channel; `Some(param)` appends a parameter. + Yes(Option), +} + +pub trait ChanMode: Sync { + fn letter(&self) -> char; + /// Whether to consume an argument for this sign (taken only if one remains). + fn wants_param(&self, adding: bool) -> bool; + /// Apply `+`/`-` to channel `key` (display name `chan`) on behalf of `uid`. + fn apply( + &self, + s: &mut Server, + chan: &str, + key: &str, + uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied; +} + +/// Look up the handler for a channel-mode letter. +pub fn chan_mode(c: char) -> Option<&'static (dyn ChanMode + Sync)> { + CHAN_MODES.iter().copied().find(|m| m.letter() == c) +} + +/// The registered channel modes. Add a mode by adding its handler here. +static CHAN_MODES: &[&(dyn ChanMode + Sync)] = &[ + &OWNER, + &ADMIN, + &OP, + &HALFOP, + &VOICE, + &BAN, + &EXCEPT, + &INVEX, + &KEY, + &LIMIT, + &MODERATED, + &NOEXTERNAL, + &TOPICLOCK, + &INVITEONLY, + &SECRET, + &SECUREONLY, + &PRIVATE, + &OPERONLY, + &NONICK, + &NOCTCP, + &NONOTICE, + &NOCOLOR, + &STRIPCOLOR, + ®ONLY, + ®MODERATED, + &CENSOR, + &AUDITORIUM, + &FILTER, + &MSGFLOOD, + &JOINFLOOD, + &NICKFLOOD, + &REDIRECT, +]; + +// --- prefix modes (+q/+a/+o/+h/+v): a per-member rank, needs a nick ---------- + +struct Prefix { + ch: char, + rank: u8, // the rank this prefix grants +} +static OWNER: Prefix = Prefix { + ch: 'q', + rank: RANK_OWNER, +}; +static ADMIN: Prefix = Prefix { + ch: 'a', + rank: RANK_ADMIN, +}; +static OP: Prefix = Prefix { + ch: 'o', + rank: RANK_OP, +}; +static HALFOP: Prefix = Prefix { + ch: 'h', + rank: RANK_HALFOP, +}; +static VOICE: Prefix = Prefix { + ch: 'v', + rank: 1, // RANK_VOICE +}; + +impl ChanMode for Prefix { + fn letter(&self) -> char { + self.ch + } + fn wants_param(&self, _adding: bool) -> bool { + true + } + fn apply( + &self, + s: &mut Server, + chan: &str, + key: &str, + uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + let Some(pn) = param else { + return Applied::No; + }; + let Some(tuid) = s.find_nick(pn) else { + s.numeric(uid, ERR_NOSUCHNICK, &format!("{pn} :No such nick/channel")); + return Applied::No; + }; + let target_rank = match s.channels.get(key).and_then(|c| c.members.get(&tuid)) { + Some(m) => m.rank(), + None => { + s.numeric( + uid, + ERR_USERNOTINCHANNEL, + &format!("{pn} {chan} :They aren't on that channel"), + ); + return Applied::No; + } + }; + // must out-rank (or match) both the prefix being set and the target's + // current top rank — no de-opping someone above you. + let src = s.rank(uid, key); + if src < self.rank || src < target_rank { + s.numeric( + uid, + ERR_CHANOPRIVSNEEDED, + &format!("{chan} :You lack the channel rank to set +{}", self.ch), + ); + return Applied::No; + } + if let Some(m) = s + .channels + .get_mut(key) + .and_then(|c| c.members.get_mut(&tuid)) + { + match self.rank { + RANK_OWNER => m.owner = adding, + RANK_ADMIN => m.admin = adding, + RANK_OP => m.op = adding, + RANK_HALFOP => m.halfop = adding, + _ => m.voice = adding, + } + } + Applied::Yes(Some(pn.to_string())) + } +} + +// --- simple flag modes (+m +n +t +i +s): one bool, no param ----------------- + +struct Flag { + ch: char, + set: fn(&mut ChanModes, bool), +} +fn set_moderated(m: &mut ChanModes, v: bool) { + m.moderated = v; +} +fn set_noexternal(m: &mut ChanModes, v: bool) { + m.no_external = v; +} +fn set_topiclock(m: &mut ChanModes, v: bool) { + m.topic_ops = v; +} +fn set_inviteonly(m: &mut ChanModes, v: bool) { + m.invite_only = v; +} +fn set_secret(m: &mut ChanModes, v: bool) { + m.secret = v; +} +static MODERATED: Flag = Flag { + ch: 'm', + set: set_moderated, +}; +static NOEXTERNAL: Flag = Flag { + ch: 'n', + set: set_noexternal, +}; +static TOPICLOCK: Flag = Flag { + ch: 't', + set: set_topiclock, +}; +static INVITEONLY: Flag = Flag { + ch: 'i', + set: set_inviteonly, +}; +static SECRET: Flag = Flag { + ch: 's', + set: set_secret, +}; +fn set_private(m: &mut ChanModes, v: bool) { + m.private = v; +} +fn set_operonly(m: &mut ChanModes, v: bool) { + m.oper_only = v; +} +fn set_nonick(m: &mut ChanModes, v: bool) { + m.no_nick = v; +} +fn set_noctcp(m: &mut ChanModes, v: bool) { + m.no_ctcp = v; +} +fn set_nonotice(m: &mut ChanModes, v: bool) { + m.no_notice = v; +} +fn set_nocolor(m: &mut ChanModes, v: bool) { + m.no_color = v; +} +fn set_stripcolor(m: &mut ChanModes, v: bool) { + m.strip_color = v; +} +static PRIVATE: Flag = Flag { + ch: 'p', + set: set_private, +}; +static OPERONLY: Flag = Flag { + ch: 'O', + set: set_operonly, +}; +static NONICK: Flag = Flag { + ch: 'N', + set: set_nonick, +}; +static NOCTCP: Flag = Flag { + ch: 'C', + set: set_noctcp, +}; +static NONOTICE: Flag = Flag { + ch: 'T', + set: set_nonotice, +}; +static NOCOLOR: Flag = Flag { + ch: 'c', + set: set_nocolor, +}; +static STRIPCOLOR: Flag = Flag { + ch: 'S', + set: set_stripcolor, +}; +fn set_regonly(m: &mut ChanModes, v: bool) { + m.reg_only = v; +} +fn set_regmoderated(m: &mut ChanModes, v: bool) { + m.reg_moderated = v; +} +static REGONLY: Flag = Flag { + ch: 'R', + set: set_regonly, +}; +static REGMODERATED: Flag = Flag { + ch: 'M', + set: set_regmoderated, +}; +fn set_censor(m: &mut ChanModes, v: bool) { + m.censor = v; +} +fn set_auditorium(m: &mut ChanModes, v: bool) { + m.auditorium = v; +} +static CENSOR: Flag = Flag { + ch: 'G', + set: set_censor, +}; +static AUDITORIUM: Flag = Flag { + ch: 'u', + set: set_auditorium, +}; + +impl ChanMode for Flag { + fn letter(&self) -> char { + self.ch + } + fn wants_param(&self, _adding: bool) -> bool { + false + } + fn apply( + &self, + s: &mut Server, + _chan: &str, + key: &str, + _uid: Uid, + adding: bool, + _param: Option<&str>, + ) -> Applied { + if let Some(c) = s.channels.get_mut(key) { + (self.set)(&mut c.modes, adding); + } + Applied::Yes(None) + } +} + +// --- +k channel key --------------------------------------------------------- + +struct Key; +static KEY: Key = Key; +impl ChanMode for Key { + fn letter(&self) -> char { + 'k' + } + fn wants_param(&self, _adding: bool) -> bool { + true // take the key on set; consume (and ignore) it on unset + } + fn apply( + &self, + s: &mut Server, + _chan: &str, + key: &str, + _uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + if adding { + let Some(k) = param else { + return Applied::No; + }; + if let Some(c) = s.channels.get_mut(key) { + c.modes.key = Some(k.to_string()); + } + Applied::Yes(Some(k.to_string())) + } else { + if let Some(c) = s.channels.get_mut(key) { + c.modes.key = None; + } + Applied::Yes(Some("*".to_string())) + } + } +} + +// --- +l user limit ---------------------------------------------------------- + +struct Limit; +static LIMIT: Limit = Limit; +impl ChanMode for Limit { + fn letter(&self) -> char { + 'l' + } + fn wants_param(&self, adding: bool) -> bool { + adding // +l takes a number; -l takes nothing + } + fn apply( + &self, + s: &mut Server, + _chan: &str, + key: &str, + _uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + if adding { + let Some(n) = param.and_then(|p| p.parse::().ok()) else { + return Applied::No; + }; + if let Some(c) = s.channels.get_mut(key) { + c.modes.limit = Some(n); + } + Applied::Yes(Some(n.to_string())) + } else { + if let Some(c) = s.channels.get_mut(key) { + c.modes.limit = None; + } + Applied::Yes(None) + } + } +} + +// --- list modes (+b bans, +e ban exceptions, +I invite exceptions) ---------- + +#[derive(Clone, Copy)] +enum ListKind { + Ban, + Except, + Invex, + Filter, // +g — message word/glob filters (not host masks) +} +impl ListKind { + fn list<'a>(&self, c: &'a Channel) -> &'a Vec { + match self { + ListKind::Ban => &c.bans, + ListKind::Except => &c.excepts, + ListKind::Invex => &c.invex, + ListKind::Filter => &c.filters, + } + } + fn list_mut<'a>(&self, c: &'a mut Channel) -> &'a mut Vec { + match self { + ListKind::Ban => &mut c.bans, + ListKind::Except => &mut c.excepts, + ListKind::Invex => &mut c.invex, + ListKind::Filter => &mut c.filters, + } + } + /// (per-entry numeric, end-of-list numeric, name for the "End of …" line) + fn numerics(&self) -> (u16, u16, &'static str) { + match self { + ListKind::Ban => (RPL_BANLIST, RPL_ENDOFBANLIST, "ban list"), + ListKind::Except => (RPL_EXCEPTLIST, RPL_ENDOFEXCEPTLIST, "exception list"), + ListKind::Invex => (RPL_INVEXLIST, RPL_ENDOFINVEXLIST, "invite list"), + ListKind::Filter => (RPL_SPAMFILTER, RPL_ENDOFSPAMFILTER, "spamfilter list"), + } + } + /// Ban-style lists hold host masks and get filled out to `nick!user@host`; + /// the +g filter list holds literal word/globs and is stored verbatim. + fn normalizes(&self) -> bool { + !matches!(self, ListKind::Filter) + } +} + +struct ListMode { + ch: char, + kind: ListKind, +} +static BAN: ListMode = ListMode { + ch: 'b', + kind: ListKind::Ban, +}; +static EXCEPT: ListMode = ListMode { + ch: 'e', + kind: ListKind::Except, +}; +static INVEX: ListMode = ListMode { + ch: 'I', + kind: ListKind::Invex, +}; +static FILTER: ListMode = ListMode { + ch: 'g', + kind: ListKind::Filter, +}; + +impl ChanMode for ListMode { + fn letter(&self) -> char { + self.ch + } + fn wants_param(&self, _adding: bool) -> bool { + true // a mask to add/remove; absent ⇒ list query + } + fn apply( + &self, + s: &mut Server, + chan: &str, + key: &str, + uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + let (entry_num, end_num, noun) = self.kind.numerics(); + // no mask ⇒ list query + let Some(mask) = param else { + let rows: Vec<(String, String, u64)> = s + .channels + .get(key) + .map(|c| { + self.kind + .list(c) + .iter() + .map(|b| (b.mask.clone(), b.setter.clone(), b.ts)) + .collect() + }) + .unwrap_or_default(); + for (m, setter, ts) in rows { + s.numeric(uid, entry_num, &format!("{chan} {m} {setter} {ts}")); + } + s.numeric(uid, end_num, &format!("{chan} :End of channel {noun}")); + return Applied::No; + }; + let mask = if self.kind.normalizes() { + normalize_ban_mask(mask) + } else { + mask.to_string() + }; + if adding { + let setter = s + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + if let Some(c) = s.channels.get_mut(key) { + let list = self.kind.list_mut(c); + if list.iter().any(|b| b.mask == mask) { + return Applied::No; // already present + } + list.push(Ban { + mask: mask.clone(), + setter, + ts: now(), + }); + } + Applied::Yes(Some(mask)) + } else { + let mut removed = false; + if let Some(c) = s.channels.get_mut(key) { + let list = self.kind.list_mut(c); + let before = list.len(); + list.retain(|b| b.mask != mask); + removed = list.len() < before; + } + if removed { + Applied::Yes(Some(mask)) + } else { + Applied::No + } + } + } +} + +// --- +z secure-only (InspIRCd m_sslmodes) ----------------------------------- + +/// `+z` — only TLS-connected users may join. It can only be *set* when every +/// current member is already on TLS (else `ERR_ALLMUSTSSL`); the join-time block +/// for non-secure users lives in [`crate::channels::Server::join`]. +struct SecureOnly; +static SECUREONLY: SecureOnly = SecureOnly; +impl ChanMode for SecureOnly { + fn letter(&self) -> char { + 'z' + } + fn wants_param(&self, _adding: bool) -> bool { + false + } + fn apply( + &self, + s: &mut Server, + chan: &str, + key: &str, + uid: Uid, + adding: bool, + _param: Option<&str>, + ) -> Applied { + if adding { + let members: Vec = s + .channels + .get(key) + .map(|c| c.members.keys().copied().collect()) + .unwrap_or_default(); + let total = members.len(); + let nonssl = members + .iter() + .filter(|m| !s.users.get(m).map(|u| u.secure).unwrap_or(false)) + .count(); + if nonssl > 0 { + s.numeric( + uid, + ERR_ALLMUSTSSL, + &format!( + "{chan} :All members of the channel must be connected using TLS ({nonssl}/{total} are non-TLS)" + ), + ); + return Applied::No; + } + } + if let Some(c) = s.channels.get_mut(key) { + c.modes.secure_only = adding; + } + Applied::Yes(None) + } +} + +// --- flood / rate modes (+f message, +j join, +F nick) and +L redirect ------ + +/// Parse a `count:secs` rate; both parts must be positive. +fn parse_rate(p: &str) -> Option { + let (a, b) = p.split_once(':')?; + let count = a.parse::().ok()?; + let secs = b.parse::().ok()?; + (count > 0 && secs > 0).then_some(Rate { count, secs }) +} + +/// `+f [*]lines:secs` — kick a user who sends more than `lines` messages in +/// `secs`; a leading `*` also sets a +b ban on them. Enforced in the PRIVMSG path. +struct MsgFloodMode; +static MSGFLOOD: MsgFloodMode = MsgFloodMode; +impl ChanMode for MsgFloodMode { + fn letter(&self) -> char { + 'f' + } + fn wants_param(&self, adding: bool) -> bool { + adding + } + fn apply( + &self, + s: &mut Server, + _chan: &str, + key: &str, + _uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + if adding { + let Some(p) = param else { + return Applied::No; + }; + let ban = p.starts_with('*'); + let Some(r) = parse_rate(if ban { &p[1..] } else { p }) else { + return Applied::No; + }; + if let Some(c) = s.channels.get_mut(key) { + c.modes.flood = Some(MsgFlood { + lines: r.count, + secs: r.secs, + ban, + }); + } + Applied::Yes(Some(p.to_string())) + } else { + if let Some(c) = s.channels.get_mut(key) { + c.modes.flood = None; + } + Applied::Yes(None) + } + } +} + +/// `+j joins:secs` — after that many joins in the window, the channel locks new +/// joins out for 60s. Enforced in [`crate::channels::Server::join`]. +struct JoinFloodMode; +static JOINFLOOD: JoinFloodMode = JoinFloodMode; +impl ChanMode for JoinFloodMode { + fn letter(&self) -> char { + 'j' + } + fn wants_param(&self, adding: bool) -> bool { + adding + } + fn apply( + &self, + s: &mut Server, + _chan: &str, + key: &str, + _uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + if adding { + let Some(r) = param.and_then(parse_rate) else { + return Applied::No; + }; + let echo = format!("{}:{}", r.count, r.secs); + if let Some(c) = s.channels.get_mut(key) { + c.modes.joinflood = Some(r); + } + Applied::Yes(Some(echo)) + } else { + if let Some(c) = s.channels.get_mut(key) { + c.modes.joinflood = None; + } + Applied::Yes(None) + } + } +} + +/// `+F changes:secs` — after that many nick changes in the window, nick changes +/// on the channel are blocked for 60s. Enforced in the NICK path. +struct NickFloodMode; +static NICKFLOOD: NickFloodMode = NickFloodMode; +impl ChanMode for NickFloodMode { + fn letter(&self) -> char { + 'F' + } + fn wants_param(&self, adding: bool) -> bool { + adding + } + fn apply( + &self, + s: &mut Server, + _chan: &str, + key: &str, + _uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + if adding { + let Some(r) = param.and_then(parse_rate) else { + return Applied::No; + }; + let echo = format!("{}:{}", r.count, r.secs); + if let Some(c) = s.channels.get_mut(key) { + c.modes.nickflood = Some(r); + } + Applied::Yes(Some(echo)) + } else { + if let Some(c) = s.channels.get_mut(key) { + c.modes.nickflood = None; + } + Applied::Yes(None) + } + } +} + +/// `+L #target` — when the channel is full (+l) new joiners are sent to +/// `#target` instead. Enforced in [`crate::channels::Server::join`]. +struct RedirectMode; +static REDIRECT: RedirectMode = RedirectMode; +impl ChanMode for RedirectMode { + fn letter(&self) -> char { + 'L' + } + fn wants_param(&self, adding: bool) -> bool { + adding + } + fn apply( + &self, + s: &mut Server, + chan: &str, + key: &str, + _uid: Uid, + adding: bool, + param: Option<&str>, + ) -> Applied { + if adding { + let Some(t) = param else { + return Applied::No; + }; + if !t.starts_with('#') || t.eq_ignore_ascii_case(chan) { + return Applied::No; // must be another channel + } + if let Some(c) = s.channels.get_mut(key) { + c.modes.redirect = Some(t.to_string()); + } + Applied::Yes(Some(t.to_string())) + } else { + if let Some(c) = s.channels.get_mut(key) { + c.modes.redirect = None; + } + Applied::Yes(None) + } + } +} + +// === user modes ============================================================ + +/// A user mode (+i/+w/+o) — same handler-object shape as [`ChanMode`], and the +/// same win over the C++ mode system: an unbounded slice of zero-sized +/// `&'static` handlers, no bitmask cap, no allocation, no `unsafe`. +pub trait UserMode: Sync { + fn letter(&self) -> char; + /// Apply `+`/`-` to the user; return `true` if it took effect (echo it). + fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool; +} + +/// Look up the handler for a user-mode letter. +pub fn user_mode(c: char) -> Option<&'static (dyn UserMode + Sync)> { + USER_MODES.iter().copied().find(|m| m.letter() == c) +} + +static USER_MODES: &[&(dyn UserMode + Sync)] = &[ + &INVISIBLE, + &WALLOPS, + &OPER, + &CLOAK, + &BOT, + &DEAF, + &HIDECHANS, + &HIDEOPER, + ®DEAF, + ®ISTERED, + &SSLPM, + &SNOMASK, +]; + +/// A simple boolean user flag (+i / +w). +struct UFlag { + ch: char, + set: fn(&mut UserFlags, bool), +} +fn set_invisible(f: &mut UserFlags, v: bool) { + f.invisible = v; +} +fn set_wallops(f: &mut UserFlags, v: bool) { + f.wallops = v; +} +static INVISIBLE: UFlag = UFlag { + ch: 'i', + set: set_invisible, +}; +static WALLOPS: UFlag = UFlag { + ch: 'w', + set: set_wallops, +}; +fn set_bot(f: &mut UserFlags, v: bool) { + f.bot = v; +} +fn set_deaf(f: &mut UserFlags, v: bool) { + f.deaf = v; +} +fn set_hidechans(f: &mut UserFlags, v: bool) { + f.hidechans = v; +} +fn set_hideoper(f: &mut UserFlags, v: bool) { + f.hideoper = v; +} +fn set_regdeaf(f: &mut UserFlags, v: bool) { + f.reg_only_pm = v; +} +fn set_sslpm(f: &mut UserFlags, v: bool) { + f.ssl_pm = v; +} +static BOT: UFlag = UFlag { + ch: 'B', + set: set_bot, +}; +static DEAF: UFlag = UFlag { + ch: 'D', + set: set_deaf, +}; +static HIDECHANS: UFlag = UFlag { + ch: 'I', + set: set_hidechans, +}; +static HIDEOPER: UFlag = UFlag { + ch: 'H', + set: set_hideoper, +}; +static REGDEAF: UFlag = UFlag { + ch: 'R', + set: set_regdeaf, +}; +static SSLPM: UFlag = UFlag { + ch: 'z', + set: set_sslpm, +}; + +impl UserMode for UFlag { + fn letter(&self) -> char { + self.ch + } + fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool { + if let Some(u) = s.users.get_mut(&uid) { + (self.set)(&mut u.flags, adding); + true + } else { + false + } + } +} + +/// `+o` is granted only by OPER; a user may `-o` (de-oper) themselves. +struct OperMode; +static OPER: OperMode = OperMode; +impl UserMode for OperMode { + fn letter(&self) -> char { + 'o' + } + fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool { + if adding { + return false; // never self-granted + } + if let Some(u) = s.users.get_mut(&uid) { + u.flags.oper = false; + true + } else { + false + } + } +} + +/// `+x` — host cloaking. The cloak string is computed once at connect by +/// [`crate::modules::cloak`]; this handler only toggles whether it's shown. +/// `-x` (revealing the real host) is oper-only, so +x can't be flipped to dodge +/// a channel ban that was set on the cloak. +struct CloakMode; +static CLOAK: CloakMode = CloakMode; +impl UserMode for CloakMode { + fn letter(&self) -> char { + 'x' + } + fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool { + // No-op unless a cloak was actually computed (i.e. a cloak_key is set). + let has_cloak = s + .users + .get(&uid) + .map(|u| !u.cloak.is_empty()) + .unwrap_or(false); + if !has_cloak { + return false; + } + if !adding && !s.is_oper(uid) { + s.numeric( + uid, + ERR_NOPRIVILEGES, + ":Only operators may remove the +x host cloak", + ); + return false; + } + let old_prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default(); + if let Some(u) = s.users.get_mut(&uid) { + u.flags.cloak = adding; + } + let (ident, disp) = s + .users + .get(&uid) + .map(|u| (u.ident.clone(), u.host_display().to_string())) + .unwrap_or_default(); + // chghost: tell capable peers the displayed host changed + s.notify_peers(uid, &format!(":{old_prefix} CHGHOST {ident} {disp}"), |c| { + c.chghost + }); + s.numeric( + uid, + RPL_HOSTHIDDEN, + &format!("{disp} :is now your displayed host"), + ); + true + } +} + +/// `+r` — "logged into an account". Set/cleared only by **services** (via +/// [`crate::server::Server::set_login`]); the user can never toggle it by hand. +struct RegisteredMode; +static REGISTERED: RegisteredMode = RegisteredMode; +impl UserMode for RegisteredMode { + fn letter(&self) -> char { + 'r' + } + fn apply(&self, _s: &mut Server, _uid: Uid, _adding: bool) -> bool { + false // services-managed; not user-settable + } +} + +/// `+s` — server-notice (snomask) receiver. Oper-only to set; anyone may drop it. +struct SnoMode; +static SNOMASK: SnoMode = SnoMode; +impl UserMode for SnoMode { + fn letter(&self) -> char { + 's' + } + fn apply(&self, s: &mut Server, uid: Uid, adding: bool) -> bool { + if adding && !s.is_oper(uid) { + return false; // only operators receive server notices + } + if let Some(u) = s.users.get_mut(&uid) { + u.flags.snomask = adding; + true + } else { + false + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn registry_covers_all_channel_modes() { + for c in "qaohvbeIklmntiszpONCTcSRMfjFLgGu".chars() { + assert!(chan_mode(c).is_some(), "missing handler for +{c}"); + } + assert!(chan_mode('y').is_none()); + } + + #[test] + fn rate_parsing() { + assert!(parse_rate("5:10").is_some()); + assert!(parse_rate("0:10").is_none()); // zero count rejected + assert!(parse_rate("5:0").is_none()); // zero window rejected + assert!(parse_rate("bad").is_none()); + } + + #[test] + fn registry_covers_all_user_modes() { + for c in "iwoxBDIHrRzs".chars() { + assert!(user_mode(c).is_some(), "missing umode +{c}"); + } + assert!(user_mode('Q').is_none()); + } + + #[test] + fn masks_are_normalized() { + assert_eq!(normalize_ban_mask("bob"), "bob!*@*"); + assert_eq!(normalize_ban_mask("bob@evil.host"), "*!bob@evil.host"); // user@host + assert_eq!(normalize_ban_mask("a!b@c"), "a!b@c"); + // extbans keep their type; only the hostmask part is filled out + assert_eq!(normalize_ban_mask("m:bob"), "m:bob!*@*"); + assert_eq!(normalize_ban_mask("c:*@evil"), "c:*!*@evil"); + } +} diff --git a/src/module.rs b/src/module.rs new file mode 100644 index 0000000..19f4ff3 --- /dev/null +++ b/src/module.rs @@ -0,0 +1,69 @@ +//! The module API — echoIRCd's answer to InspIRCd's `Module` class. +//! +//! Modules hook lifecycle events. "Pre" hooks return a [`ModResult`] and can +//! **deny** an action; "notify" hooks are informational. The core fires pre-hooks +//! inline (so a `Deny` actually blocks) and notify-hooks from a queue after the +//! triggering command finishes — so a handler can emit an event without ever +//! touching the module list. All hooks get `&mut Server`, so a module can act +//! (send lines, force a join, …), exactly like an InspIRCd module gets the +//! `ServerInstance`. + +use crate::server::Server; +use crate::Uid; + +/// A pre-hook's verdict. `Passthru` = no opinion; `Allow` = force-allow (skip +/// remaining checks); `Deny` = block the action. +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ModResult { + Passthru, + Allow, + Deny, +} + +/// A queued notify-event, drained by the core after each command. +pub enum Hook { + Connect(Uid), + Join(Uid, String), + Part(Uid, String, String), + Quit(Uid, String), +} + +#[allow(unused_variables)] +pub trait Module: Send { + fn name(&self) -> &'static str; + + // --- pre-hooks (can Deny) ------------------------------------------------ + + /// Last gate before a client finishes registration. `Deny` refuses the link. + fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult { + ModResult::Passthru + } + /// Before any command runs. `Deny` swallows the command silently. + fn on_pre_command( + &mut self, + srv: &mut Server, + uid: Uid, + cmd: &str, + params: &[String], + ) -> ModResult { + ModResult::Passthru + } + /// Before a PRIVMSG/NOTICE is delivered. `Deny` drops it. + fn on_pre_message( + &mut self, + srv: &mut Server, + uid: Uid, + target: &str, + text: &str, + ) -> ModResult { + ModResult::Passthru + } + + // --- notify-hooks -------------------------------------------------------- + + fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {} + fn on_post_command(&mut self, srv: &mut Server, uid: Uid, cmd: &str) {} + fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {} + fn on_part(&mut self, srv: &mut Server, uid: Uid, chan: &str, reason: &str) {} + fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) {} +} diff --git a/src/modules/antimixedutf8.rs b/src/modules/antimixedutf8.rs new file mode 100644 index 0000000..c213905 --- /dev/null +++ b/src/modules/antimixedutf8.rs @@ -0,0 +1,371 @@ +//! antimixedutf8 — blocks spam that mixes Unicode scripts within words (Latin +//! letters swapped for Cyrillic/Greek look-alikes: "FᏒee Ⅴ1аgrа"), a very common +//! obfuscation. This is reverse's own detection model — the scoring rules and the +//! confusable / fancy-Latin / zero-width tables — implemented from scratch in +//! native Rust. (The *tables and weights* are the detector's spec: they define +//! what counts as spam. Everything around them is original echoIRCd code.) +//! +//! Per word: letters from more than one script score; so do words that are ASCII +//! mixed with Latin-confusable letters, words built almost entirely of confusables, +//! and "fancy" styled-Latin words. Zero-width chars score too. At/above the +//! configured threshold the action fires (block | kill | gline | kline | zline). +//! +//! Rust strings are valid UTF-8, so we walk codepoints straight from `chars()` and +//! fold the per-word state through a `Scorer` — idiomatic Rust, no manual UTF-8 +//! decoding and no ref-capturing lambdas. + +use crate::module::{ModResult, Module}; +use crate::server::Server; +use crate::xline::XKind; +use crate::Uid; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Script { + Other = 0, // digits, punctuation, symbols — ignored for mixing + Latin, + Cyrillic, + Greek, + Armenian, + Hebrew, + Arabic, + Cjk, +} + +/// Map a codepoint to a script; `Other` for anything that isn't a letter we track. +fn classify_script(cp: u32) -> Script { + match cp { + 0x41..=0x5A | 0x61..=0x7A => Script::Latin, // ASCII A-Z a-z + 0x00C0..=0x024F => Script::Latin, // Latin-1 suppl + extended + 0x0370..=0x03FF => Script::Greek, + 0x0400..=0x04FF => Script::Cyrillic, + 0x0530..=0x058F => Script::Armenian, + 0x0590..=0x05FF => Script::Hebrew, + 0x0600..=0x06FF => Script::Arabic, + 0x4E00..=0x9FFF => Script::Cjk, // CJK unified + 0x3040..=0x30FF => Script::Cjk, // hiragana / katakana + _ => Script::Other, + } +} + +/// A non-Latin letter that LOOKS like an ASCII Latin letter (the homoglyphs +/// spammers swap in). Catches pure-homoglyph words that script-mixing misses, +/// without tripping on genuine monolingual text. +fn is_latin_confusable(cp: u32) -> bool { + matches!( + cp, + // Cyrillic look-alikes + 0x0430 | 0x0410 | 0x0435 | 0x0415 | 0x043E | 0x041E | 0x0440 | 0x0420 | + 0x0441 | 0x0421 | 0x0443 | 0x0423 | 0x0445 | 0x0425 | 0x0456 | 0x0406 | + 0x0455 | 0x0405 | 0x0458 | 0x0408 | 0x043A | 0x041A | 0x043C | 0x041C | + 0x043D | 0x041D | 0x0432 | 0x0412 | 0x0442 | 0x0422 | + // Greek look-alikes + 0x03BF | 0x039F | 0x03B1 | 0x0391 | 0x03B5 | 0x0395 | 0x03C1 | 0x03A1 | + 0x03C5 | 0x03A5 | 0x03BD | 0x03BA | 0x039A | 0x03B9 | 0x0399 | 0x03BC | + 0x0392 | 0x039D | 0x03A4 | 0x0397 | 0x03A7 | 0x0396 + ) +} + +/// "Fancy" Latin: fullwidth, mathematical alphanumerics, enclosed/circled letters. +/// These render as styled ASCII ("𝐅𝐫𝐞𝐞", "Free", "🅵🆁🅴🅴") — pure obfuscation. +fn is_fancy_latin(cp: u32) -> bool { + matches!( + cp, + 0xFF21..=0xFF5A // fullwidth A-Z a-z + | 0x1D400..=0x1D7FF // mathematical alphanumeric symbols + | 0x1F130..=0x1F189 // squared/enclosed latin + | 0x24B6..=0x24E9 // circled latin + | 0x2460..=0x24FF // enclosed alphanumerics (loose) + ) +} + +/// Invisible / zero-width characters used to split words and evade filters. +fn is_invisible(cp: u32) -> bool { + matches!( + cp, + 0x00AD | 0x200B | 0x200C | 0x200D | 0x2060 | 0xFEFF | 0x180E + ) +} + +/// Per-message tally, folded word by word: the per-word scoring state as a struct. +#[derive(Default)] +struct Scorer { + mixedwords: u32, // words mixing >1 real script + homoglyphwords: u32, // ASCII + confusable letters in one word + purehomowords: u32, // word made (almost) entirely of confusables + fancywords: u32, // words containing fancy/styled latin + invisibles: u32, // zero-width chars anywhere + totalletters: u32, + latinletters: u32, + wordhas: [bool; 8], + word_has_ascii: bool, + word_has_confusable: bool, + word_has_fancy: bool, + word_letters: u32, + word_confusables: u32, +} + +impl Scorer { + fn word_scripts(&self) -> u32 { + (1..8).filter(|&s| self.wordhas[s]).count() as u32 + } + + fn reset_word(&mut self) { + self.wordhas = [false; 8]; + self.word_has_ascii = false; + self.word_has_confusable = false; + self.word_has_fancy = false; + self.word_letters = 0; + self.word_confusables = 0; + } + + fn end_word(&mut self) { + // Confusable mixed WITH real ASCII in one word = the classic "swap a few + // letters" attack (already script-mixing) — count it ONCE here so a single + // stray homoglyph doesn't double-score. + if self.word_has_confusable && self.word_has_ascii { + self.homoglyphwords += 1; + } else if self.word_scripts() >= 2 { + self.mixedwords += 1; + } else if !self.word_has_ascii + && self.word_scripts() == 1 + && self.word_letters >= 4 + && self.word_confusables * 100 / self.word_letters >= 80 + { + // single-script word with no ASCII, ≥80% confusables = Latin in disguise + self.purehomowords += 1; + } + if self.word_has_fancy { + self.fancywords += 1; + } + self.reset_word(); + } +} + +/// Score a message for look-alike / obfuscated-text spam. Higher = worse; genuine +/// monolingual text (any script) stays at 0. +fn score_message(text: &str) -> u32 { + let mut sc = Scorer::default(); + for cp in text.chars().map(|c| c as u32) { + if is_invisible(cp) { + sc.invisibles += 1; + continue; // not a word boundary + } + let fancy = is_fancy_latin(cp); + let confusable = is_latin_confusable(cp); + let script = classify_script(cp); + let isletter = script != Script::Other || fancy; + let isboundary = matches!(cp, 0x20 | 0x09 | 0x2C | 0x2E | 0x21 | 0x3F | 0xFFFD); + + if isletter { + if script != Script::Other { + sc.wordhas[script as usize] = true; + } + sc.totalletters += 1; + sc.word_letters += 1; + if script == Script::Latin { + sc.latinletters += 1; + sc.word_has_ascii = true; + } + if confusable { + sc.word_has_confusable = true; + sc.word_confusables += 1; + } + if fancy { + sc.word_has_fancy = true; + } + } + if isboundary { + sc.end_word(); + } + } + sc.end_word(); // final word + + // One disguised word is usually an accident (a pasted Cyrillic letter); real + // attacks disguise MANY. Grant a 1-word grace. + let disguised = sc.homoglyphwords + sc.mixedwords + sc.purehomowords + sc.fancywords; + let effective = disguised.saturating_sub(1); + + let mut score = 0u32; + score += effective * 5; // each disguised word past the first + score += sc.fancywords; // styled unicode is rarely innocent + score += sc.invisibles * 3; // zero-width evasion is always suspicious + + // Ratio bonus: only with real disguise (≥2 words) and non-Latin dominance. + if sc.totalletters >= 8 && disguised >= 2 { + let nonlatin = sc.totalletters - sc.latinletters; + if nonlatin > 0 && sc.latinletters > 0 && nonlatin * 100 / sc.totalletters >= 40 { + score += 3; + } + } + score +} + +/// If `text` is a CTCP, return the ACTION body to check, else `None` to skip +/// (non-ACTION CTCPs aren't scanned). Plain messages return the text unchanged. +fn checkable(text: &str) -> Option<&str> { + let Some(inner) = text.strip_prefix('\u{01}') else { + return Some(text); + }; + let inner = inner.strip_suffix('\u{01}').unwrap_or(inner); + let (name, body) = inner.split_once(' ').unwrap_or((inner, "")); + if name.eq_ignore_ascii_case("ACTION") { + Some(body) + } else { + None + } +} + +/// A single-line, length-capped snippet of a blocked message for the oper +/// snotice. Keeps the look-alike glyphs visible (that's the point) but neutralises +/// every control byte (CR/LF, mIRC formatting) so it can't inject into or break +/// the protocol line the snotice is embedded in. +fn snippet(text: &str) -> String { + const MAX: usize = 120; + let mut out = String::new(); + for (i, ch) in text.chars().enumerate() { + if i >= MAX { + out.push('…'); + break; + } + if (ch as u32) < 0x20 || ch == '\u{7f}' { + out.push(' '); + } else { + out.push(ch); + } + } + out +} + +pub struct AntiMixedUtf8; + +impl Module for AntiMixedUtf8 { + fn name(&self) -> &'static str { + "antimixedutf8" + } + + fn on_pre_message( + &mut self, + srv: &mut Server, + uid: Uid, + target: &str, + text: &str, + ) -> ModResult { + if !srv.amu.enable { + return ModResult::Passthru; + } + // exempt opers and users logged into an account + if srv.is_oper(uid) || srv.is_logged_in(uid) { + return ModResult::Passthru; + } + let is_channel = target.starts_with('#'); + if (is_channel && !srv.amu.check_channel) || (!is_channel && !srv.amu.check_private) { + return ModResult::Passthru; + } + let Some(body) = checkable(text) else { + return ModResult::Passthru; + }; + if body.chars().count() < srv.amu.minlen { + return ModResult::Passthru; + } + let score = score_message(body); + if score < srv.amu.threshold { + return ModResult::Passthru; + } + + let (nick, mask, host, ip) = { + let Some(u) = srv.users.get(&uid) else { + return ModResult::Passthru; + }; + ( + u.nick.clone(), + u.prefix(), + u.host.clone(), + u.addr.ip().to_string(), + ) + }; + // Show opers WHAT was blocked (a sanitized snippet) so they can judge the + // catch and spot false positives — the whole point of an antispam log. + srv.snotice(&format!( + "ANTIMIXEDUTF8: blocked spam from {mask} to {target} (score {score} >= {}): {}", + srv.amu.threshold, + snippet(body) + )); + + // Always tell the sender their message was blocked and that opers were + // told — even for punitive actions, since the writer flushes queued lines + // before a disconnect. + srv.send( + uid, + format!( + ":{} NOTICE {nick} :*** {} (Flagged by the spam filter; network operators have been notified.)", + srv.name, srv.amu.block_msg + ), + ); + + let action = srv.amu.action.to_ascii_lowercase(); + let (dur, reason, setter) = ( + srv.amu.duration, + srv.amu.reason.clone(), + format!("antimixedutf8@{}", srv.name), + ); + match action.as_str() { + "gline" => srv.add_xline(XKind::Gline, &format!("*@{host}"), dur, &setter, &reason), + "kline" => srv.add_xline(XKind::Kline, &format!("*@{host}"), dur, &setter, &reason), + "zline" => srv.add_xline(XKind::Zline, &ip, dur, &setter, &reason), + "kill" => srv.remove_user(uid, &reason), + // "block": also emit the standard channel-failure numeric so clients + // render the drop inline; the explanatory NOTICE above covers the rest. + _ if is_channel => srv.numeric( + uid, + crate::numeric::ERR_CANNOTSENDTOCHAN, + &format!("{target} :Message blocked by the spam filter"), + ), + _ => {} + } + ModResult::Deny + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn genuine_monolingual_text_scores_zero() { + assert_eq!(score_message("hello everyone how are you today"), 0); // Latin + assert_eq!(score_message("привет всем как у вас дела сегодня"), 0); // Russian + assert_eq!(score_message("γεια σας πως ειστε ολοι σημερα εδω"), 0); // Greek + } + + #[test] + fn mixed_script_spam_scores_high() { + // Cyrillic look-alikes swapped into Latin words (multi-word disguise) + assert!(score_message("Ѕесurіtу аlеrt сlісk hеrе nоw рlеаѕе") >= 8); + // fancy/fullwidth styled word run + assert!(score_message("Free V1agra now click here") >= 8); + } + + #[test] + fn one_stray_homoglyph_is_tolerated() { + // a single disguised word gets the 1-word grace → stays under threshold + assert!(score_message("hello wоrld this is a normal message") < 8); + } + + #[test] + fn zero_width_evasion_scores() { + // three zero-width joiners = 3*3 = 9 + assert!(score_message("buy\u{200b}now\u{200b}cheap\u{200b}deal") >= 8); + } + + #[test] + fn snippet_is_one_clean_line_and_capped() { + // CR/LF and mIRC control bytes are neutralised (no protocol injection) + assert_eq!(snippet("hi\r\nthere"), "hi there"); + assert!(!snippet("x\u{03}04red").contains('\u{03}')); + // look-alike glyphs survive so opers can see what was caught + assert!(snippet("Ѕесurіtу").contains('Ѕ')); + // long input is capped with an ellipsis + let s = snippet(&"a".repeat(200)); + assert!(s.ends_with('…') && s.chars().count() == 121); + } +} diff --git a/src/modules/cloak.rs b/src/modules/cloak.rs new file mode 100644 index 0000000..505409b --- /dev/null +++ b/src/modules/cloak.rs @@ -0,0 +1,168 @@ +//! cloak — echoIRCd's host-masking module (InspIRCd's `m_cloak_*`, our way). +//! +//! Every user gets a deterministic, keyed **cloak** of their host that hides the +//! real IP while *preserving subnet structure*, so a channel ban on a whole /24 +//! or /16 still bites. The cloak is shown under user mode **+x**, which this +//! module auto-sets on connect; only opers may drop it (see [`crate::mode`]), +//! which stops +x from becoming a ban-evasion switch. +//! +//! Format follows InspIRCd's `SegmentIP`: one hashed segment per cumulative IP +//! octet-prefix, most-specific on the left, ending in the literal `.IP` suffix +//! that marks a cloaked address (as opposed to a cloaked hostname, which keeps +//! its domain). For `a.b.c.d`: +//! +//! ```text +//! HASH(a.b.c.d) . HASH(a.b.c) . HASH(a.b) . HASH(a) . IP +//! (/32) (/24) (/16) (/8) +//! ``` +//! +//! so two IPs in the same /24 share the `…​/24./16./8.IP` tail (same /16 shares +//! `…​/16./8.IP`), and the exact address never leaks. Where this improves on the +//! C++ original: the hash is **SHA-256** (via the `openssl` we already link for +//! TLS) instead of MD5, it needs no separate hashing module, and the whole path +//! stays `#![forbid(unsafe_code)]`. The key lives in the config (`cloak_key = …`); +//! with no key set, cloaking is simply off and +x is a no-op. + +use openssl::sha::sha256; + +use crate::module::Module; +use crate::server::Server; +use crate::Uid; + +/// The suffix marking a cloaked IP address (InspIRCd's default is `.IP` too). +const IP_SUFFIX: &str = ".IP"; + +pub struct Cloak; + +impl Module for Cloak { + fn name(&self) -> &'static str { + "cloak" + } + + /// Compute the cloak once, at connect, and cloak the user by default (+x). + fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) { + let Some(key) = srv.cloak_key.clone() else { + return; // no cloak key configured -> cloaking disabled + }; + let Some(host) = srv.users.get(&uid).map(|u| u.host.clone()) else { + return; + }; + let cloak = cloak_host(&key, &host); + if let Some(u) = srv.users.get_mut(&uid) { + u.cloak = cloak; + u.flags.cloak = true; // cloaked by default; -x is oper-only + } + } +} + +/// One cloak label: the first `n` hex chars of `SHA-256(key ‖ NUL ‖ data)`. +fn label(key: &str, data: &str, n: usize) -> String { + let digest = sha256(format!("{key}\u{0}{data}").as_bytes()); + let mut s = String::with_capacity(n + 1); + for b in &digest { + s.push_str(&format!("{b:02x}")); + if s.len() >= n { + break; + } + } + s.truncate(n); + s +} + +/// Parse `"a.b.c.d"` into four octets, or `None` if it isn't a dotted IPv4. +fn parse_v4(host: &str) -> Option<(u8, u8, u8, u8)> { + let mut it = host.split('.'); + let a = it.next()?.parse().ok()?; + let b = it.next()?.parse().ok()?; + let c = it.next()?.parse().ok()?; + let d = it.next()?.parse().ok()?; + if it.next().is_some() { + return None; + } + Some((a, b, c, d)) +} + +/// Compute a user's cloak from their real host. +/// +/// - IPv4 `a.b.c.d` → `H(a.b.c.d).H(a.b.c).H(a.b).H(a).IP` — one keyed segment per +/// octet-prefix tier (/32 · /24 · /16 · /8), so subnet bans keep working while +/// the exact address never appears. +/// - IPv6 → `ALPHA.BETA.GAMMA.IP` (mirrors InspIRCd), coarsened by hextet groups. +/// - hostname → keep the last two labels (the domain), mask everything to the left +/// (no `.IP` — a resolved name isn't a raw address). +pub fn cloak_host(key: &str, host: &str) -> String { + if let Some((a, b, c, d)) = parse_v4(host) { + let h32 = label(key, &format!("{a}.{b}.{c}.{d}"), 6); + let h24 = label(key, &format!("{a}.{b}.{c}"), 5); + let h16 = label(key, &format!("{a}.{b}"), 4); + let h8 = label(key, &format!("{a}"), 4); + format!("{h32}.{h24}.{h16}.{h8}{IP_SUFFIX}") + } else if host.contains(':') { + let groups: Vec<&str> = host.split(':').filter(|g| !g.is_empty()).collect(); + let mid = groups.iter().take(4).copied().collect::>().join(":"); + let wide = groups.iter().take(2).copied().collect::>().join(":"); + let alpha = label(key, host, 6); + let beta = label(key, &mid, 5); + let gamma = label(key, &wide, 4); + format!("{alpha}.{beta}.{gamma}{IP_SUFFIX}") + } else { + let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect(); + if parts.len() >= 3 { + let suffix = parts[parts.len() - 2..].join("."); + format!("{}.{suffix}", label(key, host, 8)) + } else { + format!("{}.cloak", label(key, host, 8)) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn v4_cloak_is_deterministic_and_hides_the_ip() { + let c = cloak_host("secret", "203.0.113.7"); + assert_eq!(c, cloak_host("secret", "203.0.113.7")); // stable + assert!(!c.contains("203.0.113")); // the dotted IP never appears + assert!(c.ends_with(".IP")); // InspIRCd-style IP suffix + assert_eq!(c.split('.').count(), 5); // H32.H24.H16.H8.IP + } + + #[test] + fn same_subnet_shares_a_suffix_but_host_differs() { + let a = cloak_host("secret", "203.0.113.7"); + let b = cloak_host("secret", "203.0.113.9"); // same /24 + let e = cloak_host("secret", "8.8.8.8"); // different net + let tail = |s: &str| s.split_once('.').unwrap().1.to_string(); + assert_eq!(tail(&a), tail(&b)); // /24 ban still matches both + assert_ne!(a, b); // but the exact host label differs + assert_ne!(tail(&a), tail(&e)); // unrelated net -> unrelated tail + } + + #[test] + fn wider_ban_matches_the_whole_16() { + // two different /24s inside the same /16 share only the /16./8.IP tail + let a = cloak_host("secret", "203.0.113.7"); + let b = cloak_host("secret", "203.0.200.4"); + let net16_tail = |s: &str| s.splitn(3, '.').nth(2).unwrap().to_string(); + assert_eq!(net16_tail(&a), net16_tail(&b)); // H16.H8.IP shared + assert_ne!(a.split_once('.').unwrap().1, b.split_once('.').unwrap().1); // /24 differs + } + + #[test] + fn the_key_changes_the_cloak() { + assert_ne!( + cloak_host("key-one", "203.0.113.7"), + cloak_host("key-two", "203.0.113.7"), + ); + } + + #[test] + fn hostname_keeps_its_domain_and_has_no_ip_suffix() { + let c = cloak_host("secret", "host.dyn.example.com"); + assert!(c.ends_with(".example.com")); + assert!(!c.ends_with(".IP")); + assert!(!c.starts_with("host")); + } +} diff --git a/src/modules/flood.rs b/src/modules/flood.rs new file mode 100644 index 0000000..90a5a5d --- /dev/null +++ b/src/modules/flood.rs @@ -0,0 +1,71 @@ +//! Flood protection — a module that rate-limits messages. +//! +//! It keeps each user's recent message times in that user's typed +//! [`crate::extensible::Extensible`] slot. Because the state is *owned by the +//! `User`*, it vanishes the moment the user quits — no cleanup callback, no cull +//! list, no chance of a dangling reference (the C++ InspIRCd failure mode this +//! design rules out at compile time). + +use crate::module::{ModResult, Module}; +use crate::server::{now, Server}; +use crate::Uid; + +const FLOOD_MAX: usize = 8; // messages allowed… +const FLOOD_WINDOW: u64 = 4; // …within this many seconds + +#[derive(Default)] +struct FloodState { + times: Vec, + warned: bool, +} + +pub struct Flood; + +impl Module for Flood { + fn name(&self) -> &'static str { + "flood" + } + + fn on_pre_message( + &mut self, + srv: &mut Server, + uid: Uid, + _target: &str, + _text: &str, + ) -> ModResult { + let now = now(); + let (over, warn) = { + let Some(u) = srv.users.get_mut(&uid) else { + return ModResult::Passthru; + }; + if u.flags.oper { + return ModResult::Passthru; // opers bypass flood limits + } + let st = u.ext.get_or_insert_with(FloodState::default); + st.times.retain(|&t| now.saturating_sub(t) < FLOOD_WINDOW); + st.times.push(now); + let over = st.times.len() > FLOOD_MAX; + let warn = over && !st.warned; // notice once per burst + st.warned = over; + (over, warn) + }; + if over { + if warn { + let nick = srv + .users + .get(&uid) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + srv.send( + uid, + format!( + ":{} NOTICE {nick} :*** Flood detected — slow down", + srv.name + ), + ); + } + return ModResult::Deny; + } + ModResult::Passthru + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs new file mode 100644 index 0000000..da044e4 --- /dev/null +++ b/src/modules/mod.rs @@ -0,0 +1,19 @@ +//! Optional, pluggable modules — echoIRCd's answer to InspIRCd's `src/modules/`. +//! Each hooks lifecycle events via the [`crate::module::Module`] trait. + +pub mod antimixedutf8; +pub mod cloak; +pub mod flood; +pub mod snoop; + +use crate::module::Module; + +/// The modules loaded at boot. (Later: load by name from the config.) +pub fn default_modules() -> Vec> { + vec![ + Box::new(snoop::Snoop), + Box::new(flood::Flood), + Box::new(cloak::Cloak), + Box::new(antimixedutf8::AntiMixedUtf8), + ] +} diff --git a/src/modules/snoop.rs b/src/modules/snoop.rs new file mode 100644 index 0000000..da5dc92 --- /dev/null +++ b/src/modules/snoop.rs @@ -0,0 +1,36 @@ +//! A tiny example module: log connects, joins and quits to stderr. It exercises +//! the hook wiring end-to-end and is the template for real modules. + +use crate::module::Module; +use crate::server::Server; +use crate::Uid; + +pub struct Snoop; + +impl Module for Snoop { + fn name(&self) -> &'static str { + "snoop" + } + fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) { + let info = srv + .users + .get(&uid) + .map(|u| (u.nick.clone(), u.ident.clone(), u.host.clone())); + if let Some((nick, ident, host)) = info { + eprintln!("[snoop] connect {nick} ({ident}@{host})"); + srv.snotice(&format!("Client connecting: {nick} ({ident}@{host})")); + } + } + fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) { + if let Some(u) = srv.users.get(&uid) { + eprintln!("[snoop] {} joined {chan}", u.nick); + } + } + fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) { + let nick = srv.users.get(&uid).map(|u| u.nick.clone()); + eprintln!("[snoop] quit uid={uid} ({reason})"); + if let Some(nick) = nick { + srv.snotice(&format!("Client exiting: {nick} ({reason})")); + } + } +} diff --git a/src/numeric.rs b/src/numeric.rs new file mode 100644 index 0000000..c38dfcc --- /dev/null +++ b/src/numeric.rs @@ -0,0 +1,145 @@ +//! IRC numeric replies (RFC 1459/2812 subset) used by the core. + +pub const RPL_MAP: u16 = 6; +pub const RPL_MAPEND: u16 = 7; +pub const RPL_STATSCOMMANDS: u16 = 212; +pub const RPL_ENDOFSTATS: u16 = 219; +pub const RPL_STATSUPTIME: u16 = 242; +pub const RPL_STATSOLINE: u16 = 243; +pub const RPL_STATSXLINE: u16 = 223; // K/G/Z-line listing +pub const RPL_ADMINME: u16 = 256; +pub const RPL_ADMINLOC1: u16 = 257; +pub const RPL_ADMINLOC2: u16 = 258; +pub const RPL_ADMINEMAIL: u16 = 259; +pub const RPL_USERHOST: u16 = 302; +pub const RPL_ISON: u16 = 303; +pub const RPL_WHOISIDLE: u16 = 317; +pub const RPL_LISTSTART: u16 = 321; +pub const RPL_LIST: u16 = 322; +pub const RPL_LISTEND: u16 = 323; +pub const RPL_WHOWASUSER: u16 = 314; +pub const RPL_ENDOFWHOWAS: u16 = 369; +pub const RPL_INFO: u16 = 371; +pub const RPL_ENDOFINFO: u16 = 374; +pub const RPL_REHASHING: u16 = 382; +pub const RPL_TIME: u16 = 391; +pub const ERR_NOSUCHSERVER: u16 = 402; +pub const ERR_WASNOSUCHNICK: u16 = 406; +pub const ERR_UNAVAILRESOURCE: u16 = 437; // channel temporarily unavailable (+j) +pub const ERR_LINKCHANNEL: u16 = 470; // +L — you were redirected to another channel +pub const RPL_ENDOFSPAMFILTER: u16 = 940; // end of the +g word-filter list +pub const RPL_SPAMFILTER: u16 = 941; // one +g word-filter entry +pub const RPL_KNOCK: u16 = 710; // channel gets the knock +pub const RPL_KNOCKDLVR: u16 = 711; // knocker's ack + +// SILENCE (server-side ignore list) +pub const RPL_SILELIST: u16 = 271; +pub const RPL_ENDOFSILENCE: u16 = 272; +pub const ERR_SILELISTFULL: u16 = 511; + +// WATCH (notify list) +pub const RPL_LOGON: u16 = 600; +pub const RPL_LOGOFF: u16 = 601; +pub const RPL_WATCHOFF: u16 = 602; +pub const RPL_WATCHSTAT: u16 = 603; +pub const RPL_NOWON: u16 = 604; +pub const RPL_NOWOFF: u16 = 605; +pub const RPL_WATCHLIST: u16 = 606; +pub const RPL_ENDOFWATCHLIST: u16 = 607; +pub const ERR_TOOMANYWATCH: u16 = 512; + +// MONITOR (IRCv3 notify list) +pub const RPL_MONONLINE: u16 = 730; +pub const RPL_MONOFFLINE: u16 = 731; +pub const RPL_MONLIST: u16 = 732; +pub const RPL_ENDOFMONLIST: u16 = 733; +pub const ERR_MONLISTFULL: u16 = 734; + +pub const RPL_WELCOME: u16 = 1; +pub const RPL_YOURHOST: u16 = 2; +pub const RPL_CREATED: u16 = 3; +pub const RPL_MYINFO: u16 = 4; +pub const RPL_ISUPPORT: u16 = 5; + +pub const RPL_UMODEIS: u16 = 221; +pub const RPL_YOUREOPER: u16 = 381; +pub const ERR_PASSWDMISMATCH: u16 = 464; +pub const ERR_NOPRIVILEGES: u16 = 481; +pub const ERR_UMODEUNKNOWNFLAG: u16 = 501; +pub const RPL_LUSERCLIENT: u16 = 251; + +pub const RPL_WHOISUSER: u16 = 311; +pub const RPL_WHOISSERVER: u16 = 312; +pub const RPL_ENDOFWHO: u16 = 315; +pub const RPL_WHOISCHANNELS: u16 = 319; +pub const RPL_ENDOFWHOIS: u16 = 318; +pub const RPL_WHOISOPERATOR: u16 = 313; // "is an IRC operator" (hidden by +H) +pub const RPL_WHOISBOT: u16 = 335; // "is a bot" (umode +B) +pub const RPL_WHOISACCOUNT: u16 = 330; // " :is logged in as" +pub const ERR_NEEDREGGEDNICK: u16 = 477; // chan +R/+M — must be logged into an account +pub const RPL_WHOISHOST: u16 = 378; // oper-only: real host/ip behind a cloak +pub const RPL_WHOISSECURE: u16 = 671; // "is using a secure connection" (sslinfo) +pub const RPL_HOSTHIDDEN: u16 = 396; // "is now your displayed host" (cloak on/off) + +pub const RPL_CHANNELMODEIS: u16 = 324; +pub const RPL_CREATIONTIME: u16 = 329; +pub const RPL_NOTOPIC: u16 = 331; +pub const RPL_TOPIC: u16 = 332; +pub const RPL_WHOREPLY: u16 = 352; +pub const RPL_NAMREPLY: u16 = 353; +pub const RPL_ENDOFNAMES: u16 = 366; + +pub const RPL_MOTD: u16 = 372; +pub const RPL_MOTDSTART: u16 = 375; +pub const RPL_ENDOFMOTD: u16 = 376; +pub const RPL_LINKS: u16 = 364; +pub const RPL_ENDOFLINKS: u16 = 365; + +pub const ERR_NOSUCHNICK: u16 = 401; +pub const ERR_NOSUCHCHANNEL: u16 = 403; +pub const ERR_CANNOTSENDTOCHAN: u16 = 404; +pub const ERR_NORECIPIENT: u16 = 411; +pub const ERR_NOTEXTTOSEND: u16 = 412; +pub const ERR_UNKNOWNCOMMAND: u16 = 421; +pub const ERR_NOMOTD: u16 = 422; +pub const ERR_NONICKNAMEGIVEN: u16 = 431; +pub const ERR_ERRONEUSNICKNAME: u16 = 432; +pub const ERR_NICKNAMEINUSE: u16 = 433; +pub const ERR_USERNOTINCHANNEL: u16 = 441; +pub const ERR_NOTONCHANNEL: u16 = 442; +pub const ERR_CHANNELISFULL: u16 = 471; +pub const ERR_UNKNOWNMODE: u16 = 472; +pub const ERR_INVITEONLYCHAN: u16 = 473; +pub const ERR_BADCHANNELKEY: u16 = 475; +pub const ERR_CHANOPRIVSNEEDED: u16 = 482; +pub const ERR_SECUREONLYCHAN: u16 = 489; // can't join a +z channel without TLS +pub const ERR_ALLMUSTSSL: u16 = 490; // can't set +z while a member isn't on TLS +pub const ERR_NOTREGISTERED: u16 = 451; +pub const ERR_NEEDMOREPARAMS: u16 = 461; +pub const ERR_ALREADYREGISTERED: u16 = 462; +pub const ERR_USERSDONTMATCH: u16 = 502; + +pub const RPL_AWAY: u16 = 301; +pub const RPL_UNAWAY: u16 = 305; +pub const RPL_NOWAWAY: u16 = 306; +pub const RPL_INVITING: u16 = 341; +pub const RPL_BANLIST: u16 = 367; +pub const RPL_ENDOFBANLIST: u16 = 368; +pub const RPL_INVEXLIST: u16 = 346; +pub const RPL_ENDOFINVEXLIST: u16 = 347; +pub const RPL_EXCEPTLIST: u16 = 348; +pub const RPL_ENDOFEXCEPTLIST: u16 = 349; +pub const ERR_CANTCHANGENICK: u16 = 447; // +N — no nick change on channel +pub const ERR_CANTJOINOPERSONLY: u16 = 520; // +O — IRC operators only +pub const ERR_USERONCHANNEL: u16 = 443; +pub const ERR_BANNEDFROMCHAN: u16 = 474; + +// SASL (IRCv3) +pub const RPL_LOGGEDIN: u16 = 900; +pub const RPL_LOGGEDOUT: u16 = 901; +pub const ERR_NICKLOCKED: u16 = 902; +pub const RPL_SASLSUCCESS: u16 = 903; +pub const ERR_SASLFAIL: u16 = 904; +pub const ERR_SASLTOOLONG: u16 = 905; +pub const ERR_SASLABORTED: u16 = 906; +pub const RPL_SASLMECHS: u16 = 908; diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..29b6a67 --- /dev/null +++ b/src/server.rs @@ -0,0 +1,645 @@ +//! The engine core: the `Server` struct that owns all state, the output +//! primitives (send / numeric / to_channel) and the connection lifecycle. +//! Per-subsystem behaviour lives beside its data — [`crate::users`] and +//! [`crate::channels`] add their own `impl Server` blocks, the way InspIRCd +//! keeps usermanager / channelmanager separate from the core. No locks: only the +//! single core thread ever holds a `Server`. + +use std::collections::{HashMap, HashSet, VecDeque}; +use std::net::{SocketAddr, TcpStream}; +use std::sync::mpsc::Sender; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::channels::Channel; +use crate::config::{Config, LinkBlock}; +use crate::extensible::Extensible; +use crate::link::{Link, RemoteServer, RemoteUser}; +use crate::module::Hook; +use crate::users::{Caps, User, UserFlags}; +use crate::xline::XLine; +use crate::Uid; + +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// Background timer cadence + idle/ping timeouts, in seconds. +pub const TICK_SECS: u64 = 15; +pub const PING_AFTER: u64 = 90; +pub const PING_TIMEOUT: u64 = 60; +pub const REG_TIMEOUT: u64 = 60; + +pub fn now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Format a unix timestamp as an IRCv3 `server-time` tag value +/// (`2026-08-05T07:58:03.000Z`), computing the civil date with std only. +pub fn iso_time(secs: u64) -> String { + let days = (secs / 86400) as i64; + let (h, mi, s) = ((secs % 86400) / 3600, (secs % 3600) / 60, secs % 60); + // civil date from days since 1970-01-01 (Howard Hinnant's algorithm) + let z = days + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = z - era * 146097; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z") +} + +/// A recently-departed identity, kept for WHOWAS. +pub struct WhowasEntry { + pub nick: String, + pub ident: String, + pub host: String, + pub realname: String, + pub account: Option, + pub ts: u64, +} + +pub struct Server { + pub name: String, + pub network: String, + pub created: u64, + pub motd: Vec, + pub users: HashMap, + pub nick_index: HashMap, // lower nick -> uid + pub channels: HashMap, // lower name -> channel + pub events: VecDeque, + pub opers: Vec<(String, String)>, // (name, password) from config + pub cloak_key: Option, // host-cloaking key (see modules::cloak) + pub line_ctags: String, // client-only tags of the line being handled + // --- server-to-server (see crate::link) --- + pub sid: String, // our 3-char server id + pub server_desc: String, // our description + pub link_blocks: Vec, // peers we accept / dial + pub links: HashMap, // local link connections + pub servers: HashMap, // sid -> linked server + pub uuid_counter: u64, // mints local user UIDs + pub msgid_counter: u64, // mints IRCv3 `msgid` message tags + pub uuid_local: HashMap, // our users, by network uuid + pub remote_users: HashMap, // users on other servers + pub remote_nick: HashMap, // lower nick -> remote uuid + pub whowas: VecDeque, // recent nick history (WHOWAS) + pub conf_path: String, // config path, for REHASH + pub xlines: Vec, // server bans (KLINE/GLINE/ZLINE) + pub mode_sudo: bool, // SAMODE/SAKICK: bypass rank checks + pub in_redirect: bool, // +L: guards against redirect loops + pub censor: Vec<(String, String)>, // +G bad words: (find, replace) + pub amu: crate::config::AntiMixedCfg, // antimixedutf8 module config +} + +impl Server { + pub fn new(cfg: Config) -> Server { + Server { + name: cfg.servername, + network: cfg.network, + created: now(), + motd: cfg.motd, + users: HashMap::new(), + nick_index: HashMap::new(), + channels: HashMap::new(), + events: VecDeque::new(), + opers: cfg.opers, + cloak_key: cfg.cloak_key, + line_ctags: String::new(), + sid: cfg.sid, + server_desc: cfg.serverdesc, + link_blocks: cfg.links, + links: HashMap::new(), + servers: HashMap::new(), + uuid_counter: 0, + msgid_counter: 0, + uuid_local: HashMap::new(), + remote_users: HashMap::new(), + remote_nick: HashMap::new(), + whowas: VecDeque::new(), + conf_path: cfg.conf_path, + xlines: Vec::new(), + mode_sudo: false, + in_redirect: false, + censor: cfg.censor, + amu: cfg.amu, + } + } + + /// Remember an identity for WHOWAS (capped ring, newest first). + pub fn push_whowas( + &mut self, + nick: &str, + ident: &str, + host: &str, + realname: &str, + account: Option, + ) { + if nick.is_empty() { + return; + } + self.whowas.push_front(WhowasEntry { + nick: nick.to_string(), + ident: ident.to_string(), + host: host.to_string(), + realname: realname.to_string(), + account, + ts: now(), + }); + while self.whowas.len() > 256 { + self.whowas.pop_back(); + } + } + + // --- connection lifecycle ------------------------------------------------ + + pub fn add_conn( + &mut self, + uid: Uid, + addr: SocketAddr, + out: Sender, + sock: TcpStream, + secure: bool, + ) { + let uuid = self.next_uuid(); + self.uuid_local.insert(uuid.clone(), uid); + self.users.insert( + uid, + User { + uid, + uuid, + nick: String::new(), + ident: String::new(), + realname: String::new(), + host: addr.ip().to_string(), + cloak: String::new(), + vhost: None, + secure, + account: None, + signon: now(), + addr, + registered: false, + cap: false, + cap_302: false, + caps: Caps::default(), + sasl_mech: None, + channels: HashSet::new(), + watch: Vec::new(), + monitor: Vec::new(), + silence: Vec::new(), + quitting: None, + flags: UserFlags::default(), + last_active: now(), + ping_sent: false, + ext: Extensible::default(), + out, + sock: Some(sock), + }, + ); + } + + /// Mark a user as quitting; the core turns this into a full quit after the + /// current command returns (so hooks fire while the user still exists). + pub fn mark_quit(&mut self, uid: Uid, reason: String) { + if let Some(u) = self.users.get_mut(&uid) { + u.quitting = Some(reason); + } + } + + pub fn take_quit(&mut self, uid: Uid) -> Option { + self.users.get_mut(&uid).and_then(|u| u.quitting.take()) + } + + /// Remove a user: broadcast QUIT to everyone sharing a channel, drop them + /// from all channels, free the nick, and close the socket. + pub fn remove_user(&mut self, uid: Uid, reason: &str) { + let Some(user) = self.users.remove(&uid) else { + return; + }; + self.uuid_local.remove(&user.uuid); + if user.registered { + self.push_whowas( + &user.nick, + &user.ident, + user.host_display(), + &user.realname, + user.account.clone(), + ); + self.propagate(&format!(":{} QUIT :{reason}", user.uuid), None); // tell links + } + // NB: we do *not* force-shutdown the socket here. When `user` drops at + // the end of this function its `out` Sender drops with it, so the writer + // thread drains any still-queued lines — e.g. a KILL / x-line ERROR — + // and then closes the socket itself once the channel is empty. + if !user.nick.is_empty() { + self.nick_index.remove(&user.nick.to_ascii_lowercase()); + } + if user.registered { + let line = format!(":{} QUIT :{reason}", user.prefix()); + let mut seen: HashSet = HashSet::new(); + for key in &user.channels { + if let Some(ch) = self.channels.get_mut(key) { + ch.members.remove(&uid); + for &m in ch.members.keys() { + seen.insert(m); + } + } + } + for m in seen { + self.send(m, line.clone()); + } + self.channels.retain(|_, c| !c.is_empty()); + self.watch_notify_offline(&user.nick); // tell WATCH/MONITOR watchers + } + } + + // --- output primitives --------------------------------------------------- + + /// Queue one raw line to a connection (no-op if it's gone). + pub fn send(&self, uid: Uid, line: String) { + if let Some(u) = self.users.get(&uid) { + // server-time: tag sourced (`:prefix …`) lines for clients that asked + let line = if u.caps.server_time && line.starts_with(':') { + format!("@time={} {line}", iso_time(now())) + } else { + line + }; + let _ = u.out.send(line); + } + } + + /// Send a numeric: `:server NNN `. `` is the client's + /// nick, or `*` before it has one. + pub fn numeric(&self, uid: Uid, code: u16, rest: &str) { + let target = self + .users + .get(&uid) + .map(|u| { + if u.nick.is_empty() { + "*".to_string() + } else { + u.nick.clone() + } + }) + .unwrap_or_else(|| "*".to_string()); + self.send( + uid, + format!(":{} {:03} {} {}", self.name, code, target, rest), + ); + } + + /// Send a server notice to every operator who has snomask (+s) on. + pub fn snotice(&self, msg: &str) { + let opers: Vec = self + .users + .iter() + .filter(|(_, u)| u.flags.oper && u.flags.snomask) + .map(|(&u, _)| u) + .collect(); + for o in opers { + let nick = self + .users + .get(&o) + .map(|u| u.nick.clone()) + .unwrap_or_default(); + self.send(o, format!(":{} NOTICE {nick} :*** {msg}", self.name)); + } + } + + /// Send a line to every member of a channel, optionally skipping one uid. + pub fn to_channel(&self, key: &str, line: &str, except: Option) { + if let Some(ch) = self.channels.get(key) { + for &uid in ch.members.keys() { + if Some(uid) != except { + self.send(uid, line.to_string()); + } + } + } + } + + /// Send a message body (`:prefix CMD …`) to `uid`, composing its IRCv3 tag + /// prefix from that client's caps: `time=` (server-time) plus the client-only + /// tags `ctags` (message-tags). Used for PRIVMSG / NOTICE / TAGMSG delivery. + pub fn send_tagged(&self, uid: Uid, ctags: &str, msgid: &str, body: &str) { + if let Some(u) = self.users.get(&uid) { + let mut tags: Vec = Vec::new(); + if u.caps.server_time { + tags.push(format!("time={}", iso_time(now()))); + } + // msgid (IRCv3): a unique, server-assigned id per message so clients + // can reference it (reactions, replies, redaction). Tag-only feature, + // so it goes to message-tags clients alongside any client `+`-tags. + if u.caps.message_tags { + if !msgid.is_empty() { + tags.push(format!("msgid={msgid}")); + } + if !ctags.is_empty() { + tags.push(ctags.to_string()); + } + } + let line = if tags.is_empty() { + body.to_string() + } else { + format!("@{} {body}", tags.join(";")) + }; + let _ = u.out.send(line); + } + } + + /// Mint a unique IRCv3 `msgid` for one message. Generated once per PRIVMSG/ + /// NOTICE/TAGMSG and shared across all its recipients so they correlate. + /// `-` in hex: unique for this run, distinct across + /// restarts (the start time changes). + pub fn next_msgid(&mut self) -> String { + self.msgid_counter = self.msgid_counter.wrapping_add(1); + format!("{:x}-{:x}", self.created, self.msgid_counter) + } + + /// Send `line` to every user sharing a channel with `uid` (except `uid`) whose + /// capabilities satisfy `want`. Drives away-/account-/chghost-/setname-notify. + pub fn notify_peers(&self, uid: Uid, line: &str, want: fn(&Caps) -> bool) { + let chans: Vec = self + .users + .get(&uid) + .map(|u| u.channels.iter().cloned().collect()) + .unwrap_or_default(); + let mut seen: HashSet = HashSet::new(); + for k in &chans { + if let Some(ch) = self.channels.get(k) { + for &m in ch.members.keys() { + seen.insert(m); + } + } + } + // extended-monitor (IRCv3): clients that MONITOR this nick and negotiated + // `extended-monitor` are treated as able to see it — so away/account/ + // chghost/setname reach them even without a shared channel. The `want` + // filter below still requires the matching base cap, per spec. + if let Some(low) = self.users.get(&uid).map(|u| u.nick.to_ascii_lowercase()) { + for (&m, u) in &self.users { + if u.caps.extended_monitor && u.monitor.contains(&low) { + seen.insert(m); + } + } + } + for m in seen { + if m != uid && self.users.get(&m).map(|u| want(&u.caps)).unwrap_or(false) { + self.send(m, line.to_string()); + } + } + } + + /// Change a user's displayed host and/or ident (CHGHOST/CHGIDENT/SETHOST/ + /// SETIDENT). Announces it via the `chghost` cap to peers that speak it and + /// to the user, and sends RPL_HOSTHIDDEN (396) when the host changed. Mirrors + /// InspIRCd's ChangeDisplayedHost / ChangeIdent (local scope for now). + pub fn change_host_ident(&mut self, uid: Uid, new_ident: Option<&str>, new_host: Option<&str>) { + let Some(u) = self.users.get(&uid) else { + return; + }; + let old_prefix = u.prefix(); + if let Some(h) = new_host { + if let Some(u) = self.users.get_mut(&uid) { + u.vhost = Some(h.to_string()); + } + } + if let Some(i) = new_ident { + if let Some(u) = self.users.get_mut(&uid) { + u.ident = i.to_string(); + } + } + let (ident, host, aware) = { + let u = &self.users[&uid]; + ( + u.ident.clone(), + u.host_display().to_string(), + u.caps.chghost, + ) + }; + // chghost-cap peers (and, if it speaks it, the user) get a CHGHOST line + let line = format!(":{old_prefix} CHGHOST {ident} {host}"); + self.notify_peers(uid, &line, |c| c.chghost); + if aware { + self.send(uid, line); + } + if new_host.is_some() { + self.numeric( + uid, + crate::numeric::RPL_HOSTHIDDEN, + &format!("{host} :is now your displayed host"), + ); + } + } + + /// Decide which connections to PING and which to drop, given `now`. + /// Returns `(to_ping, to_quit)`. Pure over the state, so it's unit-testable. + pub fn idle_check(&self, now: u64) -> (Vec, Vec) { + let mut ping = Vec::new(); + let mut quit = Vec::new(); + for (&uid, u) in &self.users { + let idle = now.saturating_sub(u.last_active); + if !u.registered { + if idle >= REG_TIMEOUT { + quit.push(uid); // never registered in time + } + } else if u.ping_sent { + if idle >= PING_AFTER + PING_TIMEOUT { + quit.push(uid); // no reply to our PING + } + } else if idle >= PING_AFTER { + ping.push(uid); // idle — poke it + } + } + (ping, quit) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channels::valid_chan; + use crate::users::{valid_nick, User, UserFlags}; + use std::sync::mpsc::{self, Receiver}; + + /// Insert a registered user with an output channel we can read in the test. + fn add_user(s: &mut Server, uid: Uid, nick: &str) -> Receiver { + let (tx, rx) = mpsc::channel(); + s.users.insert( + uid, + User { + uid, + uuid: format!("TST{uid:06}"), + nick: nick.to_string(), + ident: "u".to_string(), + realname: "real".to_string(), + host: "localhost".to_string(), + cloak: String::new(), + vhost: None, + secure: false, + account: None, + signon: 0, + addr: "127.0.0.1:1".parse().unwrap(), + registered: true, + cap: false, + cap_302: false, + caps: Caps::default(), + sasl_mech: None, + channels: HashSet::new(), + watch: Vec::new(), + monitor: Vec::new(), + silence: Vec::new(), + quitting: None, + flags: UserFlags::default(), + last_active: 0, + ping_sent: false, + ext: Extensible::default(), + out: tx, + sock: None, + }, + ); + s.nick_index.insert(nick.to_ascii_lowercase(), uid); + rx + } + + fn srv() -> Server { + Server::new(Config::default()) + } + + #[test] + fn join_broadcasts_and_tracks_membership() { + let mut s = srv(); + let arx = add_user(&mut s, 1, "ann"); + let brx = add_user(&mut s, 2, "bob"); + s.join(1, "#c", None); // ann creates -> gets @ + s.join(2, "#c", None); // bob joins + + assert!(s.channels["#c"].members[&1].op); + assert!(!s.channels["#c"].members[&2].op); + assert_eq!(s.channels["#c"].members.len(), 2); + + let ann: Vec = arx.try_iter().collect(); + assert!(ann + .iter() + .any(|l| l.contains("JOIN #c") && l.contains("ann!"))); + assert!(ann + .iter() + .any(|l| l.contains(":bob!") && l.contains("JOIN #c"))); + let bob: Vec = brx.try_iter().collect(); + assert!(bob.iter().any(|l| l.contains("353") && l.contains("@ann"))); + } + + #[test] + fn nick_change_reindexes_and_notifies_channel() { + let mut s = srv(); + let arx = add_user(&mut s, 1, "ann"); + add_user(&mut s, 2, "bob"); + s.join(1, "#c", None); + s.join(2, "#c", None); + s.set_nick(1, "annie"); + assert_eq!(s.find_nick("annie"), Some(1)); + assert_eq!(s.find_nick("ann"), None); + let ann: Vec = arx.try_iter().collect(); + assert!(ann + .iter() + .any(|l| l.contains("NICK :annie") && l.contains("ann!"))); + } + + #[test] + fn quit_frees_the_nick_and_tells_neighbors() { + let mut s = srv(); + add_user(&mut s, 1, "ann"); + let brx = add_user(&mut s, 2, "bob"); + s.join(1, "#c", None); + s.join(2, "#c", None); + s.remove_user(1, "bye"); + assert!(s.find_nick("ann").is_none()); + assert!(!s.channels["#c"].members.contains_key(&1)); + let bob: Vec = brx.try_iter().collect(); + assert!(bob + .iter() + .any(|l| l.contains(":ann!") && l.contains("QUIT :bye"))); + } + + #[test] + fn moderated_channel_needs_voice() { + let mut s = srv(); + add_user(&mut s, 1, "ann"); + add_user(&mut s, 2, "bob"); + s.join(1, "#c", None); // ann = op + s.join(2, "#c", None); + s.channels.get_mut("#c").unwrap().modes.moderated = true; + assert!(s.is_op(1, "#c") && !s.is_op(2, "#c")); + assert!(s.is_member(2, "#c")); + } + + #[test] + fn secure_only_channel_rejects_plaintext() { + let mut s = srv(); + add_user(&mut s, 1, "tls"); + s.users.get_mut(&1).unwrap().secure = true; // on TLS + add_user(&mut s, 2, "plain"); // add_user defaults secure=false + s.join(1, "#z", None); // tls creates -> op + s.channels.get_mut("#z").unwrap().modes.secure_only = true; + s.join(2, "#z", None); // plaintext tries to join + assert!(s.is_member(1, "#z")); // the TLS user stays + assert!(!s.is_member(2, "#z")); // the plaintext user is refused + } + + #[test] + fn reg_only_channel_rejects_unregistered() { + let mut s = srv(); + add_user(&mut s, 1, "member"); + s.users.get_mut(&1).unwrap().account = Some("acct".to_string()); // logged in + add_user(&mut s, 2, "guest"); // account None + s.join(1, "#r", None); // logged-in user creates -> op + s.channels.get_mut("#r").unwrap().modes.reg_only = true; + s.join(2, "#r", None); // guest tries to join + assert!(s.is_member(1, "#r")); // the logged-in user stays + assert!(!s.is_member(2, "#r")); // the guest is refused + } + + #[test] + fn iso_time_formats_server_time() { + assert_eq!(iso_time(0), "1970-01-01T00:00:00.000Z"); + assert_eq!(iso_time(1_000_000_000), "2001-09-09T01:46:40.000Z"); + } + + #[test] + fn nick_and_chan_validation() { + assert!(valid_nick("reverse")); + assert!(valid_nick("[abc]`")); + assert!(!valid_nick("1abc")); // can't start with a digit + assert!(!valid_nick("")); + assert!(valid_chan("#argentina")); + assert!(!valid_chan("argentina")); + assert!(!valid_chan("#a b")); + } + + #[test] + fn idle_check_pings_then_times_out() { + let mut s = srv(); + add_user(&mut s, 1, "ann"); // registered, last_active = 0 + let now = 1000; + let (ping, quit) = s.idle_check(now); + assert_eq!(ping, vec![1]); // idle -> PING + assert!(quit.is_empty()); + + s.users.get_mut(&1).unwrap().ping_sent = true; + let (ping, quit) = s.idle_check(now); + assert!(ping.is_empty()); // already pinged + assert_eq!(quit, vec![1]); // no reply -> ping timeout + + s.users.get_mut(&1).unwrap().ping_sent = false; + s.users.get_mut(&1).unwrap().last_active = now; + let (ping, quit) = s.idle_check(now); + assert!(ping.is_empty() && quit.is_empty()); // active -> left alone + + s.users.get_mut(&1).unwrap().registered = false; + s.users.get_mut(&1).unwrap().last_active = 0; + let (_, quit) = s.idle_check(now); + assert_eq!(quit, vec![1]); // unregistered + idle -> registration timeout + } +} diff --git a/src/socketengine.rs b/src/socketengine.rs new file mode 100644 index 0000000..9b44171 --- /dev/null +++ b/src/socketengine.rs @@ -0,0 +1,255 @@ +//! The socket engine: the I/O edge. Accept connections and, per socket, ferry +//! the wire to/from the core. Plaintext sockets get a blocking reader thread + +//! writer thread; TLS sockets get one thread that owns the session and polls +//! (a single TLS object can't be split across two threads). The core never +//! touches a socket except to shut it down. (InspIRCd has a `socketengines/` +//! dir of epoll/kqueue/select backends; ours is threads.) + +use std::io::{self, BufRead, BufReader, Write}; +use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use crate::ircd::Event; +use crate::tls::TlsBackend; +use crate::Uid; + +/// Longest single line we'll buffer before dropping it (crude flood guard). +const MAX_LINE: usize = 16 * 1024; +/// How long a TLS thread blocks on a read before draining its write queue. +const TLS_POLL: Duration = Duration::from_millis(100); + +/// Accept forever, wiring each connection to the core. `tls` = the backend to +/// wrap sockets in (None for a plaintext listener). `counter` is shared across +/// every listener so uids stay unique. +pub fn accept_loop( + listener: TcpListener, + core: Sender, + tls: Option>, + counter: Arc, + link: bool, +) { + for conn in listener.incoming() { + let Ok(stream) = conn else { continue }; + let Ok(addr) = stream.peer_addr() else { + continue; + }; + let _ = stream.set_nodelay(true); + let uid = counter.fetch_add(1, Ordering::Relaxed); + + match &tls { + None => { + let Ok(reader) = stream.try_clone() else { + continue; + }; + let Ok(shutdown) = stream.try_clone() else { + continue; + }; + let (out_tx, out_rx) = mpsc::channel::(); + thread::spawn(move || writer_loop(stream, out_rx)); + if core + .send(Event::Connect { + uid, + addr, + out: out_tx, + sock: shutdown, + secure: false, + link, + outbound: false, + }) + .is_err() + { + break; // core gone + } + let core_tx = core.clone(); + thread::spawn(move || reader_loop(reader, uid, core_tx)); + } + Some(backend) => { + let backend = backend.clone(); + let core_tx = core.clone(); + thread::spawn(move || tls_conn(backend, stream, uid, addr, core_tx, link)); + } + } + } +} + +/// Dial an outbound server link and wire it to the core (an `outbound` link that +/// introduces itself first). Used for auto-connecting to a configured uplink. +pub fn connect_link(addr: &str, core: Sender, counter: Arc) { + let stream = match TcpStream::connect(addr) { + Ok(s) => s, + Err(e) => { + eprintln!("[link] cannot dial {addr}: {e}"); + return; + } + }; + let _ = stream.set_nodelay(true); + let Ok(peer) = stream.peer_addr() else { return }; + let Ok(reader) = stream.try_clone() else { + return; + }; + let Ok(shutdown) = stream.try_clone() else { + return; + }; + let uid = counter.fetch_add(1, Ordering::Relaxed); + let (out_tx, out_rx) = mpsc::channel::(); + thread::spawn(move || writer_loop(stream, out_rx)); + if core + .send(Event::Connect { + uid, + addr: peer, + out: out_tx, + sock: shutdown, + secure: false, + link: true, + outbound: true, + }) + .is_err() + { + return; + } + thread::spawn(move || reader_loop(reader, uid, core)); +} + +// --- plaintext: two blocking threads ---------------------------------------- + +fn reader_loop(stream: TcpStream, uid: Uid, core: Sender) { + let mut buf = BufReader::new(stream); + let mut line = String::new(); + loop { + line.clear(); + match buf.read_line(&mut line) { + Ok(0) => break, // EOF + Ok(_) => { + if line.len() > MAX_LINE { + continue; + } + let l = line.trim_end_matches(['\r', '\n']); + if l.is_empty() { + continue; + } + if core + .send(Event::Line { + uid, + line: l.to_string(), + }) + .is_err() + { + break; + } + } + Err(_) => break, + } + } + let _ = core.send(Event::Disconnect { uid }); +} + +fn writer_loop(mut stream: TcpStream, rx: Receiver) { + // Ends when every sender (the user's `out`) is dropped by the core. + for line in rx { + if stream.write_all(line.as_bytes()).is_err() || stream.write_all(b"\r\n").is_err() { + break; + } + } + let _ = stream.shutdown(Shutdown::Both); +} + +// --- TLS: one thread owning the session ------------------------------------- + +fn tls_conn( + backend: Arc, + stream: TcpStream, + uid: Uid, + addr: SocketAddr, + core: Sender, + link: bool, +) { + // Keep a raw handle so the core can force the socket shut later. + let Ok(shutdown) = stream.try_clone() else { + return; + }; + let mut conn = match backend.accept(stream) { + Ok(c) => c, + Err(_) => { + let _ = shutdown.shutdown(Shutdown::Both); + return; // handshake failed + } + }; + let (out_tx, out_rx) = mpsc::channel::(); + if core + .send(Event::Connect { + uid, + addr, + out: out_tx, + sock: shutdown, + secure: true, + link, + outbound: false, + }) + .is_err() + { + conn.shutdown(); + return; + } + + let _ = conn.set_read_timeout(Some(TLS_POLL)); + let mut acc: Vec = Vec::new(); + let mut chunk = [0u8; 4096]; + 'io: loop { + match conn.read(&mut chunk) { + Ok(0) => break, // EOF + Ok(n) => { + acc.extend_from_slice(&chunk[..n]); + while let Some(pos) = acc.iter().position(|&b| b == b'\n') { + let raw: Vec = acc.drain(..=pos).collect(); + let text = String::from_utf8_lossy(&raw); + let l = text.trim_end_matches(['\r', '\n']); + if !l.is_empty() + && core + .send(Event::Line { + uid, + line: l.to_string(), + }) + .is_err() + { + break 'io; + } + } + if acc.len() > MAX_LINE { + acc.clear(); // overlong line with no newline: drop it + } + } + Err(e) + if matches!( + e.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => {} + Err(e) if e.kind() == io::ErrorKind::Interrupted => continue, + Err(_) => break, + } + + // drain everything the core queued for this connection + loop { + match out_rx.try_recv() { + Ok(line) => { + if conn.write_all(line.as_bytes()).is_err() || conn.write_all(b"\r\n").is_err() + { + break 'io; + } + } + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + // the core dropped us (user removed); nothing more to do + conn.shutdown(); + return; + } + } + } + let _ = conn.flush(); + } + conn.shutdown(); + let _ = core.send(Event::Disconnect { uid }); +} diff --git a/src/tls.rs b/src/tls.rs new file mode 100644 index 0000000..321ed19 --- /dev/null +++ b/src/tls.rs @@ -0,0 +1,81 @@ +//! TLS backends — echoIRCd's answer to InspIRCd's `IOHook` seam and its +//! `ssl_openssl` / `ssl_gnutls` modules. A [`TlsBackend`] wraps an accepted +//! socket in a TLS session; the socket engine then drives the resulting +//! [`TlsConn`] for any listener that has a backend attached. +//! +//! This backend is openssl. The `openssl` crate keeps all its `unsafe` internal, +//! so the daemon itself stays `#![forbid(unsafe_code)]`. A pure-Rust `rustls` +//! backend (or a gnutls one) only has to implement these same two traits and it +//! slots straight in — exactly the pluggable-provider shape InspIRCd uses. + +use std::io::{self, Read, Write}; +use std::net::{Shutdown, TcpStream}; +use std::time::Duration; + +use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslStream}; + +/// A live TLS connection: read/write plaintext, tune the read timeout (the +/// socket engine polls with one to interleave reads and queued writes), and shut +/// it down. The concrete backend type stays hidden behind this. +pub trait TlsConn: Send { + fn read(&mut self, buf: &mut [u8]) -> io::Result; + fn write_all(&mut self, buf: &[u8]) -> io::Result<()>; + fn flush(&mut self) -> io::Result<()>; + fn set_read_timeout(&self, dur: Option) -> io::Result<()>; + fn shutdown(&self); +} + +/// A TLS backend: performs the server-side handshake on an accepted socket. +pub trait TlsBackend: Send + Sync { + fn accept(&self, sock: TcpStream) -> io::Result>; +} + +fn err(e: E) -> io::Error { + io::Error::other(e.to_string()) +} + +// --- openssl backend -------------------------------------------------------- + +pub struct OpensslBackend { + acceptor: SslAcceptor, +} + +impl OpensslBackend { + /// Build an acceptor from a PEM certificate chain + private key. + pub fn new(cert: &str, key: &str) -> io::Result { + let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls()).map_err(err)?; + b.set_private_key_file(key, SslFiletype::PEM).map_err(err)?; + b.set_certificate_chain_file(cert).map_err(err)?; + b.check_private_key().map_err(err)?; + Ok(OpensslBackend { + acceptor: b.build(), + }) + } +} + +impl TlsBackend for OpensslBackend { + fn accept(&self, sock: TcpStream) -> io::Result> { + let stream = self.acceptor.accept(sock).map_err(err)?; + Ok(Box::new(OpensslConn(stream))) + } +} + +struct OpensslConn(SslStream); + +impl TlsConn for OpensslConn { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + self.0.read(buf) + } + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + self.0.write_all(buf) + } + fn flush(&mut self) -> io::Result<()> { + self.0.flush() + } + fn set_read_timeout(&self, dur: Option) -> io::Result<()> { + self.0.get_ref().set_read_timeout(dur) + } + fn shutdown(&self) { + let _ = self.0.get_ref().shutdown(Shutdown::Both); + } +} diff --git a/src/users.rs b/src/users.rs new file mode 100644 index 0000000..afe00d8 --- /dev/null +++ b/src/users.rs @@ -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, // 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::>() + .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::>() + .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, // displayed-host override (CHGHOST/SETHOST vhost) + pub secure: bool, // connected over TLS (drives WHOIS 671 / sslinfo) + pub account: Option, // 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, // SASL mechanism chosen, mid-handshake + pub channels: HashSet, // lowercased channel keys + pub watch: Vec, // WATCH list — lowercased nicks + pub monitor: Vec, // MONITOR list — lowercased nicks + pub silence: Vec, // SILENCE masks — nick!user@host globs + pub quitting: Option, // 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, + pub sock: Option, // 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 { + 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 = 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 = HashSet::new(); + targets.insert(uid); + let chans: Vec = 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 + } +} diff --git a/src/watch.rs b/src/watch.rs new file mode 100644 index 0000000..8768dfe --- /dev/null +++ b/src/watch.rs @@ -0,0 +1,84 @@ +//! WATCH / MONITOR notification plumbing + SILENCE matching. +//! +//! The lists themselves live on the [`crate::users::User`] (`watch` / `monitor` / +//! `silence`); the commands are in [`crate::coremods::core_watch`]. Whenever a +//! nick's online-state flips — registration, quit, or a nick change — the +//! lifecycle code calls [`Server::watch_notify_online`] / [`Server::watch_notify_offline`], +//! which scan for anyone WATCHing/MONITORing that nick and send the right numeric. +//! +//! An O(users) scan, not a reverse index: correct-by-construction (nothing to keep +//! in sync) and fine on a small server — a reverse index can slot in later if it +//! ever needs to scale, exactly the kind of change the borrow checker makes safe. + +use crate::channels::glob_match; +use crate::numeric::*; +use crate::server::{now, Server}; +use crate::Uid; + +pub const WATCH_MAX: usize = 128; +pub const MONITOR_MAX: usize = 128; +pub const SILENCE_MAX: usize = 32; + +impl Server { + /// A nick just came online (registered, or someone renamed to it): tell its + /// WATCHers (600 RPL_LOGON) and MONITORers (730 RPL_MONONLINE). + pub fn watch_notify_online(&self, nick: &str) { + let low = nick.to_ascii_lowercase(); + let Some((dnick, ident, host, ts)) = self.find_nick(nick).and_then(|tu| { + self.users.get(&tu).map(|x| { + ( + x.nick.clone(), + x.ident.clone(), + x.host_display().to_string(), + x.signon, + ) + }) + }) else { + return; + }; + for (&uid, u) in &self.users { + if u.watch.contains(&low) { + self.numeric( + uid, + RPL_LOGON, + &format!("{dnick} {ident} {host} {ts} :is now online"), + ); + } + if u.monitor.contains(&low) { + self.numeric(uid, RPL_MONONLINE, &format!(":{dnick}!{ident}@{host}")); + } + } + } + + /// A nick just went offline (quit, or renamed away): tell its WATCHers + /// (601 RPL_LOGOFF) and MONITORers (731 RPL_MONOFFLINE). + pub fn watch_notify_offline(&self, nick: &str) { + let low = nick.to_ascii_lowercase(); + let ts = now(); + for (&uid, u) in &self.users { + if u.watch.contains(&low) { + self.numeric(uid, RPL_LOGOFF, &format!("{nick} * * {ts} :is now offline")); + } + if u.monitor.contains(&low) { + self.numeric(uid, RPL_MONOFFLINE, &format!(":{nick}")); + } + } + } + + /// How many users currently WATCH `nick` (for `WATCH S` stats). + pub fn watchers_of(&self, nick: &str) -> usize { + let low = nick.to_ascii_lowercase(); + self.users + .values() + .filter(|u| u.watch.contains(&low)) + .count() + } + + /// True if user `by` has silenced someone whose prefix is `sender_mask`. + pub fn is_silenced(&self, by: Uid, sender_mask: &str) -> bool { + self.users + .get(&by) + .map(|u| u.silence.iter().any(|m| glob_match(m, sender_mask))) + .unwrap_or(false) + } +} diff --git a/src/xline.rs b/src/xline.rs new file mode 100644 index 0000000..3f3706f --- /dev/null +++ b/src/xline.rs @@ -0,0 +1,140 @@ +//! X-lines — server bans (InspIRCd's `m_xline`): KLINE/GLINE on `user@host`, +//! ZLINE on an IP. Matched at registration (a banned client is refused) and when +//! the line is added (matching clients are killed); expired lines are reaped on +//! the tick. Kept in `Server.xlines`. + +use crate::channels::glob_match; +use crate::server::{now, Server}; +use crate::Uid; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum XKind { + Kline, // user@host, this server + Gline, // user@host, "global" (locally the same until services span it) + Zline, // an IP address +} + +impl XKind { + pub fn tag(&self) -> &'static str { + match self { + XKind::Kline => "K", + XKind::Gline => "G", + XKind::Zline => "Z", + } + } +} + +pub struct XLine { + pub kind: XKind, + pub mask: String, // user@host glob (K/G) or ip glob (Z) + pub reason: String, + pub setter: String, + pub expires: u64, // 0 = permanent +} + +/// Parse a duration: bare number = seconds; `s`/`m`/`h`/`d`/`w` suffixes; `0`/"" = permanent. +pub fn parse_duration(s: &str) -> Option { + if s.is_empty() || s == "0" { + return Some(0); + } + let last = s.chars().last()?; + if last.is_ascii_digit() { + return s.parse::().ok(); + } + let n: u64 = s[..s.len() - 1].parse().ok()?; + let mul = match last { + 's' => 1, + 'm' => 60, + 'h' => 3600, + 'd' => 86400, + 'w' => 604800, + _ => return None, + }; + Some(n.saturating_mul(mul)) +} + +impl Server { + /// The reason a `user@host` / `ip` is banned by an active x-line, if any. + pub fn matched_xline(&self, ident: &str, host: &str, ip: &str) -> Option { + let uh = format!("{ident}@{host}"); + let n = now(); + for x in &self.xlines { + if x.expires != 0 && x.expires <= n { + continue; + } + let hit = match x.kind { + XKind::Zline => glob_match(&x.mask, ip), + _ => glob_match(&x.mask, &uh), + }; + if hit { + return Some(format!("{}-lined: {}", x.kind.tag(), x.reason)); + } + } + None + } + + /// Add (or replace) an x-line, then kill every connected user it matches. + pub fn add_xline( + &mut self, + kind: XKind, + mask: &str, + duration: u64, + setter: &str, + reason: &str, + ) { + let n = now(); + self.xlines.retain(|x| !(x.kind == kind && x.mask == mask)); + self.xlines.push(XLine { + kind, + mask: mask.to_string(), + reason: reason.to_string(), + setter: setter.to_string(), + expires: if duration == 0 { 0 } else { n + duration }, + }); + self.snotice(&format!( + "{setter} added a {}-line on {mask}: {reason}", + kind.tag() + )); + self.enforce_xlines(); + } + + /// Remove an x-line by kind + mask; returns whether one was found. + pub fn remove_xline(&mut self, kind: XKind, mask: &str) -> bool { + let before = self.xlines.len(); + self.xlines.retain(|x| !(x.kind == kind && x.mask == mask)); + self.xlines.len() < before + } + + /// Kill every connected local user that now matches an active x-line. + pub fn enforce_xlines(&mut self) { + let candidates: Vec<(Uid, String, String, String)> = self + .users + .iter() + .filter(|(_, u)| u.registered) + .map(|(&uid, u)| { + ( + uid, + u.ident.clone(), + u.host.clone(), + u.addr.ip().to_string(), + ) + }) + .collect(); + let victims: Vec<(Uid, String)> = candidates + .into_iter() + .filter_map(|(uid, ident, host, ip)| { + self.matched_xline(&ident, &host, &ip).map(|r| (uid, r)) + }) + .collect(); + for (uid, reason) in victims { + self.send(uid, format!("ERROR :Closing link: ({reason})")); + self.remove_user(uid, &reason); + } + } + + /// Drop expired x-lines (called on the background tick). + pub fn purge_xlines(&mut self) { + let n = now(); + self.xlines.retain(|x| x.expires == 0 || x.expires > n); + } +}