import echoircd — from-scratch irc daemon in native rust
This commit is contained in:
commit
9b12791774
38 changed files with 9757 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
23
Cargo.toml
Normal file
23
Cargo.toml
Normal file
|
|
@ -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
|
||||||
83
README.md
Normal file
83
README.md
Normal file
|
|
@ -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<Mutex<…>>`
|
||||||
|
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.
|
||||||
43
echoircd.conf.example
Normal file
43
echoircd.conf.example
Normal file
|
|
@ -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 = <name> <ip> <port> <password> [autoconnect] (the password is a shared secret)
|
||||||
|
# link = peer.example.net 203.0.113.5 7000 CHANGE_THIS_LINK_SECRET autoconnect
|
||||||
|
|
||||||
|
# IRC operators — oper = <name> <password>
|
||||||
|
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 = <find> [replacement]` (omit replacement to block).
|
||||||
|
# badword = examplebadword ***
|
||||||
41
scripts/native-rust-guard.sh
Executable file
41
scripts/native-rust-guard.sh
Executable file
|
|
@ -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
|
||||||
59
src/accounts.rs
Normal file
59
src/accounts.rs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
//! Account layer — the ircd's *services-ready* account support, modelled on
|
||||||
|
//! InspIRCd's `m_services_account`. **This is NOT a services daemon.**
|
||||||
|
//!
|
||||||
|
//! echoIRCd stores no passwords and runs no NickServ — registering nicks/channels
|
||||||
|
//! is a **services package**'s job (Anope/Atheme), linked in over S2S. What the
|
||||||
|
//! ircd owns is only the plumbing a service plugs into:
|
||||||
|
//! * a per-user **account name** (`User.account`) — the extension a service sets
|
||||||
|
//! or clears (InspIRCd's `accountname` metadata), which flips user mode `+r`;
|
||||||
|
//! * the account-gated **modes** (chan `+R`/`+M`, user `+r`/`+R`) that key off it
|
||||||
|
//! and live in [`crate::mode`];
|
||||||
|
//! * the **interface** a service drives it through: [`Server::set_login`] /
|
||||||
|
//! [`Server::logout`], reached today via the oper/`SVSLOGIN` command and, once
|
||||||
|
//! S2S + SASL land, by a linked services pseudoserver.
|
||||||
|
//!
|
||||||
|
//! So a real network runs Anope *beside* echoIRCd; the ircd just has to be ready.
|
||||||
|
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn is_logged_in(&self, uid: Uid) -> bool {
|
||||||
|
self.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.account.is_some())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log `uid` into `account` (services-driven): sets the account name, flips
|
||||||
|
/// `+r`, and reflects the mode back to the user.
|
||||||
|
pub fn set_login(&mut self, uid: Uid, account: &str) {
|
||||||
|
let (nick, prefix) = match self.users.get_mut(&uid) {
|
||||||
|
Some(u) => {
|
||||||
|
u.account = Some(account.to_string());
|
||||||
|
u.flags.logged_in = true;
|
||||||
|
(u.nick.clone(), u.prefix())
|
||||||
|
}
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
self.send(uid, format!(":{} MODE {nick} :+r", self.name));
|
||||||
|
// account-notify
|
||||||
|
self.notify_peers(uid, &format!(":{prefix} ACCOUNT {account}"), |c| {
|
||||||
|
c.account_notify
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Log `uid` out of any account (services-driven): clears `+r`.
|
||||||
|
pub fn logout(&mut self, uid: Uid) {
|
||||||
|
let (nick, prefix) = match self.users.get_mut(&uid) {
|
||||||
|
Some(u) if u.account.is_some() => {
|
||||||
|
u.account = None;
|
||||||
|
u.flags.logged_in = false;
|
||||||
|
(u.nick.clone(), u.prefix())
|
||||||
|
}
|
||||||
|
_ => return,
|
||||||
|
};
|
||||||
|
self.send(uid, format!(":{} MODE {nick} :-r", self.name));
|
||||||
|
self.notify_peers(uid, &format!(":{prefix} ACCOUNT *"), |c| c.account_notify);
|
||||||
|
}
|
||||||
|
}
|
||||||
792
src/channels.rs
Normal file
792
src/channels.rs
Normal file
|
|
@ -0,0 +1,792 @@
|
||||||
|
//! Channels: the `Channel` record, membership, channel modes, bans, invites and
|
||||||
|
//! JOIN/NAMES — the same job InspIRCd splits across channels/channelmanager, but
|
||||||
|
//! written from scratch in Rust (InspIRCd is a behaviour reference, not a source).
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use crate::module::Hook;
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::{now, Server};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// Per-member prefix modes (+q/+a/+o/+h/+v). Flag modes live in [`ChanModes`].
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Member {
|
||||||
|
pub owner: bool, // +q (~)
|
||||||
|
pub admin: bool, // +a (&)
|
||||||
|
pub op: bool, // +o (@)
|
||||||
|
pub halfop: bool, // +h (%)
|
||||||
|
pub voice: bool, // +v (+)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix ranks, high→low — gate who may grant a prefix / kick whom.
|
||||||
|
pub const RANK_OWNER: u8 = 5;
|
||||||
|
pub const RANK_ADMIN: u8 = 4;
|
||||||
|
pub const RANK_OP: u8 = 3;
|
||||||
|
pub const RANK_HALFOP: u8 = 2;
|
||||||
|
pub const RANK_VOICE: u8 = 1;
|
||||||
|
|
||||||
|
impl Member {
|
||||||
|
/// This member's numeric rank (0 = plain member).
|
||||||
|
pub fn rank(&self) -> u8 {
|
||||||
|
if self.owner {
|
||||||
|
RANK_OWNER
|
||||||
|
} else if self.admin {
|
||||||
|
RANK_ADMIN
|
||||||
|
} else if self.op {
|
||||||
|
RANK_OP
|
||||||
|
} else if self.halfop {
|
||||||
|
RANK_HALFOP
|
||||||
|
} else if self.voice {
|
||||||
|
RANK_VOICE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Highest prefix char for NAMES (`""` for a plain member).
|
||||||
|
pub fn prefix_char(&self) -> &'static str {
|
||||||
|
if self.owner {
|
||||||
|
"~"
|
||||||
|
} else if self.admin {
|
||||||
|
"&"
|
||||||
|
} else if self.op {
|
||||||
|
"@"
|
||||||
|
} else if self.halfop {
|
||||||
|
"%"
|
||||||
|
} else if self.voice {
|
||||||
|
"+"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set/clear a prefix mode by its letter (used by the S2S mode applier).
|
||||||
|
pub fn set_prefix(&mut self, letter: char, on: bool) {
|
||||||
|
match letter {
|
||||||
|
'q' => self.owner = on,
|
||||||
|
'a' => self.admin = on,
|
||||||
|
'o' => self.op = on,
|
||||||
|
'h' => self.halfop = on,
|
||||||
|
'v' => self.voice = on,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every prefix char this member holds, high→low (for the `multi-prefix` cap).
|
||||||
|
pub fn all_prefixes(&self) -> String {
|
||||||
|
let mut s = String::new();
|
||||||
|
for (on, c) in [
|
||||||
|
(self.owner, '~'),
|
||||||
|
(self.admin, '&'),
|
||||||
|
(self.op, '@'),
|
||||||
|
(self.halfop, '%'),
|
||||||
|
(self.voice, '+'),
|
||||||
|
] {
|
||||||
|
if on {
|
||||||
|
s.push(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Topic {
|
||||||
|
pub text: String,
|
||||||
|
pub setter: String,
|
||||||
|
pub ts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A +b ban: a `nick!user@host` glob, who set it and when.
|
||||||
|
pub struct Ban {
|
||||||
|
pub mask: String,
|
||||||
|
pub setter: String,
|
||||||
|
pub ts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// +f message flood: `[*]lines:secs` — kick past `lines` msgs in `secs` (and set
|
||||||
|
/// a +b ban too when `ban`, from the leading `*`).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MsgFlood {
|
||||||
|
pub lines: u32,
|
||||||
|
pub secs: u64,
|
||||||
|
pub ban: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A `count:secs` rate, shared by +j (join flood) and +F (nick-change flood).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Rate {
|
||||||
|
pub count: u32,
|
||||||
|
pub secs: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Channel modes other than the per-member prefixes.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ChanModes {
|
||||||
|
pub moderated: bool, // +m — only +o/+v may speak
|
||||||
|
pub topic_ops: bool, // +t — only ops may set the topic
|
||||||
|
pub no_external: bool, // +n — must be a member to message it
|
||||||
|
pub invite_only: bool, // +i
|
||||||
|
pub secret: bool, // +s
|
||||||
|
pub key: Option<String>, // +k <key>
|
||||||
|
pub limit: Option<u32>, // +l <n>
|
||||||
|
pub secure_only: bool, // +z — only TLS-connected users may join
|
||||||
|
pub private: bool, // +p — private (hidden from WHOIS channel list)
|
||||||
|
pub oper_only: bool, // +O — only IRC operators may join
|
||||||
|
pub no_nick: bool, // +N — members can't change nick while here
|
||||||
|
pub no_ctcp: bool, // +C — block CTCP to the channel
|
||||||
|
pub no_notice: bool, // +T — block NOTICEs to the channel
|
||||||
|
pub no_color: bool, // +c — reject messages with formatting/colour
|
||||||
|
pub strip_color: bool, // +S — strip formatting/colour from messages
|
||||||
|
pub reg_only: bool, // +R — only logged-in (account) users may join
|
||||||
|
pub reg_moderated: bool, // +M — only logged-in users may speak
|
||||||
|
pub censor: bool, // +G — replace configured bad words
|
||||||
|
pub auditorium: bool, // +u — hide non-ops from non-ops
|
||||||
|
pub flood: Option<MsgFlood>, // +f
|
||||||
|
pub joinflood: Option<Rate>, // +j
|
||||||
|
pub nickflood: Option<Rate>, // +F
|
||||||
|
pub redirect: Option<String>, // +L <#target> — when full, send there
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChanModes {
|
||||||
|
/// Set/clear a no-parameter flag mode by its letter (the S2S mode applier).
|
||||||
|
pub fn set_by_letter(&mut self, c: char, on: bool) {
|
||||||
|
match c {
|
||||||
|
'm' => self.moderated = on,
|
||||||
|
'n' => self.no_external = on,
|
||||||
|
't' => self.topic_ops = on,
|
||||||
|
'i' => self.invite_only = on,
|
||||||
|
's' => self.secret = on,
|
||||||
|
'z' => self.secure_only = on,
|
||||||
|
'p' => self.private = on,
|
||||||
|
'O' => self.oper_only = on,
|
||||||
|
'N' => self.no_nick = on,
|
||||||
|
'C' => self.no_ctcp = on,
|
||||||
|
'T' => self.no_notice = on,
|
||||||
|
'c' => self.no_color = on,
|
||||||
|
'S' => self.strip_color = on,
|
||||||
|
'R' => self.reg_only = on,
|
||||||
|
'M' => self.reg_moderated = on,
|
||||||
|
'G' => self.censor = on,
|
||||||
|
'u' => self.auditorium = on,
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `+mnt`, or with `params` the +k/+l arguments too: `+ntkl secret 20`.
|
||||||
|
pub fn render(&self, params: bool) -> String {
|
||||||
|
let mut s = String::from("+");
|
||||||
|
for (on, ch) in [
|
||||||
|
(self.invite_only, 'i'),
|
||||||
|
(self.moderated, 'm'),
|
||||||
|
(self.no_external, 'n'),
|
||||||
|
(self.secret, 's'),
|
||||||
|
(self.topic_ops, 't'),
|
||||||
|
(self.secure_only, 'z'),
|
||||||
|
(self.private, 'p'),
|
||||||
|
(self.oper_only, 'O'),
|
||||||
|
(self.no_nick, 'N'),
|
||||||
|
(self.no_ctcp, 'C'),
|
||||||
|
(self.no_notice, 'T'),
|
||||||
|
(self.no_color, 'c'),
|
||||||
|
(self.strip_color, 'S'),
|
||||||
|
(self.reg_only, 'R'),
|
||||||
|
(self.reg_moderated, 'M'),
|
||||||
|
(self.censor, 'G'),
|
||||||
|
(self.auditorium, 'u'),
|
||||||
|
] {
|
||||||
|
if on {
|
||||||
|
s.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if self.key.is_some() {
|
||||||
|
s.push('k');
|
||||||
|
}
|
||||||
|
if self.limit.is_some() {
|
||||||
|
s.push('l');
|
||||||
|
}
|
||||||
|
if self.flood.is_some() {
|
||||||
|
s.push('f');
|
||||||
|
}
|
||||||
|
if self.joinflood.is_some() {
|
||||||
|
s.push('j');
|
||||||
|
}
|
||||||
|
if self.nickflood.is_some() {
|
||||||
|
s.push('F');
|
||||||
|
}
|
||||||
|
if self.redirect.is_some() {
|
||||||
|
s.push('L');
|
||||||
|
}
|
||||||
|
if params {
|
||||||
|
if let Some(k) = &self.key {
|
||||||
|
s.push(' ');
|
||||||
|
s.push_str(k);
|
||||||
|
}
|
||||||
|
if let Some(l) = self.limit {
|
||||||
|
s.push(' ');
|
||||||
|
s.push_str(&l.to_string());
|
||||||
|
}
|
||||||
|
if let Some(f) = &self.flood {
|
||||||
|
let star = if f.ban { "*" } else { "" };
|
||||||
|
s.push_str(&format!(" {star}{}:{}", f.lines, f.secs));
|
||||||
|
}
|
||||||
|
if let Some(j) = &self.joinflood {
|
||||||
|
s.push_str(&format!(" {}:{}", j.count, j.secs));
|
||||||
|
}
|
||||||
|
if let Some(n) = &self.nickflood {
|
||||||
|
s.push_str(&format!(" {}:{}", n.count, n.secs));
|
||||||
|
}
|
||||||
|
if let Some(t) = &self.redirect {
|
||||||
|
s.push(' ');
|
||||||
|
s.push_str(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Channel {
|
||||||
|
pub name: String, // display casing
|
||||||
|
pub topic: Option<Topic>,
|
||||||
|
pub members: HashMap<Uid, Member>,
|
||||||
|
pub rmembers: HashMap<String, Member>, // remote members, by network uuid (S2S)
|
||||||
|
pub modes: ChanModes,
|
||||||
|
pub bans: Vec<Ban>,
|
||||||
|
pub excepts: Vec<Ban>, // +e ban exceptions
|
||||||
|
pub invex: Vec<Ban>, // +I invite exceptions
|
||||||
|
pub filters: Vec<Ban>, // +g word/glob message filters (mask = the glob)
|
||||||
|
pub invites: HashSet<Uid>, // uids allowed past +i
|
||||||
|
pub created: u64,
|
||||||
|
// --- ephemeral flood counters (not modes; never rendered or synced) -------
|
||||||
|
pub msgflood_hits: HashMap<Uid, Vec<u64>>, // +f per-user message times
|
||||||
|
pub joinflood_hits: Vec<u64>, // +j recent join times
|
||||||
|
pub joinflood_until: u64, // +j locked out until this unix ts
|
||||||
|
pub nickflood_hits: Vec<u64>, // +F recent nick-change times
|
||||||
|
pub nickflood_until: u64, // +F locked out until this unix ts
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Channel {
|
||||||
|
/// A fresh channel with the default `+nt` modes.
|
||||||
|
pub fn new(name: &str) -> Channel {
|
||||||
|
Channel {
|
||||||
|
name: name.to_string(),
|
||||||
|
topic: None,
|
||||||
|
members: HashMap::new(),
|
||||||
|
rmembers: HashMap::new(),
|
||||||
|
modes: ChanModes {
|
||||||
|
no_external: true,
|
||||||
|
topic_ops: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
bans: Vec::new(),
|
||||||
|
excepts: Vec::new(),
|
||||||
|
invex: Vec::new(),
|
||||||
|
filters: Vec::new(),
|
||||||
|
invites: HashSet::new(),
|
||||||
|
created: now(),
|
||||||
|
msgflood_hits: HashMap::new(),
|
||||||
|
joinflood_hits: Vec::new(),
|
||||||
|
joinflood_until: 0,
|
||||||
|
nickflood_hits: Vec::new(),
|
||||||
|
nickflood_until: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True once no local *and* no remote members remain (safe to drop).
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.members.is_empty() && self.rmembers.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// A member's channel rank (0 if not a member).
|
||||||
|
pub fn rank(&self, uid: Uid, key: &str) -> u8 {
|
||||||
|
// SAMODE/SAKICK run as the server: every access check keys off rank(),
|
||||||
|
// so a transient sudo makes them bypass the ladder cleanly.
|
||||||
|
if self.mode_sudo {
|
||||||
|
return RANK_OWNER;
|
||||||
|
}
|
||||||
|
self.channels
|
||||||
|
.get(key)
|
||||||
|
.and_then(|c| c.members.get(&uid))
|
||||||
|
.map(|m| m.rank())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_op(&self, uid: Uid, key: &str) -> bool {
|
||||||
|
self.rank(uid, key) >= RANK_OP
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_member(&self, uid: Uid, key: &str) -> bool {
|
||||||
|
self.channels
|
||||||
|
.get(key)
|
||||||
|
.map(|c| c.members.contains_key(&uid))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Join a user to a channel (creating it if new, giving the creator +o),
|
||||||
|
/// then broadcast JOIN and send TOPIC + NAMES. Queues the join hook.
|
||||||
|
pub fn join(&mut self, uid: Uid, name: &str, key_arg: Option<&str>) {
|
||||||
|
if !valid_chan(name) {
|
||||||
|
self.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{name} :No such channel"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let key = name.to_ascii_lowercase();
|
||||||
|
if self
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.channels.contains(&key))
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
return; // unknown user, or already joined
|
||||||
|
}
|
||||||
|
// an existing channel can refuse the join (+k / +b / +i / +l)
|
||||||
|
if let Some(ch) = self.channels.get(&key) {
|
||||||
|
if let Some(k) = &ch.modes.key {
|
||||||
|
if key_arg != Some(k.as_str()) {
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_BADCHANNELKEY,
|
||||||
|
&format!("{name} :Cannot join channel (+k)"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// +b — bans block even an invited user, unless a +e exception matches
|
||||||
|
let mask = self.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
||||||
|
if ch.bans.iter().any(|b| glob_match(&b.mask, &mask))
|
||||||
|
&& !ch.excepts.iter().any(|e| glob_match(&e.mask, &mask))
|
||||||
|
{
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_BANNEDFROMCHAN,
|
||||||
|
&format!("{name} :Cannot join channel (+b)"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// +i — unless invited or matched by a +I invite exception
|
||||||
|
if ch.modes.invite_only
|
||||||
|
&& !ch.invites.contains(&uid)
|
||||||
|
&& !ch.invex.iter().any(|e| glob_match(&e.mask, &mask))
|
||||||
|
{
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_INVITEONLYCHAN,
|
||||||
|
&format!("{name} :Cannot join channel (+i)"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// +z — TLS-connected users only
|
||||||
|
if ch.modes.secure_only && !self.users.get(&uid).map(|u| u.secure).unwrap_or(false) {
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_SECUREONLYCHAN,
|
||||||
|
&format!("{name} :Cannot join channel; TLS users only (+z is set)"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// +O — IRC operators only
|
||||||
|
if ch.modes.oper_only && !self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false) {
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANTJOINOPERSONLY,
|
||||||
|
&format!("{name} :Cannot join channel; IRC operators only (+O is set)"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// +R — must be logged into an account (services-registered)
|
||||||
|
if ch.modes.reg_only
|
||||||
|
&& self
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.account.is_none())
|
||||||
|
.unwrap_or(true)
|
||||||
|
{
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NEEDREGGEDNICK,
|
||||||
|
&format!("{name} :Cannot join channel; you must be logged in (+R is set)"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// +l full — with +L redirect, bounce the user to the target instead
|
||||||
|
if let Some(ch) = self.channels.get(&key) {
|
||||||
|
let full = ch.modes.limit.is_some_and(|l| ch.members.len() as u32 >= l);
|
||||||
|
let redirect = ch.modes.redirect.clone();
|
||||||
|
if full {
|
||||||
|
match redirect {
|
||||||
|
Some(t)
|
||||||
|
if !self.in_redirect
|
||||||
|
&& t.to_ascii_lowercase() != key
|
||||||
|
&& !self.is_member(uid, &t.to_ascii_lowercase()) =>
|
||||||
|
{
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_LINKCHANNEL,
|
||||||
|
&format!("{name} {t} :Cannot join channel (+l), redirecting"),
|
||||||
|
);
|
||||||
|
self.in_redirect = true;
|
||||||
|
self.join(uid, &t, None);
|
||||||
|
self.in_redirect = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CHANNELISFULL,
|
||||||
|
&format!("{name} :Cannot join channel (+l)"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// +j join flood — once tripped, the channel locks new joins out for 60s
|
||||||
|
if self.channels.contains_key(&key) && self.joinflood_check(&key) {
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_UNAVAILRESOURCE,
|
||||||
|
&format!("{name} :This channel is temporarily unavailable (+j join flood)"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let is_new = !self.channels.contains_key(&key);
|
||||||
|
let ch = self
|
||||||
|
.channels
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_insert_with(|| Channel::new(name));
|
||||||
|
ch.members.insert(
|
||||||
|
uid,
|
||||||
|
Member {
|
||||||
|
op: is_new,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
ch.invites.remove(&uid); // consume any pending invite
|
||||||
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
|
u.channels.insert(key.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// JOIN broadcast — extended-join clients also get the account + realname
|
||||||
|
let (prefix, acct, realname) = {
|
||||||
|
let u = &self.users[&uid];
|
||||||
|
(
|
||||||
|
u.prefix(),
|
||||||
|
u.account.clone().unwrap_or_else(|| "*".to_string()),
|
||||||
|
u.realname.clone(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let plain = format!(":{prefix} JOIN {name}");
|
||||||
|
let extended = format!(":{prefix} JOIN {name} {acct} :{realname}");
|
||||||
|
let aud = self.channels[&key].modes.auditorium;
|
||||||
|
let members: Vec<Uid> = self.channels[&key].members.keys().copied().collect();
|
||||||
|
for m in members {
|
||||||
|
// +u auditorium: non-op members don't see other users join
|
||||||
|
if aud && m != uid && self.rank(m, &key) < RANK_OP {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let ext = self
|
||||||
|
.users
|
||||||
|
.get(&m)
|
||||||
|
.map(|u| u.caps.extended_join)
|
||||||
|
.unwrap_or(false);
|
||||||
|
self.send(m, if ext { extended.clone() } else { plain.clone() });
|
||||||
|
}
|
||||||
|
if let Some(t) = self.channels[&key].topic.as_ref() {
|
||||||
|
let text = t.text.clone();
|
||||||
|
self.numeric(uid, RPL_TOPIC, &format!("{name} :{text}"));
|
||||||
|
}
|
||||||
|
self.send_names(uid, &key);
|
||||||
|
self.propagate_join(uid, name); // tell linked servers this user joined
|
||||||
|
self.events.push_back(Hook::Join(uid, key));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_names(&self, uid: Uid, key: &str) {
|
||||||
|
let Some(ch) = self.channels.get(key) else {
|
||||||
|
self.numeric(uid, RPL_ENDOFNAMES, &format!("{key} :End of /NAMES list"));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// multi-prefix → all prefixes; userhost-in-names → full nick!user@host
|
||||||
|
let (multi, uhost) = self
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| (u.caps.multi_prefix, u.caps.userhost_in_names))
|
||||||
|
.unwrap_or((false, false));
|
||||||
|
// +u auditorium: a non-op viewer only sees ops (plus themselves)
|
||||||
|
let hide =
|
||||||
|
ch.modes.auditorium && ch.members.get(&uid).map(|m| m.rank()).unwrap_or(0) < RANK_OP;
|
||||||
|
let mut names = String::new();
|
||||||
|
for (m, flags) in &ch.members {
|
||||||
|
if hide && *m != uid && flags.rank() < RANK_OP {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let p = if multi {
|
||||||
|
flags.all_prefixes()
|
||||||
|
} else {
|
||||||
|
flags.prefix_char().to_string()
|
||||||
|
};
|
||||||
|
if let Some(u) = self.users.get(m) {
|
||||||
|
names.push_str(&p);
|
||||||
|
let shown = if uhost { u.prefix() } else { u.nick.clone() };
|
||||||
|
names.push_str(&shown);
|
||||||
|
names.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// remote members (users on linked servers)
|
||||||
|
for (ruuid, mem) in &ch.rmembers {
|
||||||
|
if hide && mem.rank() < RANK_OP {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(ru) = self.remote_users.get(ruuid) {
|
||||||
|
let p = if multi {
|
||||||
|
mem.all_prefixes()
|
||||||
|
} else {
|
||||||
|
mem.prefix_char().to_string()
|
||||||
|
};
|
||||||
|
names.push_str(&p);
|
||||||
|
let shown = if uhost { ru.prefix() } else { ru.nick.clone() };
|
||||||
|
names.push_str(&shown);
|
||||||
|
names.push(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_NAMREPLY,
|
||||||
|
&format!("= {} :{}", ch.name, names.trim_end()),
|
||||||
|
);
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_ENDOFNAMES,
|
||||||
|
&format!("{} :End of /NAMES list", ch.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if `uid` is caught by an acting extban of type `kind` (`m`/`c`/`n`) on
|
||||||
|
/// `key` with no matching `kind:` exception in +e. The stored mask is
|
||||||
|
/// `kind:<hostmask>`; we glob the hostmask part against the user's prefix.
|
||||||
|
pub fn extban_active(&self, uid: Uid, key: &str, kind: char) -> bool {
|
||||||
|
let Some(ch) = self.channels.get(key) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(who) = self.users.get(&uid).map(|u| u.prefix()) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let pfx = format!("{kind}:");
|
||||||
|
let hit = |list: &Vec<Ban>| {
|
||||||
|
list.iter()
|
||||||
|
.filter(|b| b.mask.starts_with(&pfx))
|
||||||
|
.any(|b| glob_match(&b.mask[2..], &who))
|
||||||
|
};
|
||||||
|
hit(&ch.bans) && !hit(&ch.excepts)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Broadcast a membership line, honouring +u auditorium: when set, non-op
|
||||||
|
/// members other than `actor` don't see it. `actor` always receives it.
|
||||||
|
pub fn to_channel_vis(&self, key: &str, line: &str, actor: Uid) {
|
||||||
|
if let Some(ch) = self.channels.get(key) {
|
||||||
|
let aud = ch.modes.auditorium;
|
||||||
|
for (&uid, m) in &ch.members {
|
||||||
|
if aud && uid != actor && m.rank() < RANK_OP {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.send(uid, line.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// +f: record a channel message from `uid`; `Some(ban)` when they just went
|
||||||
|
/// over the limit (ban ⇒ also set a +b). Members at half-op+ and opers are
|
||||||
|
/// exempt (checked by the caller).
|
||||||
|
pub fn messageflood_hit(&mut self, uid: Uid, key: &str) -> Option<bool> {
|
||||||
|
let n = now();
|
||||||
|
let ch = self.channels.get_mut(key)?;
|
||||||
|
let f = ch.modes.flood.clone()?;
|
||||||
|
let v = ch.msgflood_hits.entry(uid).or_default();
|
||||||
|
v.retain(|&t| n.saturating_sub(t) < f.secs);
|
||||||
|
v.push(n);
|
||||||
|
if v.len() as u32 > f.lines {
|
||||||
|
ch.msgflood_hits.remove(&uid);
|
||||||
|
Some(f.ban)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kick `uid` from `key` for flooding (optionally banning `*!*@host` first),
|
||||||
|
/// broadcasting the KICK and queuing the part hook.
|
||||||
|
pub fn flood_kick(&mut self, uid: Uid, key: &str, ban: bool) {
|
||||||
|
let (nick, host) = match self.users.get(&uid) {
|
||||||
|
Some(u) => (u.nick.clone(), u.host_display().to_string()),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let cname = self
|
||||||
|
.channels
|
||||||
|
.get(key)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.unwrap_or_else(|| key.to_string());
|
||||||
|
if ban {
|
||||||
|
let mask = format!("*!*@{host}");
|
||||||
|
if let Some(c) = self.channels.get_mut(key) {
|
||||||
|
if !c.bans.iter().any(|b| b.mask == mask) {
|
||||||
|
c.bans.push(Ban {
|
||||||
|
mask,
|
||||||
|
setter: self.name.clone(),
|
||||||
|
ts: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.to_channel(
|
||||||
|
key,
|
||||||
|
&format!(":{} KICK {cname} {nick} :Flood", self.name),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
self.propagate_from_user(uid, &format!("KICK {cname} {nick} :Flood"));
|
||||||
|
if let Some(c) = self.channels.get_mut(key) {
|
||||||
|
c.members.remove(&uid);
|
||||||
|
}
|
||||||
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
|
u.channels.remove(key);
|
||||||
|
}
|
||||||
|
self.channels.retain(|_, c| !c.is_empty());
|
||||||
|
self.events
|
||||||
|
.push_back(Hook::Part(uid, key.to_string(), "flood".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// +j: record a join attempt on `key`; true if joins are (now) locked out.
|
||||||
|
pub fn joinflood_check(&mut self, key: &str) -> bool {
|
||||||
|
let n = now();
|
||||||
|
let Some(ch) = self.channels.get_mut(key) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(f) = ch.modes.joinflood.clone() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if n < ch.joinflood_until {
|
||||||
|
return true; // still locked out
|
||||||
|
}
|
||||||
|
ch.joinflood_hits.retain(|&t| n.saturating_sub(t) < f.secs);
|
||||||
|
ch.joinflood_hits.push(n);
|
||||||
|
if ch.joinflood_hits.len() as u32 > f.count {
|
||||||
|
ch.joinflood_until = n + 60; // lock the channel for 60s
|
||||||
|
ch.joinflood_hits.clear();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
/// +F: record a nick change and return the display name of any channel that
|
||||||
|
/// is (now) locked out — the caller denies the change if so. Opers exempt.
|
||||||
|
pub fn nickflood_blocked(&mut self, uid: Uid) -> Option<String> {
|
||||||
|
let n = now();
|
||||||
|
let keys: Vec<String> = self
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.channels.iter().cloned().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut blocked = None;
|
||||||
|
for key in keys {
|
||||||
|
let Some(ch) = self.channels.get_mut(&key) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(f) = ch.modes.nickflood.clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if n < ch.nickflood_until {
|
||||||
|
blocked.get_or_insert_with(|| ch.name.clone());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ch.nickflood_hits.retain(|&t| n.saturating_sub(t) < f.secs);
|
||||||
|
ch.nickflood_hits.push(n);
|
||||||
|
if ch.nickflood_hits.len() as u32 > f.count {
|
||||||
|
ch.nickflood_until = n + 60;
|
||||||
|
ch.nickflood_hits.clear();
|
||||||
|
blocked.get_or_insert_with(|| ch.name.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
blocked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A channel name starts with `#`, is ≤ 50 chars, and has no space/comma/control.
|
||||||
|
pub fn valid_chan(name: &str) -> bool {
|
||||||
|
name.starts_with('#')
|
||||||
|
&& name.len() > 1
|
||||||
|
&& name.len() <= 50
|
||||||
|
&& !name
|
||||||
|
.chars()
|
||||||
|
.any(|c| c == ' ' || c == ',' || (c as u32) < 0x20)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive glob (`*` = any run, `?` = one char) — for +b mask matching.
|
||||||
|
pub fn glob_match(pat: &str, s: &str) -> bool {
|
||||||
|
let p: Vec<char> = pat.to_lowercase().chars().collect();
|
||||||
|
let t: Vec<char> = s.to_lowercase().chars().collect();
|
||||||
|
let (mut pi, mut ti) = (0usize, 0usize);
|
||||||
|
let mut star: Option<usize> = None;
|
||||||
|
let mut mark = 0usize;
|
||||||
|
while ti < t.len() {
|
||||||
|
if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) {
|
||||||
|
pi += 1;
|
||||||
|
ti += 1;
|
||||||
|
} else if pi < p.len() && p[pi] == '*' {
|
||||||
|
star = Some(pi);
|
||||||
|
mark = ti;
|
||||||
|
pi += 1;
|
||||||
|
} else if let Some(sp) = star {
|
||||||
|
pi = sp + 1;
|
||||||
|
mark += 1;
|
||||||
|
ti = mark;
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
while pi < p.len() && p[pi] == '*' {
|
||||||
|
pi += 1;
|
||||||
|
}
|
||||||
|
pi == p.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fill out a ban mask to `nick!user@host` form (`bob` → `bob!*@*`).
|
||||||
|
pub fn normalize_mask(m: &str) -> String {
|
||||||
|
match (m.contains('!'), m.contains('@')) {
|
||||||
|
(true, true) => m.to_string(),
|
||||||
|
(false, true) => format!("*!{m}"),
|
||||||
|
(true, false) => format!("{m}@*"),
|
||||||
|
(false, false) => format!("{m}!*@*"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like [`normalize_mask`] but aware of extbans: `m:bob` normalises only the
|
||||||
|
/// value after the `X:` prefix, so acting bans (`m:` mute, `c:` nocolor,
|
||||||
|
/// `n:` nonick) keep their type while their hostmask is filled out.
|
||||||
|
pub fn normalize_ban_mask(m: &str) -> String {
|
||||||
|
let b = m.as_bytes();
|
||||||
|
if b.len() >= 2 && b[1] == b':' && (b[0] as char).is_ascii_alphabetic() {
|
||||||
|
return format!("{}:{}", &m[..1], normalize_mask(&m[2..]));
|
||||||
|
}
|
||||||
|
normalize_mask(m)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn member_rank_and_prefix_char_take_the_highest() {
|
||||||
|
let mut m = Member::default();
|
||||||
|
assert_eq!(m.rank(), 0);
|
||||||
|
assert_eq!(m.prefix_char(), "");
|
||||||
|
m.voice = true;
|
||||||
|
assert_eq!((m.rank(), m.prefix_char()), (RANK_VOICE, "+"));
|
||||||
|
m.halfop = true;
|
||||||
|
assert_eq!((m.rank(), m.prefix_char()), (RANK_HALFOP, "%"));
|
||||||
|
m.op = true;
|
||||||
|
assert_eq!((m.rank(), m.prefix_char()), (RANK_OP, "@"));
|
||||||
|
m.admin = true;
|
||||||
|
assert_eq!((m.rank(), m.prefix_char()), (RANK_ADMIN, "&"));
|
||||||
|
m.owner = true;
|
||||||
|
assert_eq!((m.rank(), m.prefix_char()), (RANK_OWNER, "~"));
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/command.rs
Normal file
29
src/command.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
//! The command API — echoIRCd's answer to InspIRCd's `Command` class.
|
||||||
|
//!
|
||||||
|
//! A command is a stateless handler registered by name. The core validates
|
||||||
|
//! `min_params` and the registration gate (`before_reg`) before calling
|
||||||
|
//! [`Command::handle`], which gets `&mut Server` and does the work.
|
||||||
|
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// Outcome of a command (mirrors InspIRCd's `CmdResult`, minus server-only bits).
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum CmdResult {
|
||||||
|
Ok,
|
||||||
|
Fail,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait Command: Send {
|
||||||
|
/// The command name, upper-case (also its registry key).
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
/// Minimum parameters; fewer ⇒ the core replies `461` and skips the handler.
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
/// May this run before the client has registered (NICK/USER/CAP/PING/QUIT)?
|
||||||
|
fn before_reg(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
fn handle(&self, srv: &mut Server, uid: Uid, params: &[String]) -> CmdResult;
|
||||||
|
}
|
||||||
193
src/config.rs
Normal file
193
src/config.rs
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
//! Tiny `key = value` config, same spirit as rubot.conf (no XML, no deps).
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! servername = echo.devtronic.pro
|
||||||
|
//! network = echoNet
|
||||||
|
//! bind = 127.0.0.1:6767
|
||||||
|
//! motd = Welcome to echoIRCd
|
||||||
|
//! oper = god secret
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
/// A server-link block: how to authenticate a peer named `name` (and, if
|
||||||
|
/// `autoconnect`, where to dial it). Passwords are the shared link secret.
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct LinkBlock {
|
||||||
|
pub name: String,
|
||||||
|
pub ip: String,
|
||||||
|
pub port: u16,
|
||||||
|
pub password: String,
|
||||||
|
pub autoconnect: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Config for the `antimixedutf8` module (blocks mixed-script look-alike spam).
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AntiMixedCfg {
|
||||||
|
pub enable: bool,
|
||||||
|
pub threshold: u32,
|
||||||
|
pub minlen: usize,
|
||||||
|
pub action: String, // block | kill | gline | kline | zline
|
||||||
|
pub duration: u64, // seconds, for the *line actions
|
||||||
|
pub reason: String,
|
||||||
|
pub block_msg: String, // notice text sent on the "block" action
|
||||||
|
pub check_channel: bool,
|
||||||
|
pub check_private: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AntiMixedCfg {
|
||||||
|
fn default() -> AntiMixedCfg {
|
||||||
|
AntiMixedCfg {
|
||||||
|
enable: false,
|
||||||
|
threshold: 8,
|
||||||
|
minlen: 10,
|
||||||
|
action: "block".to_string(),
|
||||||
|
duration: 3600,
|
||||||
|
reason: "Mixed-script text (spam).".to_string(),
|
||||||
|
block_msg: "Your message contains mixed look-alike characters often used by \
|
||||||
|
spam. Please rewrite it and try again."
|
||||||
|
.to_string(),
|
||||||
|
check_channel: true,
|
||||||
|
check_private: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct Config {
|
||||||
|
pub servername: String,
|
||||||
|
pub network: String,
|
||||||
|
pub bind: String,
|
||||||
|
pub bind_tls: Option<String>, // e.g. 0.0.0.0:6697 — the TLS listener
|
||||||
|
pub tls_cert: Option<String>, // PEM certificate chain
|
||||||
|
pub tls_key: Option<String>, // PEM private key
|
||||||
|
pub motd: Vec<String>,
|
||||||
|
pub opers: Vec<(String, String)>, // (name, password)
|
||||||
|
pub cloak_key: Option<String>, // secret key for host cloaking (+x); None = off
|
||||||
|
pub sid: String, // this server's 3-char server id (S2S)
|
||||||
|
pub serverdesc: String, // this server's description
|
||||||
|
pub bind_server: Option<String>, // the server-to-server link listener
|
||||||
|
pub links: Vec<LinkBlock>, // peers we accept / dial
|
||||||
|
pub conf_path: String, // where this was loaded from (for REHASH)
|
||||||
|
pub censor: Vec<(String, String)>, // +G bad words: (find, replace); empty replace = block
|
||||||
|
pub amu: AntiMixedCfg, // antimixedutf8 module config
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Config {
|
||||||
|
fn default() -> Config {
|
||||||
|
Config {
|
||||||
|
servername: "echo.local".to_string(),
|
||||||
|
network: "echoNet".to_string(),
|
||||||
|
bind: "127.0.0.1:6767".to_string(),
|
||||||
|
bind_tls: None,
|
||||||
|
tls_cert: None,
|
||||||
|
tls_key: None,
|
||||||
|
motd: Vec::new(),
|
||||||
|
opers: Vec::new(),
|
||||||
|
cloak_key: None,
|
||||||
|
sid: "0AA".to_string(),
|
||||||
|
serverdesc: "echoIRCd server".to_string(),
|
||||||
|
bind_server: None,
|
||||||
|
links: Vec::new(),
|
||||||
|
conf_path: "echoircd.conf".to_string(),
|
||||||
|
censor: Vec::new(),
|
||||||
|
amu: AntiMixedCfg::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Read a config file, falling back to defaults for anything missing. A
|
||||||
|
/// missing file is not an error — you get the defaults.
|
||||||
|
pub fn load(path: &str) -> Config {
|
||||||
|
let mut c = Config {
|
||||||
|
conf_path: path.to_string(),
|
||||||
|
..Config::default()
|
||||||
|
};
|
||||||
|
let Ok(text) = std::fs::read_to_string(path) else {
|
||||||
|
return c;
|
||||||
|
};
|
||||||
|
for line in text.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') || line.starts_with(';') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some((k, v)) = line.split_once('=') else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (k, v) = (k.trim(), v.trim());
|
||||||
|
match k {
|
||||||
|
"servername" | "server" => c.servername = v.to_string(),
|
||||||
|
"network" => c.network = v.to_string(),
|
||||||
|
"bind" => c.bind = v.to_string(),
|
||||||
|
"bind_tls" => c.bind_tls = Some(v.to_string()),
|
||||||
|
"tls_cert" => c.tls_cert = Some(v.to_string()),
|
||||||
|
"tls_key" => c.tls_key = Some(v.to_string()),
|
||||||
|
"cloak_key" => c.cloak_key = Some(v.to_string()),
|
||||||
|
"sid" => c.sid = v.to_string(),
|
||||||
|
"serverdesc" | "description" => c.serverdesc = v.to_string(),
|
||||||
|
"bind_server" => c.bind_server = Some(v.to_string()),
|
||||||
|
"link" => {
|
||||||
|
// link = <name> <ip> <port> <password> [autoconnect]
|
||||||
|
let t: Vec<&str> = v.split_whitespace().collect();
|
||||||
|
if t.len() >= 4 {
|
||||||
|
if let Ok(port) = t[2].parse::<u16>() {
|
||||||
|
c.links.push(LinkBlock {
|
||||||
|
name: t[0].to_string(),
|
||||||
|
ip: t[1].to_string(),
|
||||||
|
port,
|
||||||
|
password: t[3].to_string(),
|
||||||
|
autoconnect: t.get(4).is_some_and(|a| {
|
||||||
|
a.eq_ignore_ascii_case("autoconnect")
|
||||||
|
|| a.eq_ignore_ascii_case("connect")
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"motd" => c.motd.push(v.to_string()),
|
||||||
|
"oper" => {
|
||||||
|
let mut it = v.split_whitespace();
|
||||||
|
if let (Some(n), Some(p)) = (it.next(), it.next()) {
|
||||||
|
c.opers.push((n.to_string(), p.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// +G censor word: `badword = <find> [replace]` (no replace ⇒ block)
|
||||||
|
"badword" => {
|
||||||
|
let mut it = v.splitn(2, char::is_whitespace);
|
||||||
|
if let Some(find) = it.next().filter(|f| !f.is_empty()) {
|
||||||
|
let replace = it.next().unwrap_or("").trim().to_string();
|
||||||
|
c.censor.push((find.to_string(), replace));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"antimixedutf8" | "amu" => {
|
||||||
|
c.amu.enable =
|
||||||
|
matches!(v.to_ascii_lowercase().as_str(), "on" | "true" | "yes" | "1")
|
||||||
|
}
|
||||||
|
"amu_threshold" => {
|
||||||
|
if let Ok(n) = v.parse() {
|
||||||
|
c.amu.threshold = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"amu_minlen" => {
|
||||||
|
if let Ok(n) = v.parse() {
|
||||||
|
c.amu.minlen = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"amu_action" => c.amu.action = v.to_string(),
|
||||||
|
"amu_duration" => {
|
||||||
|
if let Some(d) = crate::xline::parse_duration(v) {
|
||||||
|
c.amu.duration = d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"amu_reason" => c.amu.reason = v.to_string(),
|
||||||
|
"amu_message" => c.amu.block_msg = v.to_string(),
|
||||||
|
"amu_target" => {
|
||||||
|
let t = v.to_ascii_lowercase();
|
||||||
|
c.amu.check_channel = t == "both" || t == "channel";
|
||||||
|
c.amu.check_private = t == "both" || t == "private";
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c
|
||||||
|
}
|
||||||
|
}
|
||||||
471
src/coremods/core_channel.rs
Normal file
471
src/coremods/core_channel.rs
Normal file
|
|
@ -0,0 +1,471 @@
|
||||||
|
//! core_channel — channel membership commands: JOIN, PART, KICK, TOPIC, NAMES.
|
||||||
|
|
||||||
|
use crate::channels::{Topic, RANK_HALFOP};
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::module::Hook;
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::{now, Server};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![
|
||||||
|
Box::new(Join),
|
||||||
|
Box::new(Part),
|
||||||
|
Box::new(Kick),
|
||||||
|
Box::new(TopicCmd),
|
||||||
|
Box::new(Names),
|
||||||
|
Box::new(Invite),
|
||||||
|
Box::new(Knock),
|
||||||
|
Box::new(Cycle),
|
||||||
|
Box::new(Remove),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// KNOCK — ask for an invite to an invite-only channel.
|
||||||
|
struct Knock;
|
||||||
|
impl Command for Knock {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"KNOCK"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let chan = ¶ms[0];
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
if !s.channels.contains_key(&key) {
|
||||||
|
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let can = s.channels[&key].modes.invite_only && !s.is_member(uid, &key);
|
||||||
|
if !can {
|
||||||
|
let nick = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
s.send(
|
||||||
|
uid,
|
||||||
|
format!(
|
||||||
|
":{} NOTICE {nick} :Can't KNOCK on {chan} (not invite-only, or you're on it)",
|
||||||
|
s.name
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let reason = params
|
||||||
|
.get(1)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "requesting an invite".to_string());
|
||||||
|
let who = s.users[&uid].prefix();
|
||||||
|
s.to_channel(
|
||||||
|
&key,
|
||||||
|
&format!(
|
||||||
|
":{} NOTICE {chan} :[Knock] {who} is knocking: {reason}",
|
||||||
|
s.name
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_KNOCKDLVR,
|
||||||
|
&format!("{chan} :Your KNOCK has been delivered"),
|
||||||
|
);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CYCLE — part and immediately rejoin a channel.
|
||||||
|
struct Cycle;
|
||||||
|
impl Command for Cycle {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"CYCLE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let chan = ¶ms[0];
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
if !s.is_member(uid, &key) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOTONCHANNEL,
|
||||||
|
&format!("{chan} :You're not on that channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let prefix = s.users[&uid].prefix();
|
||||||
|
s.to_channel(&key, &format!(":{prefix} PART {chan} :cycling"), None);
|
||||||
|
s.propagate_part(uid, chan, "cycling");
|
||||||
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
|
ch.members.remove(&uid);
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.channels.remove(&key);
|
||||||
|
}
|
||||||
|
s.channels.retain(|_, c| !c.is_empty());
|
||||||
|
s.join(uid, chan, None);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// REMOVE — like KICK, but the target sees a PART (a softer removal).
|
||||||
|
struct Remove;
|
||||||
|
impl Command for Remove {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"REMOVE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let (chan, victim) = (¶ms[0], ¶ms[1]);
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
if !s.channels.contains_key(&key) {
|
||||||
|
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if s.rank(uid, &key) < RANK_HALFOP {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CHANOPRIVSNEEDED,
|
||||||
|
&format!("{chan} :You're not a channel operator"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(tuid) = s.find_nick(victim) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{victim} :No such nick/channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
if !s.channels[&key].members.contains_key(&tuid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_USERNOTINCHANNEL,
|
||||||
|
&format!("{victim} {chan} :They aren't on that channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if s.rank(uid, &key) < s.rank(tuid, &key) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CHANOPRIVSNEEDED,
|
||||||
|
&format!("{chan} :You cannot remove a user of higher rank"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let by = s.users[&uid].nick.clone();
|
||||||
|
let reason = match params.get(2) {
|
||||||
|
Some(r) => format!("Removed by {by}: {r}"),
|
||||||
|
None => format!("Removed by {by}"),
|
||||||
|
};
|
||||||
|
let prefix = s.users[&tuid].prefix();
|
||||||
|
s.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), None);
|
||||||
|
s.propagate_part(tuid, chan, &reason);
|
||||||
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
|
ch.members.remove(&tuid);
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&tuid) {
|
||||||
|
u.channels.remove(&key);
|
||||||
|
}
|
||||||
|
s.channels.retain(|_, c| !c.is_empty());
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Invite;
|
||||||
|
impl Command for Invite {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"INVITE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let (tnick, chan) = (¶ms[0], ¶ms[1]);
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
if !s.channels.contains_key(&key) {
|
||||||
|
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if !s.is_member(uid, &key) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOTONCHANNEL,
|
||||||
|
&format!("{chan} :You're not on that channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// only ops may invite into an +i channel
|
||||||
|
if s.channels[&key].modes.invite_only && !s.is_op(uid, &key) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CHANOPRIVSNEEDED,
|
||||||
|
&format!("{chan} :You're not a channel operator"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(tuid) = s.find_nick(tnick) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{tnick} :No such nick/channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
if s.channels[&key].members.contains_key(&tuid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_USERONCHANNEL,
|
||||||
|
&format!("{tnick} {chan} :is already on channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
|
ch.invites.insert(tuid);
|
||||||
|
}
|
||||||
|
let who = s.users[&tuid].nick.clone();
|
||||||
|
s.numeric(uid, RPL_INVITING, &format!("{who} {chan}"));
|
||||||
|
let prefix = s.users[&uid].prefix();
|
||||||
|
s.send(tuid, format!(":{prefix} INVITE {who} :{chan}"));
|
||||||
|
// invite-notify: tell capable channel members about the invite
|
||||||
|
let notify = format!(":{prefix} INVITE {who} {chan}");
|
||||||
|
let members: Vec<Uid> = s.channels[&key].members.keys().copied().collect();
|
||||||
|
for m in members {
|
||||||
|
if m != uid
|
||||||
|
&& s.users
|
||||||
|
.get(&m)
|
||||||
|
.map(|u| u.caps.invite_notify)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
s.send(m, notify.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Join;
|
||||||
|
impl Command for Join {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"JOIN"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let keys: Vec<&str> = params
|
||||||
|
.get(1)
|
||||||
|
.map(|k| k.split(',').collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for (i, name) in params[0].split(',').filter(|x| !x.is_empty()).enumerate() {
|
||||||
|
s.join(uid, name, keys.get(i).copied());
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Part;
|
||||||
|
impl Command for Part {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"PART"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let reason = params.get(1).cloned().unwrap_or_default();
|
||||||
|
for target in params[0].split(',').filter(|x| !x.is_empty()) {
|
||||||
|
let key = target.to_ascii_lowercase();
|
||||||
|
let on = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.channels.contains(&key))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !on {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOTONCHANNEL,
|
||||||
|
&format!("{target} :You're not on that channel"),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let prefix = s.users[&uid].prefix();
|
||||||
|
let line = if reason.is_empty() {
|
||||||
|
format!(":{prefix} PART {target}")
|
||||||
|
} else {
|
||||||
|
format!(":{prefix} PART {target} :{reason}")
|
||||||
|
};
|
||||||
|
s.to_channel_vis(&key, &line, uid); // +u: only ops + self see the part
|
||||||
|
s.propagate_part(uid, target, &reason); // tell linked servers
|
||||||
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
|
ch.members.remove(&uid);
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.channels.remove(&key);
|
||||||
|
}
|
||||||
|
s.channels.retain(|_, c| !c.is_empty());
|
||||||
|
s.events.push_back(Hook::Part(uid, key, reason.clone()));
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Kick;
|
||||||
|
impl Command for Kick {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"KICK"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let (chan, victim) = (¶ms[0], ¶ms[1]);
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
if !s.channels.contains_key(&key) {
|
||||||
|
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if s.rank(uid, &key) < RANK_HALFOP {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CHANOPRIVSNEEDED,
|
||||||
|
&format!("{chan} :You're not a channel operator"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(tuid) = s.find_nick(victim) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{victim} :No such nick/channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
if !s.channels[&key].members.contains_key(&tuid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_USERNOTINCHANNEL,
|
||||||
|
&format!("{victim} {chan} :They aren't on that channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// can't kick someone who out-ranks you
|
||||||
|
if s.rank(uid, &key) < s.rank(tuid, &key) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CHANOPRIVSNEEDED,
|
||||||
|
&format!("{chan} :You cannot kick a user of higher rank"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let kicker = s.users[&uid].nick.clone();
|
||||||
|
let reason = params.get(2).cloned().unwrap_or(kicker);
|
||||||
|
let prefix = s.users[&uid].prefix();
|
||||||
|
s.to_channel(
|
||||||
|
&key,
|
||||||
|
&format!(":{prefix} KICK {chan} {victim} :{reason}"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
s.propagate_from_user(uid, &format!("KICK {chan} {victim} :{reason}")); // tell links
|
||||||
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
|
ch.members.remove(&tuid);
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&tuid) {
|
||||||
|
u.channels.remove(&key);
|
||||||
|
}
|
||||||
|
s.channels.retain(|_, c| !c.is_empty());
|
||||||
|
s.events
|
||||||
|
.push_back(Hook::Part(tuid, key, "kicked".to_string()));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TopicCmd;
|
||||||
|
impl Command for TopicCmd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"TOPIC"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let target = ¶ms[0];
|
||||||
|
let key = target.to_ascii_lowercase();
|
||||||
|
if !s.channels.contains_key(&key) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHCHANNEL,
|
||||||
|
&format!("{target} :No such channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if params.len() < 2 {
|
||||||
|
match s.channels[&key].topic.as_ref() {
|
||||||
|
Some(t) => {
|
||||||
|
let text = t.text.clone();
|
||||||
|
s.numeric(uid, RPL_TOPIC, &format!("{target} :{text}"));
|
||||||
|
}
|
||||||
|
None => s.numeric(uid, RPL_NOTOPIC, &format!("{target} :No topic is set")),
|
||||||
|
}
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
let on = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.channels.contains(&key))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !on {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOTONCHANNEL,
|
||||||
|
&format!("{target} :You're not on that channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// +t: only ops may set the topic
|
||||||
|
if s.channels[&key].modes.topic_ops && !s.is_op(uid, &key) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CHANOPRIVSNEEDED,
|
||||||
|
&format!("{target} :You're not a channel operator"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let text = params[1].clone();
|
||||||
|
let (prefix, setter) = {
|
||||||
|
let u = &s.users[&uid];
|
||||||
|
(u.prefix(), u.nick.clone())
|
||||||
|
};
|
||||||
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
|
ch.topic = Some(Topic {
|
||||||
|
text: text.clone(),
|
||||||
|
setter,
|
||||||
|
ts: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
s.to_channel(&key, &format!(":{prefix} TOPIC {target} :{text}"), None);
|
||||||
|
s.propagate_from_user(uid, &format!("TOPIC {target} :{text}")); // tell links
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Names;
|
||||||
|
impl Command for Names {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"NAMES"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
for target in params[0].split(',').filter(|x| !x.is_empty()) {
|
||||||
|
s.send_names(uid, &target.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
287
src/coremods/core_extra.rs
Normal file
287
src/coremods/core_extra.rs
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
//! core_extra — the standard informational / utility commands a full ircd is
|
||||||
|
//! expected to answer: LIST, WHOWAS, USERHOST, ISON, TIME, ADMIN, INFO, STATS, MAP.
|
||||||
|
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::{iso_time, now, Server, VERSION};
|
||||||
|
use crate::xline::XKind;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![
|
||||||
|
Box::new(List),
|
||||||
|
Box::new(Whowas),
|
||||||
|
Box::new(UserHost),
|
||||||
|
Box::new(IsOn),
|
||||||
|
Box::new(Time),
|
||||||
|
Box::new(Admin),
|
||||||
|
Box::new(Info),
|
||||||
|
Box::new(Stats),
|
||||||
|
Box::new(Map),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct List;
|
||||||
|
impl Command for List {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"LIST"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
s.numeric(uid, RPL_LISTSTART, "Channel :Users Name");
|
||||||
|
let keys: Vec<String> = s.channels.keys().cloned().collect();
|
||||||
|
for key in keys {
|
||||||
|
let ch = &s.channels[&key];
|
||||||
|
// hide secret / private channels from non-members
|
||||||
|
if (ch.modes.secret || ch.modes.private) && !ch.members.contains_key(&uid) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let count = ch.members.len() + ch.rmembers.len();
|
||||||
|
let topic = ch
|
||||||
|
.topic
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.text.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let name = ch.name.clone();
|
||||||
|
s.numeric(uid, RPL_LIST, &format!("{name} {count} :{topic}"));
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_LISTEND, ":End of /LIST");
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Whowas;
|
||||||
|
impl Command for Whowas {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"WHOWAS"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let target = ¶ms[0];
|
||||||
|
let want = target.to_ascii_lowercase();
|
||||||
|
let limit = params
|
||||||
|
.get(1)
|
||||||
|
.and_then(|c| c.parse::<usize>().ok())
|
||||||
|
.unwrap_or(8);
|
||||||
|
let hits: Vec<(String, String, String, String, u64)> = s
|
||||||
|
.whowas
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.nick.to_ascii_lowercase() == want)
|
||||||
|
.take(limit)
|
||||||
|
.map(|e| {
|
||||||
|
(
|
||||||
|
e.nick.clone(),
|
||||||
|
e.ident.clone(),
|
||||||
|
e.host.clone(),
|
||||||
|
e.realname.clone(),
|
||||||
|
e.ts,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if hits.is_empty() {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_WASNOSUCHNICK,
|
||||||
|
&format!("{target} :There was no such nickname"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (nick, ident, host, realname, ts) in hits {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOWASUSER,
|
||||||
|
&format!("{nick} {ident} {host} * :{realname}"),
|
||||||
|
);
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISSERVER,
|
||||||
|
&format!("{nick} {} :{}", s.name, iso_time(ts)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_ENDOFWHOWAS, &format!("{target} :End of WHOWAS"));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserHost;
|
||||||
|
impl Command for UserHost {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"USERHOST"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
for nick in params.iter().take(5) {
|
||||||
|
if let Some(tuid) = s.find_nick(nick) {
|
||||||
|
if let Some(u) = s.users.get(&tuid) {
|
||||||
|
let star = if u.flags.oper { "*" } else { "" };
|
||||||
|
let here = if u.flags.away.is_some() { "-" } else { "+" };
|
||||||
|
parts.push(format!(
|
||||||
|
"{}{star}={here}{}@{}",
|
||||||
|
u.nick,
|
||||||
|
u.ident,
|
||||||
|
u.host_display()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_USERHOST, &format!(":{}", parts.join(" ")));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct IsOn;
|
||||||
|
impl Command for IsOn {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"ISON"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let on: Vec<String> = params
|
||||||
|
.iter()
|
||||||
|
.flat_map(|p| p.split_whitespace())
|
||||||
|
.filter(|n| s.find_nick(n).is_some() || s.find_remote(n).is_some())
|
||||||
|
.map(|n| n.to_string())
|
||||||
|
.collect();
|
||||||
|
s.numeric(uid, RPL_ISON, &format!(":{}", on.join(" ")));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Time;
|
||||||
|
impl Command for Time {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"TIME"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
s.numeric(uid, RPL_TIME, &format!("{} :{}", s.name, iso_time(now())));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Admin;
|
||||||
|
impl Command for Admin {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"ADMIN"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_ADMINME,
|
||||||
|
&format!("{} :Administrative info", s.name),
|
||||||
|
);
|
||||||
|
s.numeric(uid, RPL_ADMINLOC1, &format!(":{} IRC network", s.network));
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_ADMINLOC2,
|
||||||
|
":echoIRCd — a from-scratch ircd in Rust",
|
||||||
|
);
|
||||||
|
s.numeric(uid, RPL_ADMINEMAIL, &format!(":admin@{}", s.name));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Info;
|
||||||
|
impl Command for Info {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"INFO"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
for line in [
|
||||||
|
format!("echoircd-{VERSION} — a from-scratch IRC daemon in Rust"),
|
||||||
|
"Modeled on InspIRCd's API; #![forbid(unsafe_code)]".to_string(),
|
||||||
|
format!("Running the {} network", s.network),
|
||||||
|
] {
|
||||||
|
s.numeric(uid, RPL_INFO, &format!(":{line}"));
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_ENDOFINFO, ":End of /INFO list");
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Stats;
|
||||||
|
impl Command for Stats {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"STATS"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let letter = params[0].chars().next().unwrap_or(' ');
|
||||||
|
match letter {
|
||||||
|
'u' => {
|
||||||
|
let up = now().saturating_sub(s.created);
|
||||||
|
let (d, h, m, sec) = (up / 86400, (up % 86400) / 3600, (up % 3600) / 60, up % 60);
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_STATSUPTIME,
|
||||||
|
&format!(":Server Up {d} days {h:02}:{m:02}:{sec:02}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
'o' => {
|
||||||
|
let opers: Vec<String> = s.opers.iter().map(|(n, _)| n.clone()).collect();
|
||||||
|
for n in opers {
|
||||||
|
s.numeric(uid, RPL_STATSOLINE, &format!("O * * {n} :oper"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'k' | 'g' | 'z' => {
|
||||||
|
let kind = match letter {
|
||||||
|
'k' => XKind::Kline,
|
||||||
|
'g' => XKind::Gline,
|
||||||
|
_ => XKind::Zline,
|
||||||
|
};
|
||||||
|
let rows: Vec<String> = s
|
||||||
|
.xlines
|
||||||
|
.iter()
|
||||||
|
.filter(|x| x.kind == kind)
|
||||||
|
.map(|x| {
|
||||||
|
format!(
|
||||||
|
"{} {} {} {} :{}",
|
||||||
|
x.kind.tag(),
|
||||||
|
x.mask,
|
||||||
|
x.expires,
|
||||||
|
x.setter,
|
||||||
|
x.reason
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for r in rows {
|
||||||
|
s.numeric(uid, RPL_STATSXLINE, &r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_ENDOFSTATS,
|
||||||
|
&format!("{letter} :End of /STATS report"),
|
||||||
|
);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Map;
|
||||||
|
impl Command for Map {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"MAP"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_MAP,
|
||||||
|
&format!("{} ({} users)", s.name, s.users.len()),
|
||||||
|
);
|
||||||
|
let mut peers: Vec<String> = s.servers.values().map(|sv| sv.name.clone()).collect();
|
||||||
|
peers.sort();
|
||||||
|
for name in peers {
|
||||||
|
s.numeric(uid, RPL_MAP, &format!("`- {name}"));
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_MAPEND, ":End of /MAP");
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
312
src/coremods/core_info.rs
Normal file
312
src/coremods/core_info.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
||||||
|
//! core_info — informational commands: WHOIS, WHO, LUSERS, MOTD, VERSION.
|
||||||
|
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::{Server, VERSION};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![
|
||||||
|
Box::new(Whois),
|
||||||
|
Box::new(Who),
|
||||||
|
Box::new(Lusers),
|
||||||
|
Box::new(Motd),
|
||||||
|
Box::new(VersionCmd),
|
||||||
|
Box::new(Links),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// LINKS — the servers this one knows about (itself + every linked peer).
|
||||||
|
struct Links;
|
||||||
|
impl Command for Links {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"LINKS"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_LINKS,
|
||||||
|
&format!("{} {} :0 {}", s.name, s.name, s.server_desc),
|
||||||
|
);
|
||||||
|
let mut rows: Vec<(String, String)> = s
|
||||||
|
.servers
|
||||||
|
.values()
|
||||||
|
.map(|sv| (sv.name.clone(), sv.desc.clone()))
|
||||||
|
.collect();
|
||||||
|
rows.sort();
|
||||||
|
for (name, desc) in rows {
|
||||||
|
s.numeric(uid, RPL_LINKS, &format!("{name} {} :1 {desc}", s.name));
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_ENDOFLINKS, "* :End of /LINKS list");
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Whois;
|
||||||
|
impl Command for Whois {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"WHOIS"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let tnick = params[0]
|
||||||
|
.split(',')
|
||||||
|
.next()
|
||||||
|
.unwrap_or(¶ms[0])
|
||||||
|
.to_string();
|
||||||
|
let Some(tuid) = s.find_nick(&tnick) else {
|
||||||
|
// maybe they're on another server
|
||||||
|
if let Some((uuid, _)) = s.find_remote(&tnick) {
|
||||||
|
if let Some(ru) = s.remote_users.get(&uuid) {
|
||||||
|
let srv = s
|
||||||
|
.servers
|
||||||
|
.get(&ru.sid)
|
||||||
|
.map(|sv| sv.name.clone())
|
||||||
|
.unwrap_or_else(|| ru.sid.clone());
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISUSER,
|
||||||
|
&format!("{} {} {} * :{}", ru.nick, ru.ident, ru.host, ru.realname),
|
||||||
|
);
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISSERVER,
|
||||||
|
&format!("{} {srv} :remote user", ru.nick),
|
||||||
|
);
|
||||||
|
if let Some(a) = &ru.account {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISACCOUNT,
|
||||||
|
&format!("{} {a} :is logged in as", ru.nick),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_ENDOFWHOIS,
|
||||||
|
&format!("{} :End of /WHOIS list", ru.nick),
|
||||||
|
);
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{tnick} :No such nick/channel"),
|
||||||
|
);
|
||||||
|
s.numeric(uid, RPL_ENDOFWHOIS, &format!("{tnick} :End of /WHOIS list"));
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
let asker_oper = s.is_oper(uid);
|
||||||
|
let is_self = tuid == uid;
|
||||||
|
let keys: Vec<String> = s.users[&tuid].channels.iter().cloned().collect();
|
||||||
|
let (
|
||||||
|
nick,
|
||||||
|
ident,
|
||||||
|
disp,
|
||||||
|
realname,
|
||||||
|
realhost,
|
||||||
|
realip,
|
||||||
|
secure,
|
||||||
|
oper,
|
||||||
|
bot,
|
||||||
|
hideoper,
|
||||||
|
hidechans,
|
||||||
|
account,
|
||||||
|
last_active,
|
||||||
|
signon,
|
||||||
|
) = {
|
||||||
|
let u = &s.users[&tuid];
|
||||||
|
(
|
||||||
|
u.nick.clone(),
|
||||||
|
u.ident.clone(),
|
||||||
|
u.host_display().to_string(),
|
||||||
|
u.realname.clone(),
|
||||||
|
u.host.clone(),
|
||||||
|
u.addr.ip().to_string(),
|
||||||
|
u.secure,
|
||||||
|
u.flags.oper,
|
||||||
|
u.flags.bot,
|
||||||
|
u.flags.hideoper,
|
||||||
|
u.flags.hidechans,
|
||||||
|
u.account.clone(),
|
||||||
|
u.last_active,
|
||||||
|
u.signon,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let chans: Vec<String> = keys
|
||||||
|
.iter()
|
||||||
|
.filter_map(|k| s.channels.get(k).map(|c| c.name.clone()))
|
||||||
|
.collect();
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISUSER,
|
||||||
|
&format!("{nick} {ident} {disp} * :{realname}"),
|
||||||
|
);
|
||||||
|
if bot {
|
||||||
|
s.numeric(uid, RPL_WHOISBOT, &format!("{nick} :is a bot"));
|
||||||
|
}
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISSERVER,
|
||||||
|
&format!("{nick} {} :echoIRCd", s.name),
|
||||||
|
);
|
||||||
|
// +I hides the channel list from everyone but the user themselves + opers
|
||||||
|
if !chans.is_empty() && (is_self || asker_oper || !hidechans) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISCHANNELS,
|
||||||
|
&format!("{nick} :{}", chans.join(" ")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 313: is an IRC operator (hidden by +H unless the asker is an oper)
|
||||||
|
if oper && (!hideoper || asker_oper) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISOPERATOR,
|
||||||
|
&format!("{nick} :is an IRC operator"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// opers can see through the cloak to the real host/ip
|
||||||
|
if asker_oper && disp != realhost {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISHOST,
|
||||||
|
&format!("{nick} :is connecting from {ident}@{realhost} {realip}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 330: logged in to a services account
|
||||||
|
if let Some(acct) = &account {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISACCOUNT,
|
||||||
|
&format!("{nick} {acct} :is logged in as"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// sslinfo: advertise a secure (TLS) connection
|
||||||
|
if secure {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISSECURE,
|
||||||
|
&format!("{nick} :is using a secure connection"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 317: idle time + signon time
|
||||||
|
let idle = crate::server::now().saturating_sub(last_active);
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WHOISIDLE,
|
||||||
|
&format!("{nick} {idle} {signon} :seconds idle, signon time"),
|
||||||
|
);
|
||||||
|
s.numeric(uid, RPL_ENDOFWHOIS, &format!("{nick} :End of /WHOIS list"));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Who;
|
||||||
|
impl Command for Who {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"WHO"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let target = ¶ms[0];
|
||||||
|
if target.starts_with('#') {
|
||||||
|
let key = target.to_ascii_lowercase();
|
||||||
|
let multi = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.caps.multi_prefix)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let rows: Vec<(Uid, String, String)> = match s.channels.get(&key) {
|
||||||
|
Some(ch) => {
|
||||||
|
let name = ch.name.clone();
|
||||||
|
ch.members
|
||||||
|
.iter()
|
||||||
|
.map(|(&m, mem)| {
|
||||||
|
let p = if multi {
|
||||||
|
mem.all_prefixes()
|
||||||
|
} else {
|
||||||
|
mem.prefix_char().to_string()
|
||||||
|
};
|
||||||
|
(m, name.clone(), p)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
None => Vec::new(),
|
||||||
|
};
|
||||||
|
for (m, name, pfx) in rows {
|
||||||
|
if let Some(u) = s.users.get(&m) {
|
||||||
|
let row = format!(
|
||||||
|
"{name} {} {} {} {} H{pfx} :0 {}",
|
||||||
|
u.ident,
|
||||||
|
u.host_display(),
|
||||||
|
s.name,
|
||||||
|
u.nick,
|
||||||
|
u.realname
|
||||||
|
);
|
||||||
|
s.numeric(uid, RPL_WHOREPLY, &row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(tuid) = s.find_nick(target) {
|
||||||
|
let u = &s.users[&tuid];
|
||||||
|
let row = format!(
|
||||||
|
"* {} {} {} {} H :0 {}",
|
||||||
|
u.ident,
|
||||||
|
u.host_display(),
|
||||||
|
s.name,
|
||||||
|
u.nick,
|
||||||
|
u.realname
|
||||||
|
);
|
||||||
|
s.numeric(uid, RPL_WHOREPLY, &row);
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_ENDOFWHO, &format!("{target} :End of /WHO list"));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Lusers;
|
||||||
|
impl Command for Lusers {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"LUSERS"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_LUSERCLIENT,
|
||||||
|
&format!(":There are {} users on 1 server", s.users.len()),
|
||||||
|
);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Motd;
|
||||||
|
impl Command for Motd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"MOTD"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
s.send_motd(uid);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct VersionCmd;
|
||||||
|
impl Command for VersionCmd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"VERSION"
|
||||||
|
}
|
||||||
|
fn before_reg(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
s.send(
|
||||||
|
uid,
|
||||||
|
format!(":{} 351 * echoircd-{VERSION} {} :", s.name, s.name),
|
||||||
|
);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
546
src/coremods/core_message.rs
Normal file
546
src/coremods/core_message.rs
Normal file
|
|
@ -0,0 +1,546 @@
|
||||||
|
//! core_message — PRIVMSG and NOTICE (channel + user targets).
|
||||||
|
|
||||||
|
use crate::channels::{glob_match, RANK_HALFOP, RANK_VOICE};
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// mIRC/IRC formatting control bytes (bold, colour, hex-colour, reset, …).
|
||||||
|
const FMT: [char; 9] = [
|
||||||
|
'\u{02}', '\u{03}', '\u{04}', '\u{0F}', '\u{11}', '\u{16}', '\u{1D}', '\u{1E}', '\u{1F}',
|
||||||
|
];
|
||||||
|
|
||||||
|
fn is_ctcp(t: &str) -> bool {
|
||||||
|
t.starts_with('\u{01}')
|
||||||
|
}
|
||||||
|
fn is_action(t: &str) -> bool {
|
||||||
|
t.starts_with("\u{01}ACTION")
|
||||||
|
}
|
||||||
|
fn has_formatting(t: &str) -> bool {
|
||||||
|
t.chars().any(|c| FMT.contains(&c))
|
||||||
|
}
|
||||||
|
/// Strip formatting/colour codes (drops \x03 colour specs and \x04 hex specs).
|
||||||
|
fn strip_formatting(t: &str) -> String {
|
||||||
|
let cs: Vec<char> = t.chars().collect();
|
||||||
|
let mut out = String::with_capacity(cs.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < cs.len() {
|
||||||
|
match cs[i] {
|
||||||
|
'\u{02}' | '\u{0F}' | '\u{11}' | '\u{16}' | '\u{1D}' | '\u{1E}' | '\u{1F}' => i += 1,
|
||||||
|
'\u{03}' => {
|
||||||
|
i += 1;
|
||||||
|
let mut n = 0;
|
||||||
|
while n < 2 && i < cs.len() && cs[i].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
if n > 0 && i + 1 < cs.len() && cs[i] == ',' && cs[i + 1].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
let mut m = 0;
|
||||||
|
while m < 2 && i < cs.len() && cs[i].is_ascii_digit() {
|
||||||
|
i += 1;
|
||||||
|
m += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'\u{04}' => {
|
||||||
|
i += 1;
|
||||||
|
let mut n = 0;
|
||||||
|
while n < 6 && i < cs.len() && cs[i].is_ascii_hexdigit() {
|
||||||
|
i += 1;
|
||||||
|
n += 1;
|
||||||
|
}
|
||||||
|
if n == 6 && i + 1 < cs.len() && cs[i] == ',' && cs[i + 1].is_ascii_hexdigit() {
|
||||||
|
i += 1;
|
||||||
|
let mut m = 0;
|
||||||
|
while m < 6 && i < cs.len() && cs[i].is_ascii_hexdigit() {
|
||||||
|
i += 1;
|
||||||
|
m += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c => {
|
||||||
|
out.push(c);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive substring test over chars (Unicode-safe, no byte slicing).
|
||||||
|
fn ci_contains(hay: &str, find: &str) -> bool {
|
||||||
|
let h: Vec<char> = hay.chars().collect();
|
||||||
|
let f: Vec<char> = find.chars().collect();
|
||||||
|
if f.is_empty() || f.len() > h.len() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
(0..=h.len() - f.len()).any(|i| (0..f.len()).all(|k| h[i + k].eq_ignore_ascii_case(&f[k])))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Case-insensitive replace-all over chars (Unicode-safe, no byte slicing).
|
||||||
|
fn ci_replace(hay: &str, find: &str, rep: &str) -> String {
|
||||||
|
let h: Vec<char> = hay.chars().collect();
|
||||||
|
let f: Vec<char> = find.chars().collect();
|
||||||
|
if f.is_empty() {
|
||||||
|
return hay.to_string();
|
||||||
|
}
|
||||||
|
let mut out = String::with_capacity(hay.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < h.len() {
|
||||||
|
let hit =
|
||||||
|
i + f.len() <= h.len() && (0..f.len()).all(|k| h[i + k].eq_ignore_ascii_case(&f[k]));
|
||||||
|
if hit {
|
||||||
|
out.push_str(rep);
|
||||||
|
i += f.len();
|
||||||
|
} else {
|
||||||
|
out.push(h[i]);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// +G censor: replace each configured bad word in `body`. Returns `None` when a
|
||||||
|
/// matched word has an empty replacement (⇒ the message must be blocked).
|
||||||
|
fn apply_censor(body: &str, censor: &[(String, String)]) -> Option<String> {
|
||||||
|
let mut out = body.to_string();
|
||||||
|
for (find, replace) in censor {
|
||||||
|
if find.is_empty() || !ci_contains(&out, find) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if replace.is_empty() {
|
||||||
|
return None; // no replacement ⇒ block
|
||||||
|
}
|
||||||
|
out = ci_replace(&out, find, replace);
|
||||||
|
}
|
||||||
|
Some(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![Box::new(PrivMsg), Box::new(Notice), Box::new(TagMsg)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared PRIVMSG/NOTICE delivery. NOTICE never generates automatic replies.
|
||||||
|
fn deliver(s: &mut Server, uid: Uid, params: &[String], notice: bool) -> CmdResult {
|
||||||
|
let cmd = if notice { "NOTICE" } else { "PRIVMSG" };
|
||||||
|
if params.is_empty() {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NORECIPIENT,
|
||||||
|
&format!(":No recipient given ({cmd})"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if params.len() < 2 || params[1].is_empty() {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(uid, ERR_NOTEXTTOSEND, ":No text to send");
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let (target, text) = (¶ms[0], ¶ms[1]);
|
||||||
|
let Some(prefix) = s.users.get(&uid).map(|u| u.prefix()) else {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
if target.starts_with('#') {
|
||||||
|
let key = target.to_ascii_lowercase();
|
||||||
|
let member = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.members.contains_key(&uid))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !member {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANNOTSENDTOCHAN,
|
||||||
|
&format!("{target} :Cannot send to channel"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// +m: only voiced-or-above may speak
|
||||||
|
let moderated = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.modes.moderated)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if moderated && s.rank(uid, &key) < RANK_VOICE {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANNOTSENDTOCHAN,
|
||||||
|
&format!("{target} :Cannot send to channel (+m)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// +M: only logged-in (account) users may speak (voiced-or-above exempt)
|
||||||
|
let reg_moderated = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.modes.reg_moderated)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if reg_moderated && s.rank(uid, &key) < RANK_VOICE && !s.is_logged_in(uid) {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NEEDREGGEDNICK,
|
||||||
|
&format!("{target} :You must be logged into an account to speak here (+M)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// extban `m:` mute — matched users can't speak unless voiced-or-above
|
||||||
|
if s.extban_active(uid, &key, 'm') && s.rank(uid, &key) < RANK_VOICE {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANNOTSENDTOCHAN,
|
||||||
|
&format!("{target} :Cannot send to channel (you're muted, +b m:)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// +f message flood — ops/half-ops and opers are exempt; others get kicked
|
||||||
|
let flood_exempt = s.rank(uid, &key) >= RANK_HALFOP
|
||||||
|
|| s.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false);
|
||||||
|
if !flood_exempt {
|
||||||
|
if let Some(ban) = s.messageflood_hit(uid, &key) {
|
||||||
|
s.flood_kick(uid, &key, ban);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// content-based modes: +C no CTCP, +T no notices, +c no colour, +S strip
|
||||||
|
let (no_ctcp, no_notice, no_color, strip) = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| {
|
||||||
|
(
|
||||||
|
c.modes.no_ctcp,
|
||||||
|
c.modes.no_notice,
|
||||||
|
c.modes.no_color,
|
||||||
|
c.modes.strip_color,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if notice && no_notice {
|
||||||
|
return CmdResult::Fail; // +T — NOTICEs are silently dropped
|
||||||
|
}
|
||||||
|
if no_ctcp && is_ctcp(text) && !is_action(text) {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANNOTSENDTOCHAN,
|
||||||
|
&format!("{target} :CTCP is disabled (+C)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if no_color && has_formatting(text) {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANNOTSENDTOCHAN,
|
||||||
|
&format!("{target} :Formatting/colour is disabled (+c)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// extban `c:` no-colour — matched users can't send formatting
|
||||||
|
if s.extban_active(uid, &key, 'c') && has_formatting(text) {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANNOTSENDTOCHAN,
|
||||||
|
&format!("{target} :Formatting/colour is disabled for you (+b c:)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// +g channel filter — block messages matching any word/glob (use *word*)
|
||||||
|
let filtered = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.filters.iter().any(|f| glob_match(&f.mask, text)))
|
||||||
|
.unwrap_or(false);
|
||||||
|
if filtered {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANNOTSENDTOCHAN,
|
||||||
|
&format!("{target} :Cannot send to channel (blocked by +g filter)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let mut body = if strip {
|
||||||
|
strip_formatting(text)
|
||||||
|
} else {
|
||||||
|
text.clone()
|
||||||
|
};
|
||||||
|
// +G censor — replace configured bad words (empty replacement ⇒ block)
|
||||||
|
let censor_on = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.modes.censor)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if censor_on && !s.censor.is_empty() {
|
||||||
|
match apply_censor(&body, &s.censor) {
|
||||||
|
Some(b) => body = b,
|
||||||
|
None => {
|
||||||
|
if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANNOTSENDTOCHAN,
|
||||||
|
&format!("{target} :Cannot send to channel (+G censor)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// deliver to every member except the sender and +D (deaf) users, tagging
|
||||||
|
// per-recipient (server-time + any client-only tags on the line)
|
||||||
|
let line = format!(":{prefix} {cmd} {target} :{body}");
|
||||||
|
let ctags = s.line_ctags.clone();
|
||||||
|
let msgid = s.next_msgid(); // one id shared by every recipient of this message
|
||||||
|
let members: Vec<Uid> = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.members.keys().copied().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for m in members {
|
||||||
|
if m == uid || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
s.send_tagged(m, &ctags, &msgid, &line);
|
||||||
|
}
|
||||||
|
// echo-message: give the sender their own copy if they asked for one
|
||||||
|
if s.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.caps.echo_message)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
s.send_tagged(uid, &ctags, &msgid, &line);
|
||||||
|
}
|
||||||
|
// propagate to linked servers that have members in this channel
|
||||||
|
s.send_channel_to_links(uid, &key, target, cmd, &body);
|
||||||
|
} else if let Some(tuid) = s.find_nick(target) {
|
||||||
|
// user +R (regdeaf): drop messages from users not logged into an account
|
||||||
|
if s.users
|
||||||
|
.get(&tuid)
|
||||||
|
.map(|u| u.flags.reg_only_pm)
|
||||||
|
.unwrap_or(false)
|
||||||
|
&& !s.is_logged_in(uid)
|
||||||
|
{
|
||||||
|
if !notice {
|
||||||
|
let tn = s
|
||||||
|
.users
|
||||||
|
.get(&tuid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NEEDREGGEDNICK,
|
||||||
|
&format!("{tn} :You must be logged into an account to message this user (+R)"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// user +z (sslqueries): only TLS users may PM them
|
||||||
|
if s.users.get(&tuid).map(|u| u.flags.ssl_pm).unwrap_or(false)
|
||||||
|
&& !s.users.get(&uid).map(|u| u.secure).unwrap_or(false)
|
||||||
|
{
|
||||||
|
if !notice {
|
||||||
|
let (tn, sn) = (
|
||||||
|
s.users
|
||||||
|
.get(&tuid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
s.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
s.send(
|
||||||
|
uid,
|
||||||
|
format!(
|
||||||
|
":{} NOTICE {sn} :Cannot message {tn}: a TLS connection is required (+z)",
|
||||||
|
s.name
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// SILENCE: if the recipient silenced the sender, drop it silently — the
|
||||||
|
// sender is never told (that's the point), but still gets their own echo.
|
||||||
|
let silenced = s.is_silenced(tuid, &prefix);
|
||||||
|
let pm = format!(":{prefix} {cmd} {target} :{text}");
|
||||||
|
let ctags = s.line_ctags.clone();
|
||||||
|
let msgid = s.next_msgid();
|
||||||
|
if !silenced {
|
||||||
|
s.send_tagged(tuid, &ctags, &msgid, &pm);
|
||||||
|
}
|
||||||
|
if s.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.caps.echo_message)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
s.send_tagged(uid, &ctags, &msgid, &pm);
|
||||||
|
}
|
||||||
|
// if the recipient is away, tell the sender (PRIVMSG only, not if silenced)
|
||||||
|
if !notice && !silenced {
|
||||||
|
if let Some(msg) = s.users.get(&tuid).and_then(|u| u.flags.away.clone()) {
|
||||||
|
s.numeric(uid, RPL_AWAY, &format!("{target} :{msg}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some((uuid, via)) = s.find_remote(target) {
|
||||||
|
// the target is a user on another server — route it across the link
|
||||||
|
s.send_to_remote(uid, &uuid, via, cmd, text);
|
||||||
|
if s.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.caps.echo_message)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
let ctags = s.line_ctags.clone();
|
||||||
|
let msgid = s.next_msgid();
|
||||||
|
s.send_tagged(
|
||||||
|
uid,
|
||||||
|
&ctags,
|
||||||
|
&msgid,
|
||||||
|
&format!(":{prefix} {cmd} {target} :{text}"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if !notice {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{target} :No such nick/channel"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrivMsg;
|
||||||
|
impl Command for PrivMsg {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"PRIVMSG"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
deliver(s, uid, params, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Notice;
|
||||||
|
impl Command for Notice {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"NOTICE"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
deliver(s, uid, params, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// TAGMSG — an IRCv3 message that carries only client tags (typing, reactions, …)
|
||||||
|
/// and no text. Relayed to targets whose clients enabled `message-tags`; clients
|
||||||
|
/// without it never see it. Mirrors PRIVMSG's target / membership / +m rules.
|
||||||
|
struct TagMsg;
|
||||||
|
impl Command for TagMsg {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"TAGMSG"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let ctags = s.line_ctags.clone();
|
||||||
|
if ctags.is_empty() {
|
||||||
|
return CmdResult::Ok; // no client tags -> nothing to relay
|
||||||
|
}
|
||||||
|
let target = ¶ms[0];
|
||||||
|
let Some(prefix) = s.users.get(&uid).map(|u| u.prefix()) else {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
let body = format!(":{prefix} TAGMSG {target}");
|
||||||
|
let msgid = s.next_msgid(); // shared across this TAGMSG's recipients
|
||||||
|
if target.starts_with('#') {
|
||||||
|
let key = target.to_ascii_lowercase();
|
||||||
|
if !s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.members.contains_key(&uid))
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// +m: only voiced-or-above may emit tags
|
||||||
|
let moderated = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.modes.moderated)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if moderated && s.rank(uid, &key) < RANK_VOICE {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let echo = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.caps.echo_message)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let members: Vec<Uid> = s
|
||||||
|
.channels
|
||||||
|
.get(&key)
|
||||||
|
.map(|c| c.members.keys().copied().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for m in members {
|
||||||
|
if (m == uid && !echo) || s.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// only message-tags clients receive a TAGMSG
|
||||||
|
if s.users
|
||||||
|
.get(&m)
|
||||||
|
.map(|u| u.caps.message_tags)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
s.send_tagged(m, &ctags, &msgid, &body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(tuid) = s.find_nick(target) {
|
||||||
|
if s.users
|
||||||
|
.get(&tuid)
|
||||||
|
.map(|u| u.caps.message_tags)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
s.send_tagged(tuid, &ctags, &msgid, &body);
|
||||||
|
}
|
||||||
|
if s.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.caps.echo_message)
|
||||||
|
.unwrap_or(false)
|
||||||
|
{
|
||||||
|
s.send_tagged(uid, &ctags, &msgid, &body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strip_drops_codes_but_keeps_text_and_bare_commas() {
|
||||||
|
assert_eq!(strip_formatting("\u{03}04red\u{03} text"), "red text");
|
||||||
|
assert_eq!(strip_formatting("\u{02}bold\u{02}"), "bold");
|
||||||
|
assert_eq!(strip_formatting("\u{03}04,08fg"), "fg"); // colour,bg spec
|
||||||
|
assert_eq!(strip_formatting("\u{03}4, hi"), ", hi"); // bare comma survives
|
||||||
|
assert!(has_formatting("\u{03}4x") && !has_formatting("plain"));
|
||||||
|
assert!(is_ctcp("\u{01}PING\u{01}") && !is_ctcp("hi"));
|
||||||
|
assert!(is_action("\u{01}ACTION waves"));
|
||||||
|
}
|
||||||
|
}
|
||||||
179
src/coremods/core_mode.rs
Normal file
179
src/coremods/core_mode.rs
Normal file
|
|
@ -0,0 +1,179 @@
|
||||||
|
//! core_mode — the MODE command. Both channel and user modes are dispatched to
|
||||||
|
//! the handler objects in [`crate::mode`] (InspIRCd-style `ModeHandler`s); this
|
||||||
|
//! file just parses the modestring and orchestrates.
|
||||||
|
|
||||||
|
use crate::channels::RANK_HALFOP;
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::mode::{chan_mode, user_mode, Applied};
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![Box::new(Mode)]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Append one mode change to the echo string, emitting the +/- only when it flips.
|
||||||
|
fn emit(applied: &mut String, last: &mut char, sign: char, c: char) {
|
||||||
|
if *last != sign {
|
||||||
|
applied.push(sign);
|
||||||
|
*last = sign;
|
||||||
|
}
|
||||||
|
applied.push(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Mode;
|
||||||
|
impl Command for Mode {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"MODE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
apply_mode(s, uid, params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The MODE body, shared with SAMODE (which wraps it in `Server::mode_sudo` so
|
||||||
|
/// the rank gates below all pass — see `core_oper::SaMode`).
|
||||||
|
pub fn apply_mode(s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let target = ¶ms[0];
|
||||||
|
if !target.starts_with('#') {
|
||||||
|
return apply_user_modes(s, uid, target, params);
|
||||||
|
}
|
||||||
|
let key = target.to_ascii_lowercase();
|
||||||
|
if !s.channels.contains_key(&key) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHCHANNEL,
|
||||||
|
&format!("{target} :No such channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// query: `MODE #c`
|
||||||
|
if params.len() < 2 {
|
||||||
|
let modestr = s.channels[&key].modes.render(s.is_member(uid, &key));
|
||||||
|
s.numeric(uid, RPL_CHANNELMODEIS, &format!("{target} {modestr}"));
|
||||||
|
let created = s.channels[&key].created;
|
||||||
|
s.numeric(uid, RPL_CREATIONTIME, &format!("{target} {created}"));
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
// setting modes needs at least half-op; each handler then enforces its
|
||||||
|
// own finer rule (prefixes need enough rank, +z needs all-secure, …)
|
||||||
|
if s.rank(uid, &key) < RANK_HALFOP {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CHANOPRIVSNEEDED,
|
||||||
|
&format!("{target} :You're not a channel operator"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch each mode letter to its handler
|
||||||
|
let modestring = params[1].clone();
|
||||||
|
let args = ¶ms[2..];
|
||||||
|
let mut argi = 0usize;
|
||||||
|
let mut sign = '+';
|
||||||
|
let mut applied = String::new();
|
||||||
|
let mut last = ' ';
|
||||||
|
let mut echoed: Vec<String> = Vec::new();
|
||||||
|
for c in modestring.chars() {
|
||||||
|
if c == '+' || c == '-' {
|
||||||
|
sign = c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let adding = sign == '+';
|
||||||
|
let Some(handler) = chan_mode(c) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_UNKNOWNMODE,
|
||||||
|
&format!("{c} :is unknown mode char to me"),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let param = if handler.wants_param(adding) {
|
||||||
|
let p = args.get(argi).cloned();
|
||||||
|
if p.is_some() {
|
||||||
|
argi += 1;
|
||||||
|
}
|
||||||
|
p
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Applied::Yes(echo) = handler.apply(s, target, &key, uid, adding, param.as_deref()) {
|
||||||
|
emit(&mut applied, &mut last, sign, c);
|
||||||
|
if let Some(p) = echo {
|
||||||
|
echoed.push(p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !applied.is_empty() {
|
||||||
|
let prefix = s.users[&uid].prefix();
|
||||||
|
let pstr = if echoed.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" {}", echoed.join(" "))
|
||||||
|
};
|
||||||
|
s.to_channel(
|
||||||
|
&key,
|
||||||
|
&format!(":{prefix} MODE {target} {applied}{pstr}"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
s.propagate_from_user(uid, &format!("MODE {target} {applied}{pstr}"));
|
||||||
|
// links
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// User modes: dispatched to the [`crate::mode`] `UserMode` handler objects.
|
||||||
|
fn apply_user_modes(s: &mut Server, uid: Uid, target: &str, params: &[String]) -> CmdResult {
|
||||||
|
let me = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !target.eq_ignore_ascii_case(&me) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_USERSDONTMATCH,
|
||||||
|
":Can't change mode for other users",
|
||||||
|
);
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
if params.len() < 2 {
|
||||||
|
let um = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.flags.umodes())
|
||||||
|
.unwrap_or_else(|| "+".to_string());
|
||||||
|
s.numeric(uid, RPL_UMODEIS, &um);
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
let modestring = params[1].clone();
|
||||||
|
let mut sign = '+';
|
||||||
|
let mut applied = String::new();
|
||||||
|
let mut last = ' ';
|
||||||
|
for c in modestring.chars() {
|
||||||
|
if c == '+' || c == '-' {
|
||||||
|
sign = c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let adding = sign == '+';
|
||||||
|
let Some(handler) = user_mode(c) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_UMODEUNKNOWNFLAG,
|
||||||
|
&format!(":Unknown MODE flag {c}"),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if handler.apply(s, uid, adding) {
|
||||||
|
emit(&mut applied, &mut last, sign, c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !applied.is_empty() {
|
||||||
|
s.send(uid, format!(":{me} MODE {me} :{applied}"));
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
778
src/coremods/core_oper.rs
Normal file
778
src/coremods/core_oper.rs
Normal file
|
|
@ -0,0 +1,778 @@
|
||||||
|
//! core_oper — IRC operator commands: OPER, KILL, WALLOPS. Mirrors InspIRCd's
|
||||||
|
//! `coremods/core_oper/`. Oper blocks are configured with `oper = name pass`.
|
||||||
|
|
||||||
|
use crate::channels::Topic;
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::coremods::core_mode::apply_mode;
|
||||||
|
use crate::module::Hook;
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::{now, Server};
|
||||||
|
use crate::users::{valid_host, valid_ident, valid_nick};
|
||||||
|
use crate::xline::{parse_duration, XKind};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![
|
||||||
|
Box::new(Oper),
|
||||||
|
Box::new(Kill),
|
||||||
|
Box::new(Wallops),
|
||||||
|
Box::new(SvsLogin),
|
||||||
|
Box::new(SvsLogout),
|
||||||
|
Box::new(Rehash),
|
||||||
|
Box::new(GlobOps),
|
||||||
|
Box::new(SaJoin),
|
||||||
|
Box::new(SaPart),
|
||||||
|
Box::new(SaNick),
|
||||||
|
Box::new(Die),
|
||||||
|
Box::new(Restart),
|
||||||
|
Box::new(Kline),
|
||||||
|
Box::new(Gline),
|
||||||
|
Box::new(Zline),
|
||||||
|
Box::new(ChgHost),
|
||||||
|
Box::new(ChgIdent),
|
||||||
|
Box::new(SetHost),
|
||||||
|
Box::new(SetIdent),
|
||||||
|
Box::new(SaMode),
|
||||||
|
Box::new(SaTopic),
|
||||||
|
Box::new(SaKick),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reject non-opers with 481; returns whether the caller is an oper.
|
||||||
|
fn require_oper(s: &mut Server, uid: Uid) -> bool {
|
||||||
|
if s.is_oper(uid) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOPRIVILEGES,
|
||||||
|
":Permission Denied- You're not an IRC operator",
|
||||||
|
);
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Oper;
|
||||||
|
impl Command for Oper {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"OPER"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let (name, pass) = (¶ms[0], ¶ms[1]);
|
||||||
|
if s.opers.iter().any(|(n, p)| n == name && p == pass) {
|
||||||
|
s.oper_up(uid);
|
||||||
|
CmdResult::Ok
|
||||||
|
} else {
|
||||||
|
s.numeric(uid, ERR_PASSWDMISMATCH, ":Password incorrect");
|
||||||
|
CmdResult::Fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Kill;
|
||||||
|
impl Command for Kill {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"KILL"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !s.is_oper(uid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOPRIVILEGES,
|
||||||
|
":Permission Denied- You're not an IRC operator",
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let target = ¶ms[0];
|
||||||
|
let reason = params
|
||||||
|
.get(1)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Killed".to_string());
|
||||||
|
let Some(tuid) = s.find_nick(target) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{target} :No such nick/channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
let killer = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
s.send(
|
||||||
|
tuid,
|
||||||
|
format!(":{} KILL {target} :{killer} ({reason})", s.name),
|
||||||
|
);
|
||||||
|
s.remove_user(tuid, &format!("Killed by {killer}: {reason}"));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SVSLOGIN / SVSLOGOUT — the **services interface** to the account layer
|
||||||
|
/// ([`crate::accounts`]). Over S2S these arrive from a services pseudoserver
|
||||||
|
/// (Anope/Atheme); until S2S exists an oper may invoke them to drive `+r` and the
|
||||||
|
/// account-gated channel modes. `SVSLOGIN <nick> <account>` logs a user in
|
||||||
|
/// (`account` of `*`/`0` logs out); `SVSLOGOUT <nick>` logs them out.
|
||||||
|
struct SvsLogin;
|
||||||
|
impl Command for SvsLogin {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SVSLOGIN"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !s.is_oper(uid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOPRIVILEGES,
|
||||||
|
":Permission Denied- SVSLOGIN is a services command",
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let (target, account) = (¶ms[0], ¶ms[1]);
|
||||||
|
let Some(tuid) = s.find_nick(target) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{target} :No such nick/channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
if account == "*" || account == "0" {
|
||||||
|
s.logout(tuid);
|
||||||
|
} else {
|
||||||
|
s.set_login(tuid, account);
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SvsLogout;
|
||||||
|
impl Command for SvsLogout {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SVSLOGOUT"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !s.is_oper(uid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOPRIVILEGES,
|
||||||
|
":Permission Denied- SVSLOGOUT is a services command",
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(tuid) = s.find_nick(¶ms[0]) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{} :No such nick/channel", params[0]),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
s.logout(tuid);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Wallops;
|
||||||
|
impl Command for Wallops {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"WALLOPS"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !s.is_oper(uid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOPRIVILEGES,
|
||||||
|
":Permission Denied- You're not an IRC operator",
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let from = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
s.wallops(&from, ¶ms[0]);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// REHASH — reload the config file (MOTD, oper blocks, cloak key).
|
||||||
|
struct Rehash;
|
||||||
|
impl Command for Rehash {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"REHASH"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let fresh = Config::load(&s.conf_path);
|
||||||
|
s.motd = fresh.motd;
|
||||||
|
s.opers = fresh.opers;
|
||||||
|
s.cloak_key = fresh.cloak_key;
|
||||||
|
s.censor = fresh.censor;
|
||||||
|
s.amu = fresh.amu;
|
||||||
|
s.numeric(uid, RPL_REHASHING, &format!("{} :Rehashing", s.conf_path));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLOBOPS — a message to every IRC operator.
|
||||||
|
struct GlobOps;
|
||||||
|
impl Command for GlobOps {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"GLOBOPS"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let from = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let opers: Vec<Uid> = s
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, u)| u.flags.oper)
|
||||||
|
.map(|(&u, _)| u)
|
||||||
|
.collect();
|
||||||
|
for o in opers {
|
||||||
|
let nick = s.users.get(&o).map(|u| u.nick.clone()).unwrap_or_default();
|
||||||
|
s.send(
|
||||||
|
o,
|
||||||
|
format!(
|
||||||
|
":{} NOTICE {nick} :*** GLOBOPS from {from}: {}",
|
||||||
|
s.name, params[0]
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SAJOIN — force a user into a channel.
|
||||||
|
struct SaJoin;
|
||||||
|
impl Command for SaJoin {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SAJOIN"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(tuid) = s.find_nick(¶ms[0]) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{} :No such nick/channel", params[0]),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
s.join(tuid, ¶ms[1], None);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SAPART — force a user out of a channel.
|
||||||
|
struct SaPart;
|
||||||
|
impl Command for SaPart {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SAPART"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(tuid) = s.find_nick(¶ms[0]) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{} :No such nick/channel", params[0]),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
let chan = ¶ms[1];
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
let reason = params
|
||||||
|
.get(2)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Removed".to_string());
|
||||||
|
if s.is_member(tuid, &key) {
|
||||||
|
let prefix = s.users[&tuid].prefix();
|
||||||
|
s.to_channel(&key, &format!(":{prefix} PART {chan} :{reason}"), None);
|
||||||
|
s.propagate_part(tuid, chan, &reason);
|
||||||
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
|
ch.members.remove(&tuid);
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&tuid) {
|
||||||
|
u.channels.remove(&key);
|
||||||
|
}
|
||||||
|
s.channels.retain(|_, c| !c.is_empty());
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SANICK — force a user's nickname.
|
||||||
|
struct SaNick;
|
||||||
|
impl Command for SaNick {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SANICK"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(tuid) = s.find_nick(¶ms[0]) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{} :No such nick/channel", params[0]),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
let newnick = ¶ms[1];
|
||||||
|
if !valid_nick(newnick) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_ERRONEUSNICKNAME,
|
||||||
|
&format!("{newnick} :Erroneous nickname"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if s.find_nick(newnick).is_some()
|
||||||
|
|| s.remote_nick.contains_key(&newnick.to_ascii_lowercase())
|
||||||
|
{
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NICKNAMEINUSE,
|
||||||
|
&format!("{newnick} :Nickname is already in use"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
s.set_nick(tuid, newnick);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// DIE — shut the server down (requires the server name as confirmation).
|
||||||
|
struct Die;
|
||||||
|
impl Command for Die {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"DIE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if params[0] != s.name {
|
||||||
|
s.numeric(uid, ERR_NOPRIVILEGES, ":DIE requires the server name");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let by = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
eprintln!("[oper] DIE by {by}");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RESTART — like DIE (a supervisor is expected to relaunch us).
|
||||||
|
struct Restart;
|
||||||
|
impl Command for Restart {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"RESTART"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if params[0] != s.name {
|
||||||
|
s.numeric(uid, ERR_NOPRIVILEGES, ":RESTART requires the server name");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let by = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
eprintln!("[oper] RESTART by {by}");
|
||||||
|
std::process::exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared KLINE/GLINE/ZLINE handling: the mask alone removes, mask+duration adds.
|
||||||
|
fn do_xline(s: &mut Server, uid: Uid, params: &[String], kind: XKind) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let mask = params[0].clone();
|
||||||
|
let nick = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if params.len() < 2 {
|
||||||
|
let word = if s.remove_xline(kind, &mask) {
|
||||||
|
"removed"
|
||||||
|
} else {
|
||||||
|
"not found"
|
||||||
|
};
|
||||||
|
s.send(
|
||||||
|
uid,
|
||||||
|
format!(
|
||||||
|
":{} NOTICE {nick} :{}-line {word}: {mask}",
|
||||||
|
s.name,
|
||||||
|
kind.tag()
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
let dur = parse_duration(¶ms[1]).unwrap_or(0);
|
||||||
|
let reason = params
|
||||||
|
.get(2)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "No reason given".to_string());
|
||||||
|
s.add_xline(kind, &mask, dur, &nick, &reason);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
|
||||||
|
/// KLINE — ban a `user@host` mask on this server.
|
||||||
|
struct Kline;
|
||||||
|
impl Command for Kline {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"KLINE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
do_xline(s, uid, params, XKind::Kline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GLINE — a network-wide `user@host` ban.
|
||||||
|
struct Gline;
|
||||||
|
impl Command for Gline {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"GLINE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
do_xline(s, uid, params, XKind::Gline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// ZLINE — ban an IP address (glob).
|
||||||
|
struct Zline;
|
||||||
|
impl Command for Zline {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"ZLINE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
do_xline(s, uid, params, XKind::Zline)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a nick to a uid, sending ERR_NOSUCHNICK if it's unknown.
|
||||||
|
fn oper_target(s: &mut Server, uid: Uid, nick: &str) -> Option<Uid> {
|
||||||
|
match s.find_nick(nick) {
|
||||||
|
Some(t) => Some(t),
|
||||||
|
None => {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{nick} :No such nick/channel"),
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server NOTICE to the invoking oper (soft errors for the CHG*/SA* set).
|
||||||
|
fn onotice(s: &mut Server, uid: Uid, msg: &str) {
|
||||||
|
let nick = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_else(|| "*".to_string());
|
||||||
|
s.send(uid, format!(":{} NOTICE {nick} :{msg}", s.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The oper's nick, for audit snotices.
|
||||||
|
fn oper_nick(s: &Server, uid: Uid) -> String {
|
||||||
|
s.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CHGHOST — change another user's displayed host.
|
||||||
|
struct ChgHost;
|
||||||
|
impl Command for ChgHost {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"CHGHOST"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if !valid_host(¶ms[1]) {
|
||||||
|
onotice(s, uid, "*** CHGHOST: invalid characters in hostname");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(t) = oper_target(s, uid, ¶ms[0]) else {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
s.change_host_ident(t, None, Some(¶ms[1]));
|
||||||
|
let by = oper_nick(s, uid);
|
||||||
|
s.snotice(&format!(
|
||||||
|
"{by} used CHGHOST on {}: {}",
|
||||||
|
params[0], params[1]
|
||||||
|
));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SETHOST — change your own displayed host.
|
||||||
|
struct SetHost;
|
||||||
|
impl Command for SetHost {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SETHOST"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if !valid_host(¶ms[0]) {
|
||||||
|
onotice(s, uid, "*** SETHOST: invalid characters in hostname");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
s.change_host_ident(uid, None, Some(¶ms[0]));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CHGIDENT — change another user's ident/username.
|
||||||
|
struct ChgIdent;
|
||||||
|
impl Command for ChgIdent {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"CHGIDENT"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if !valid_ident(¶ms[1]) {
|
||||||
|
onotice(s, uid, "*** CHGIDENT: invalid characters in ident");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(t) = oper_target(s, uid, ¶ms[0]) else {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
s.change_host_ident(t, Some(¶ms[1]), None);
|
||||||
|
let by = oper_nick(s, uid);
|
||||||
|
s.snotice(&format!(
|
||||||
|
"{by} used CHGIDENT on {}: {}",
|
||||||
|
params[0], params[1]
|
||||||
|
));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SETIDENT — change your own ident/username.
|
||||||
|
struct SetIdent;
|
||||||
|
impl Command for SetIdent {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SETIDENT"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if !valid_ident(¶ms[0]) {
|
||||||
|
onotice(s, uid, "*** SETIDENT: invalid characters in ident");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
s.change_host_ident(uid, Some(¶ms[0]), None);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SAMODE — apply a channel MODE as the server, bypassing the rank ladder.
|
||||||
|
struct SaMode;
|
||||||
|
impl Command for SaMode {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SAMODE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
s.mode_sudo = true;
|
||||||
|
let r = apply_mode(s, uid, params);
|
||||||
|
s.mode_sudo = false;
|
||||||
|
let by = oper_nick(s, uid);
|
||||||
|
s.snotice(&format!("{by} used SAMODE: {}", params.join(" ")));
|
||||||
|
r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SATOPIC — set a channel topic as the server, bypassing +t / op checks.
|
||||||
|
struct SaTopic;
|
||||||
|
impl Command for SaTopic {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SATOPIC"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let chan = ¶ms[0];
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
if !s.channels.contains_key(&key) {
|
||||||
|
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let text = params[1].clone();
|
||||||
|
let (prefix, setter) = {
|
||||||
|
let u = &s.users[&uid];
|
||||||
|
(u.prefix(), u.nick.clone())
|
||||||
|
};
|
||||||
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
|
ch.topic = Some(Topic {
|
||||||
|
text: text.clone(),
|
||||||
|
setter,
|
||||||
|
ts: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
s.to_channel(&key, &format!(":{prefix} TOPIC {chan} :{text}"), None);
|
||||||
|
s.propagate_from_user(uid, &format!("TOPIC {chan} :{text}"));
|
||||||
|
let by = oper_nick(s, uid);
|
||||||
|
s.snotice(&format!("{by} used SATOPIC on {chan}"));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SAKICK — kick a user as the server, bypassing rank checks.
|
||||||
|
struct SaKick;
|
||||||
|
impl Command for SaKick {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SAKICK"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
2
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !require_oper(s, uid) {
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let (chan, victim) = (¶ms[0], ¶ms[1]);
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
if !s.channels.contains_key(&key) {
|
||||||
|
s.numeric(uid, ERR_NOSUCHCHANNEL, &format!("{chan} :No such channel"));
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let Some(tuid) = s.find_nick(victim) else {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NOSUCHNICK,
|
||||||
|
&format!("{victim} :No such nick/channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
if !s.channels[&key].members.contains_key(&tuid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_USERNOTINCHANNEL,
|
||||||
|
&format!("{victim} {chan} :They aren't on that channel"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let reason = params
|
||||||
|
.get(2)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Kicked by services".to_string());
|
||||||
|
let prefix = s.users[&uid].prefix();
|
||||||
|
s.to_channel(
|
||||||
|
&key,
|
||||||
|
&format!(":{prefix} KICK {chan} {victim} :{reason}"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
s.propagate_from_user(uid, &format!("KICK {chan} {victim} :{reason}"));
|
||||||
|
if let Some(ch) = s.channels.get_mut(&key) {
|
||||||
|
ch.members.remove(&tuid);
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&tuid) {
|
||||||
|
u.channels.remove(&key);
|
||||||
|
}
|
||||||
|
s.channels.retain(|_, c| !c.is_empty());
|
||||||
|
s.events
|
||||||
|
.push_back(Hook::Part(tuid, key, "kicked".to_string()));
|
||||||
|
let by = oper_nick(s, uid);
|
||||||
|
s.snotice(&format!("{by} used SAKICK on {victim} in {chan}"));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
398
src/coremods/core_user.rs
Normal file
398
src/coremods/core_user.rs
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
//! core_user — the client registration & session commands: CAP, NICK, USER,
|
||||||
|
//! PING, PONG, QUIT.
|
||||||
|
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::users::{ident_of, valid_nick, Caps};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![
|
||||||
|
Box::new(Cap),
|
||||||
|
Box::new(Authenticate),
|
||||||
|
Box::new(Nick),
|
||||||
|
Box::new(UserCmd),
|
||||||
|
Box::new(Ping),
|
||||||
|
Box::new(Pong),
|
||||||
|
Box::new(Quit),
|
||||||
|
Box::new(Away),
|
||||||
|
Box::new(SetName),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Away;
|
||||||
|
impl Command for Away {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"AWAY"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let msg = params.first().cloned().filter(|m| !m.is_empty());
|
||||||
|
let now_away = msg.is_some();
|
||||||
|
let prefix = match s.users.get_mut(&uid) {
|
||||||
|
Some(u) => {
|
||||||
|
u.flags.away = msg.clone();
|
||||||
|
u.prefix()
|
||||||
|
}
|
||||||
|
None => return CmdResult::Fail,
|
||||||
|
};
|
||||||
|
// away-notify: tell capable peers we went away / came back
|
||||||
|
let line = match &msg {
|
||||||
|
Some(m) => format!(":{prefix} AWAY :{m}"),
|
||||||
|
None => format!(":{prefix} AWAY"),
|
||||||
|
};
|
||||||
|
s.notify_peers(uid, &line, |c| c.away_notify);
|
||||||
|
if now_away {
|
||||||
|
s.numeric(uid, RPL_NOWAWAY, ":You have been marked as being away");
|
||||||
|
} else {
|
||||||
|
s.numeric(uid, RPL_UNAWAY, ":You are no longer marked as being away");
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Cap;
|
||||||
|
impl Command for Cap {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"CAP"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn before_reg(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let who = cap_target(s, uid);
|
||||||
|
match params[0].to_ascii_uppercase().as_str() {
|
||||||
|
"LS" => {
|
||||||
|
let cap302 = params.get(1).map(|v| v == "302").unwrap_or(false);
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.cap = true; // hold registration until CAP END
|
||||||
|
u.cap_302 |= cap302;
|
||||||
|
}
|
||||||
|
s.send(
|
||||||
|
uid,
|
||||||
|
format!(":{} CAP {who} LS :{}", s.name, Caps::ls_line(cap302)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
"REQ" => {
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.cap = true;
|
||||||
|
}
|
||||||
|
let req = params.get(1).cloned().unwrap_or_default();
|
||||||
|
let wanted: Vec<(&str, bool)> = req
|
||||||
|
.split_whitespace()
|
||||||
|
.map(|t| match t.strip_prefix('-') {
|
||||||
|
Some(rest) => (rest, false),
|
||||||
|
None => (t, true),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
// CAP REQ is atomic: ACK the whole set or NAK the whole set
|
||||||
|
if !wanted.is_empty() && wanted.iter().all(|(n, _)| Caps::is_known(n)) {
|
||||||
|
for (name, on) in &wanted {
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.caps.set(name, *on);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(uid, format!(":{} CAP {who} ACK :{req}", s.name));
|
||||||
|
} else {
|
||||||
|
s.send(uid, format!(":{} CAP {who} NAK :{req}", s.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"LIST" => {
|
||||||
|
let list = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.caps.enabled())
|
||||||
|
.unwrap_or_default();
|
||||||
|
s.send(uid, format!(":{} CAP {who} LIST :{list}", s.name));
|
||||||
|
}
|
||||||
|
"END" => {
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.cap = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// CAP reply target: the nick, or `*` before one is set.
|
||||||
|
fn cap_target(s: &Server, uid: Uid) -> String {
|
||||||
|
s.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| {
|
||||||
|
if u.nick.is_empty() {
|
||||||
|
"*".to_string()
|
||||||
|
} else {
|
||||||
|
u.nick.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "*".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AUTHENTICATE — the SASL handshake. echoIRCd verifies nothing itself (it has no
|
||||||
|
/// accounts); once a services server is linked over S2S the payload is relayed to
|
||||||
|
/// it and `set_login` applied on success. Until then — exactly like InspIRCd with
|
||||||
|
/// no services — SASL fails cleanly.
|
||||||
|
struct Authenticate;
|
||||||
|
impl Command for Authenticate {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"AUTHENTICATE"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn before_reg(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if !s.users.get(&uid).map(|u| u.caps.sasl).unwrap_or(false) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_SASLFAIL,
|
||||||
|
":You must request the sasl capability first",
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let arg = ¶ms[0];
|
||||||
|
let mech = s.users.get(&uid).and_then(|u| u.sasl_mech.clone());
|
||||||
|
match mech {
|
||||||
|
// step 1 — the client picks a mechanism
|
||||||
|
None => {
|
||||||
|
if arg.eq_ignore_ascii_case("PLAIN") {
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.sasl_mech = Some("PLAIN".to_string());
|
||||||
|
}
|
||||||
|
s.send(uid, "AUTHENTICATE +".to_string());
|
||||||
|
CmdResult::Ok
|
||||||
|
} else if arg == "*" {
|
||||||
|
s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
|
||||||
|
CmdResult::Ok
|
||||||
|
} else {
|
||||||
|
s.numeric(uid, RPL_SASLMECHS, "PLAIN :are available SASL mechanisms");
|
||||||
|
s.numeric(uid, ERR_SASLFAIL, ":Unsupported SASL mechanism");
|
||||||
|
CmdResult::Fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// step 2 — the client sends the base64 payload (or aborts with `*`)
|
||||||
|
Some(_) => {
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.sasl_mech = None;
|
||||||
|
}
|
||||||
|
if arg == "*" {
|
||||||
|
s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
if arg.len() > 400 {
|
||||||
|
s.numeric(uid, ERR_SASLTOOLONG, ":SASL message too long");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// base64(authzid \0 authcid \0 passwd) — would be relayed to services
|
||||||
|
let _creds = openssl::base64::decode_block(arg).unwrap_or_default();
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_SASLFAIL,
|
||||||
|
":SASL authentication failed (services are not available)",
|
||||||
|
);
|
||||||
|
CmdResult::Fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SETNAME — change your realname (IRCv3). Broadcast to `setname`-capable peers.
|
||||||
|
struct SetName;
|
||||||
|
impl Command for SetName {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SETNAME"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let realname = params[0].clone();
|
||||||
|
let prefix = match s.users.get_mut(&uid) {
|
||||||
|
Some(u) => {
|
||||||
|
u.realname = realname.clone();
|
||||||
|
u.prefix()
|
||||||
|
}
|
||||||
|
None => return CmdResult::Fail,
|
||||||
|
};
|
||||||
|
let line = format!(":{prefix} SETNAME :{realname}");
|
||||||
|
if s.users.get(&uid).map(|u| u.caps.setname).unwrap_or(false) {
|
||||||
|
s.send(uid, line.clone());
|
||||||
|
}
|
||||||
|
s.notify_peers(uid, &line, |c| c.setname);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Nick;
|
||||||
|
impl Command for Nick {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"NICK"
|
||||||
|
}
|
||||||
|
fn before_reg(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let Some(newnick) = params.first() else {
|
||||||
|
s.numeric(uid, ERR_NONICKNAMEGIVEN, ":No nickname given");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
};
|
||||||
|
if !valid_nick(newnick) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_ERRONEUSNICKNAME,
|
||||||
|
&format!("{newnick} :Erroneous nickname"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if let Some(other) = s.find_nick(newnick) {
|
||||||
|
if other != uid {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NICKNAMEINUSE,
|
||||||
|
&format!("{newnick} :Nickname is already in use"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
return CmdResult::Ok; // same nick, no-op
|
||||||
|
}
|
||||||
|
// a nick already held by a user on a linked server is taken too
|
||||||
|
if s.remote_nick.contains_key(&newnick.to_ascii_lowercase()) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NICKNAMEINUSE,
|
||||||
|
&format!("{newnick} :Nickname is already in use"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// +N — can't change nick while on a no-nick-change channel (opers bypass)
|
||||||
|
if !s.is_oper(uid) {
|
||||||
|
let blocked = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.channels.clone())
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.find_map(|k| {
|
||||||
|
s.channels
|
||||||
|
.get(k)
|
||||||
|
.filter(|c| c.modes.no_nick)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
});
|
||||||
|
if let Some(cn) = blocked {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANTCHANGENICK,
|
||||||
|
&format!("{cn} :Cannot change nick while on this channel (+N is set)"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// extban `n:` — a matched user can't change nick on that channel
|
||||||
|
let chans = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.channels.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(k) = chans.into_iter().find(|k| s.extban_active(uid, k, 'n')) {
|
||||||
|
let cn = s.channels.get(&k).map(|c| c.name.clone()).unwrap_or(k);
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANTCHANGENICK,
|
||||||
|
&format!("{cn} :Cannot change nick here (+b n:)"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
// +F nick-change flood — locks nick changes on the channel for 60s
|
||||||
|
if let Some(cn) = s.nickflood_blocked(uid) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_CANTCHANGENICK,
|
||||||
|
&format!("{cn} :Too many nick changes, try later (+F is set)"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.set_nick(uid, newnick);
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserCmd;
|
||||||
|
impl Command for UserCmd {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"USER"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
4
|
||||||
|
}
|
||||||
|
fn before_reg(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if s.users.get(&uid).map(|u| u.registered).unwrap_or(false) {
|
||||||
|
s.numeric(uid, ERR_ALREADYREGISTERED, ":You may not reregister");
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
let ident = ident_of(¶ms[0]);
|
||||||
|
let realname = params[3].clone();
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.ident = format!("~{ident}");
|
||||||
|
u.realname = realname;
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Ping;
|
||||||
|
impl Command for Ping {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"PING"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn before_reg(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
s.send(uid, format!(":{} PONG {} :{}", s.name, s.name, params[0]));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Pong;
|
||||||
|
impl Command for Pong {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"PONG"
|
||||||
|
}
|
||||||
|
fn before_reg(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn handle(&self, _s: &mut Server, _uid: Uid, _params: &[String]) -> CmdResult {
|
||||||
|
CmdResult::Ok // keepalive; nothing to do yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Quit;
|
||||||
|
impl Command for Quit {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"QUIT"
|
||||||
|
}
|
||||||
|
fn before_reg(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let reason = params
|
||||||
|
.first()
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| "Client quit".to_string());
|
||||||
|
s.mark_quit(uid, format!("Quit: {reason}"));
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
306
src/coremods/core_watch.rs
Normal file
306
src/coremods/core_watch.rs
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
//! core_watch — WATCH, MONITOR (IRCv3) and SILENCE. The per-user lists live on
|
||||||
|
//! the `User`; the online/offline notifications are driven from the lifecycle
|
||||||
|
//! code via [`crate::server::Server::watch_notify_online`] / `_offline`. Mirrors
|
||||||
|
//! InspIRCd's `m_watch` / `m_monitor` / `m_silence`.
|
||||||
|
|
||||||
|
use crate::channels::normalize_mask;
|
||||||
|
use crate::command::{CmdResult, Command};
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::watch::{MONITOR_MAX, SILENCE_MAX, WATCH_MAX};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub fn commands() -> Vec<Box<dyn Command>> {
|
||||||
|
vec![Box::new(Watch), Box::new(Monitor), Box::new(Silence)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- WATCH ------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Report a nick's current presence as RPL_NOWON (604) or RPL_NOWOFF (605).
|
||||||
|
fn watch_status(s: &Server, uid: Uid, nick: &str) {
|
||||||
|
if let Some(u) = s.find_nick(nick).and_then(|tu| s.users.get(&tu)) {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_NOWON,
|
||||||
|
&format!(
|
||||||
|
"{} {} {} {} :is online",
|
||||||
|
u.nick,
|
||||||
|
u.ident,
|
||||||
|
u.host_display(),
|
||||||
|
u.signon
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
s.numeric(uid, RPL_NOWOFF, &format!("{nick} * * 0 :is offline"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watch_add(s: &mut Server, uid: Uid, nick: &str) {
|
||||||
|
if nick.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let low = nick.to_ascii_lowercase();
|
||||||
|
let full = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.watch.len() >= WATCH_MAX && !u.watch.contains(&low))
|
||||||
|
.unwrap_or(true);
|
||||||
|
if full {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_TOOMANYWATCH,
|
||||||
|
&format!("{nick} :Maximum size for WATCH-list exceeded"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
if !u.watch.contains(&low) {
|
||||||
|
u.watch.push(low);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch_status(s, uid, nick);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn watch_list(s: &Server, uid: Uid, online_only: bool) {
|
||||||
|
let nicks = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.watch.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for n in nicks {
|
||||||
|
// `l` (online-only) skips offline entries; `L` shows all
|
||||||
|
if !online_only || s.find_nick(&n).is_some() {
|
||||||
|
watch_status(s, uid, &n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_ENDOFWATCHLIST, ":End of WATCH list");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Watch;
|
||||||
|
impl Command for Watch {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"WATCH"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
if params.is_empty() {
|
||||||
|
watch_list(s, uid, true); // bare WATCH lists your online entries
|
||||||
|
return CmdResult::Ok;
|
||||||
|
}
|
||||||
|
for tok in params.iter().flat_map(|p| p.split_whitespace()) {
|
||||||
|
match tok {
|
||||||
|
"C" | "c" => {
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.watch.clear();
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_ENDOFWATCHLIST, ":End of WATCH list");
|
||||||
|
}
|
||||||
|
"S" | "s" => {
|
||||||
|
let (mine, watched) = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| (u.watch.len(), u.watch.clone()))
|
||||||
|
.unwrap_or((0, Vec::new()));
|
||||||
|
let me = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let on_me = s.watchers_of(&me);
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WATCHSTAT,
|
||||||
|
&format!(":You have {mine} and are on {on_me} WATCH entries"),
|
||||||
|
);
|
||||||
|
if !watched.is_empty() {
|
||||||
|
s.numeric(uid, RPL_WATCHLIST, &format!(":{}", watched.join(" ")));
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_ENDOFWATCHLIST, ":End of WATCH S");
|
||||||
|
}
|
||||||
|
"L" => watch_list(s, uid, false),
|
||||||
|
"l" => watch_list(s, uid, true),
|
||||||
|
_ if tok.starts_with('+') => watch_add(s, uid, &tok[1..]),
|
||||||
|
_ if tok.starts_with('-') => {
|
||||||
|
let low = tok[1..].to_ascii_lowercase();
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.watch.retain(|n| n != &low);
|
||||||
|
}
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WATCHOFF,
|
||||||
|
&format!("{} * * 0 :stopped watching", &tok[1..]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- MONITOR (IRCv3) --------------------------------------------------------
|
||||||
|
|
||||||
|
/// Report the online/offline split of `nicks` to `uid` (730 / 731).
|
||||||
|
fn monitor_report(s: &Server, uid: Uid, nicks: &[String]) {
|
||||||
|
let mut online = Vec::new();
|
||||||
|
let mut offline = Vec::new();
|
||||||
|
for n in nicks {
|
||||||
|
match s.find_nick(n).and_then(|tu| s.users.get(&tu)) {
|
||||||
|
Some(u) => online.push(format!("{}!{}@{}", u.nick, u.ident, u.host_display())),
|
||||||
|
None => offline.push(n.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !online.is_empty() {
|
||||||
|
s.numeric(uid, RPL_MONONLINE, &format!(":{}", online.join(",")));
|
||||||
|
}
|
||||||
|
if !offline.is_empty() {
|
||||||
|
s.numeric(uid, RPL_MONOFFLINE, &format!(":{}", offline.join(",")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Monitor;
|
||||||
|
impl Command for Monitor {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"MONITOR"
|
||||||
|
}
|
||||||
|
fn min_params(&self) -> usize {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
match params[0].to_ascii_uppercase().as_str() {
|
||||||
|
"+" => {
|
||||||
|
let targets: Vec<String> = params
|
||||||
|
.get(1)
|
||||||
|
.map(|t| {
|
||||||
|
t.split(',')
|
||||||
|
.filter(|x| !x.is_empty())
|
||||||
|
.map(String::from)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut added = Vec::new();
|
||||||
|
for t in targets {
|
||||||
|
let low = t.to_ascii_lowercase();
|
||||||
|
let full = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.monitor.len() >= MONITOR_MAX && !u.monitor.contains(&low))
|
||||||
|
.unwrap_or(true);
|
||||||
|
if full {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_MONLISTFULL,
|
||||||
|
&format!("{MONITOR_MAX} {t} :Monitor list is full"),
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
if !u.monitor.contains(&low) {
|
||||||
|
u.monitor.push(low);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
added.push(t);
|
||||||
|
}
|
||||||
|
monitor_report(s, uid, &added);
|
||||||
|
}
|
||||||
|
"-" => {
|
||||||
|
let targets: Vec<String> = params
|
||||||
|
.get(1)
|
||||||
|
.map(|t| {
|
||||||
|
t.split(',')
|
||||||
|
.filter(|x| !x.is_empty())
|
||||||
|
.map(|x| x.to_ascii_lowercase())
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.monitor.retain(|n| !targets.contains(n));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"C" => {
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.monitor.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"L" => {
|
||||||
|
let list = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.monitor.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !list.is_empty() {
|
||||||
|
s.numeric(uid, RPL_MONLIST, &format!(":{}", list.join(",")));
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_ENDOFMONLIST, ":End of MONITOR list");
|
||||||
|
}
|
||||||
|
"S" => {
|
||||||
|
let list = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.monitor.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
monitor_report(s, uid, &list);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- SILENCE ----------------------------------------------------------------
|
||||||
|
|
||||||
|
fn silence_list(s: &Server, uid: Uid) {
|
||||||
|
let list = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.silence.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
for m in list {
|
||||||
|
s.numeric(uid, RPL_SILELIST, &m);
|
||||||
|
}
|
||||||
|
s.numeric(uid, RPL_ENDOFSILENCE, ":End of SILENCE list");
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Silence;
|
||||||
|
impl Command for Silence {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"SILENCE"
|
||||||
|
}
|
||||||
|
fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult {
|
||||||
|
let Some(arg) = params.first() else {
|
||||||
|
silence_list(s, uid);
|
||||||
|
return CmdResult::Ok;
|
||||||
|
};
|
||||||
|
let prefix = s.users.get(&uid).map(|u| u.prefix()).unwrap_or_default();
|
||||||
|
if let Some(m) = arg.strip_prefix('+') {
|
||||||
|
let mask = normalize_mask(m);
|
||||||
|
let full = s
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.silence.len() >= SILENCE_MAX && !u.silence.contains(&mask))
|
||||||
|
.unwrap_or(true);
|
||||||
|
if full {
|
||||||
|
s.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_SILELISTFULL,
|
||||||
|
&format!("{mask} :Your SILENCE list is full"),
|
||||||
|
);
|
||||||
|
return CmdResult::Fail;
|
||||||
|
}
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
if !u.silence.contains(&mask) {
|
||||||
|
u.silence.push(mask.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.send(uid, format!(":{prefix} SILENCE +{mask}"));
|
||||||
|
} else if let Some(m) = arg.strip_prefix('-') {
|
||||||
|
let mask = normalize_mask(m);
|
||||||
|
if let Some(u) = s.users.get_mut(&uid) {
|
||||||
|
u.silence.retain(|x| x != &mask);
|
||||||
|
}
|
||||||
|
s.send(uid, format!(":{prefix} SILENCE -{mask}"));
|
||||||
|
} else {
|
||||||
|
silence_list(s, uid);
|
||||||
|
}
|
||||||
|
CmdResult::Ok
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/coremods/mod.rs
Normal file
34
src/coremods/mod.rs
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
//! The built-in commands, grouped the way InspIRCd groups its `coremods/`:
|
||||||
|
//! `core_user`, `core_channel`, `core_message`, `core_mode`, `core_oper`,
|
||||||
|
//! `core_info`. Each module exposes `commands()`; [`command_table`] assembles the
|
||||||
|
//! registry the core dispatches through.
|
||||||
|
|
||||||
|
pub mod core_channel;
|
||||||
|
pub mod core_extra;
|
||||||
|
pub mod core_info;
|
||||||
|
pub mod core_message;
|
||||||
|
pub mod core_mode;
|
||||||
|
pub mod core_oper;
|
||||||
|
pub mod core_user;
|
||||||
|
pub mod core_watch;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use crate::command::Command;
|
||||||
|
|
||||||
|
pub fn command_table() -> HashMap<&'static str, Box<dyn Command>> {
|
||||||
|
let mut m: HashMap<&'static str, Box<dyn Command>> = HashMap::new();
|
||||||
|
for c in core_user::commands()
|
||||||
|
.into_iter()
|
||||||
|
.chain(core_channel::commands())
|
||||||
|
.chain(core_message::commands())
|
||||||
|
.chain(core_mode::commands())
|
||||||
|
.chain(core_oper::commands())
|
||||||
|
.chain(core_info::commands())
|
||||||
|
.chain(core_extra::commands())
|
||||||
|
.chain(core_watch::commands())
|
||||||
|
{
|
||||||
|
m.insert(c.name(), c);
|
||||||
|
}
|
||||||
|
m
|
||||||
|
}
|
||||||
79
src/extensible.rs
Normal file
79
src/extensible.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
//! Typed per-object metadata — echoIRCd's answer to InspIRCd's `Extensible` /
|
||||||
|
//! `ExtensionItem`.
|
||||||
|
//!
|
||||||
|
//! In C++ InspIRCd, a module attaches data to a user/channel through a `void*`
|
||||||
|
//! `ExtensionItem`: it registers the item, casts on every access, and must supply
|
||||||
|
//! a `free()` callback — a well-worn source of leaks, type-confusion and
|
||||||
|
//! use-after-free (the reason the core carries a whole "cull list").
|
||||||
|
//!
|
||||||
|
//! Here it's a `TypeId`-keyed typemap. A module stores its own concrete type and
|
||||||
|
//! gets it back type-checked; the value is owned by the object it hangs off, so
|
||||||
|
//! it's dropped automatically when that object is — no registry, no `unsafe`, no
|
||||||
|
//! manual free, no dangling data.
|
||||||
|
|
||||||
|
use std::any::{Any, TypeId};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Extensible {
|
||||||
|
map: HashMap<TypeId, Box<dyn Any + Send>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Extensible {
|
||||||
|
pub fn get<T: Any + Send>(&self) -> Option<&T> {
|
||||||
|
self.map
|
||||||
|
.get(&TypeId::of::<T>())
|
||||||
|
.and_then(|b| b.downcast_ref::<T>())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_mut<T: Any + Send>(&mut self) -> Option<&mut T> {
|
||||||
|
self.map
|
||||||
|
.get_mut(&TypeId::of::<T>())
|
||||||
|
.and_then(|b| b.downcast_mut::<T>())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set<T: Any + Send>(&mut self, value: T) {
|
||||||
|
self.map.insert(TypeId::of::<T>(), Box::new(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the stored `T`, inserting `f()`'s value first if it's not there yet.
|
||||||
|
pub fn get_or_insert_with<T: Any + Send>(&mut self, f: impl FnOnce() -> T) -> &mut T {
|
||||||
|
self.map
|
||||||
|
.entry(TypeId::of::<T>())
|
||||||
|
.or_insert_with(|| Box::new(f()))
|
||||||
|
.downcast_mut::<T>()
|
||||||
|
.expect("each TypeId keys exactly its own type")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take<T: Any + Send>(&mut self) -> Option<T> {
|
||||||
|
self.map
|
||||||
|
.remove(&TypeId::of::<T>())
|
||||||
|
.and_then(|b| b.downcast::<T>().ok())
|
||||||
|
.map(|b| *b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Default, PartialEq, Debug)]
|
||||||
|
struct A(u32);
|
||||||
|
struct B(&'static str);
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn typed_storage_is_isolated_and_recoverable() {
|
||||||
|
let mut e = Extensible::default();
|
||||||
|
assert!(e.get::<A>().is_none());
|
||||||
|
e.set(A(7));
|
||||||
|
e.set(B("hi"));
|
||||||
|
// two different types coexist, each recovered as itself
|
||||||
|
assert_eq!(e.get::<A>(), Some(&A(7)));
|
||||||
|
assert_eq!(e.get::<B>().map(|b| b.0), Some("hi"));
|
||||||
|
e.get_mut::<A>().unwrap().0 += 1;
|
||||||
|
assert_eq!(e.get::<A>(), Some(&A(8)));
|
||||||
|
assert_eq!(e.take::<A>(), Some(A(8)));
|
||||||
|
assert!(e.get::<A>().is_none());
|
||||||
|
assert_eq!(*e.get_or_insert_with(|| A(100)), A(100));
|
||||||
|
}
|
||||||
|
}
|
||||||
277
src/ircd.rs
Normal file
277
src/ircd.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
||||||
|
//! The core: owns the [`Server`] state, the command table and the module list,
|
||||||
|
//! and turns a stream of [`Event`]s into IRC. Everything here runs on one
|
||||||
|
//! thread, so no state is ever locked.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::{SocketAddr, TcpStream};
|
||||||
|
use std::sync::mpsc::{Receiver, Sender};
|
||||||
|
|
||||||
|
use crate::command::Command;
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::coremods::command_table;
|
||||||
|
use crate::message;
|
||||||
|
use crate::module::{Hook, ModResult, Module};
|
||||||
|
use crate::numeric::{ERR_NEEDMOREPARAMS, ERR_NOTREGISTERED, ERR_UNKNOWNCOMMAND};
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// What the I/O threads hand to the core.
|
||||||
|
pub enum Event {
|
||||||
|
Connect {
|
||||||
|
uid: Uid,
|
||||||
|
addr: SocketAddr,
|
||||||
|
out: Sender<String>,
|
||||||
|
sock: TcpStream,
|
||||||
|
secure: bool,
|
||||||
|
link: bool, // a server-to-server connection, not a client
|
||||||
|
outbound: bool, // (link) we dialed them
|
||||||
|
},
|
||||||
|
Line {
|
||||||
|
uid: Uid,
|
||||||
|
line: String,
|
||||||
|
},
|
||||||
|
Disconnect {
|
||||||
|
uid: Uid,
|
||||||
|
},
|
||||||
|
/// Background timer tick — drives ping/idle timeouts.
|
||||||
|
Tick,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Ircd {
|
||||||
|
server: Server,
|
||||||
|
commands: HashMap<&'static str, Box<dyn Command>>,
|
||||||
|
modules: Vec<Box<dyn Module>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Ircd {
|
||||||
|
pub fn new(cfg: Config) -> Ircd {
|
||||||
|
Ircd {
|
||||||
|
server: Server::new(cfg),
|
||||||
|
commands: command_table(),
|
||||||
|
modules: crate::modules::default_modules(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run until the event channel closes (i.e. the listener is gone).
|
||||||
|
pub fn run(mut self, rx: Receiver<Event>) {
|
||||||
|
for ev in rx {
|
||||||
|
match ev {
|
||||||
|
Event::Connect {
|
||||||
|
uid,
|
||||||
|
addr,
|
||||||
|
out,
|
||||||
|
sock,
|
||||||
|
secure,
|
||||||
|
link,
|
||||||
|
outbound,
|
||||||
|
} => {
|
||||||
|
if link {
|
||||||
|
self.server.add_link(uid, addr, out, sock, outbound);
|
||||||
|
} else {
|
||||||
|
self.server.add_conn(uid, addr, out, sock, secure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Line { uid, line } => {
|
||||||
|
if self.server.links.contains_key(&uid) {
|
||||||
|
if let Some(msg) = message::parse(&line) {
|
||||||
|
self.server.on_link(uid, &msg);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.on_line(uid, &line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Disconnect { uid } => {
|
||||||
|
if self.server.links.contains_key(&uid) {
|
||||||
|
self.server.close_link(uid, "Connection closed");
|
||||||
|
} else {
|
||||||
|
self.quit_user(uid, "Connection closed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Event::Tick => self.on_tick(),
|
||||||
|
}
|
||||||
|
self.drain_hooks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_line(&mut self, uid: Uid, line: &str) {
|
||||||
|
let Some(msg) = message::parse(line) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
// stash this line's client-only tags for TAGMSG / PRIVMSG relay
|
||||||
|
self.server.line_ctags = msg.ctags.clone();
|
||||||
|
// any valid line means the connection is alive
|
||||||
|
if let Some(u) = self.server.users.get_mut(&uid) {
|
||||||
|
u.last_active = crate::server::now();
|
||||||
|
u.ping_sent = false;
|
||||||
|
}
|
||||||
|
let cmd = msg.command.as_str();
|
||||||
|
let registered = self
|
||||||
|
.server
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.registered)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// module pre-command gate
|
||||||
|
for m in &mut self.modules {
|
||||||
|
if m.on_pre_command(&mut self.server, uid, cmd, &msg.params) == ModResult::Deny {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// message pre-hook (PRIVMSG/NOTICE)
|
||||||
|
if matches!(cmd, "PRIVMSG" | "NOTICE") && msg.params.len() >= 2 {
|
||||||
|
let (target, text) = (msg.params[0].clone(), msg.params[1].clone());
|
||||||
|
for m in &mut self.modules {
|
||||||
|
if m.on_pre_message(&mut self.server, uid, &target, &text) == ModResult::Deny {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(handler) = self.commands.get(cmd) else {
|
||||||
|
if registered {
|
||||||
|
self.server
|
||||||
|
.numeric(uid, ERR_UNKNOWNCOMMAND, &format!("{cmd} :Unknown command"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !registered && !handler.before_reg() {
|
||||||
|
self.server
|
||||||
|
.numeric(uid, ERR_NOTREGISTERED, ":You have not registered");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if msg.params.len() < handler.min_params() {
|
||||||
|
self.server.numeric(
|
||||||
|
uid,
|
||||||
|
ERR_NEEDMOREPARAMS,
|
||||||
|
&format!("{cmd} :Not enough parameters"),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _ = handler.handle(&mut self.server, uid, &msg.params);
|
||||||
|
|
||||||
|
for m in &mut self.modules {
|
||||||
|
m.on_post_command(&mut self.server, uid, cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// a command may have asked to quit (QUIT)
|
||||||
|
if let Some(reason) = self.server.take_quit(uid) {
|
||||||
|
self.quit_user(uid, &reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// …or completed the registration handshake
|
||||||
|
if !registered {
|
||||||
|
let ready = self
|
||||||
|
.server
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| !u.registered && !u.nick.is_empty() && !u.ident.is_empty() && !u.cap)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if ready {
|
||||||
|
self.complete_registration(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn complete_registration(&mut self, uid: Uid) {
|
||||||
|
for m in &mut self.modules {
|
||||||
|
if m.on_user_register(&mut self.server, uid) == ModResult::Deny {
|
||||||
|
self.server.send(
|
||||||
|
uid,
|
||||||
|
"ERROR :Closing link (registration refused)".to_string(),
|
||||||
|
);
|
||||||
|
self.server.remove_user(uid, "Registration refused");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// x-line: refuse a banned host / ip before welcoming
|
||||||
|
let (ident, host, ip) = {
|
||||||
|
let u = &self.server.users[&uid];
|
||||||
|
(u.ident.clone(), u.host.clone(), u.addr.ip().to_string())
|
||||||
|
};
|
||||||
|
if let Some(reason) = self.server.matched_xline(&ident, &host, &ip) {
|
||||||
|
self.server
|
||||||
|
.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||||
|
self.server.remove_user(uid, &reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.server.welcome(uid);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quit_user(&mut self, uid: Uid, reason: &str) {
|
||||||
|
if !self.server.users.contains_key(&uid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let registered = self.server.users[&uid].registered;
|
||||||
|
if registered {
|
||||||
|
// fire the quit hook while the user still exists
|
||||||
|
for m in &mut self.modules {
|
||||||
|
m.on_user_quit(&mut self.server, uid, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.server.remove_user(uid, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Background timer: PING idle clients, reap the unresponsive and the
|
||||||
|
/// never-registered.
|
||||||
|
fn on_tick(&mut self) {
|
||||||
|
self.server.ping_links(); // keepalive on every server link
|
||||||
|
self.server.purge_xlines(); // drop expired server bans
|
||||||
|
let now = crate::server::now();
|
||||||
|
let (to_ping, to_quit) = self.server.idle_check(now);
|
||||||
|
for uid in to_ping {
|
||||||
|
let token = self.server.name.clone();
|
||||||
|
self.server.send(uid, format!("PING :{token}"));
|
||||||
|
if let Some(u) = self.server.users.get_mut(&uid) {
|
||||||
|
u.ping_sent = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for uid in to_quit {
|
||||||
|
let reg = self
|
||||||
|
.server
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.registered)
|
||||||
|
.unwrap_or(false);
|
||||||
|
let reason = if reg {
|
||||||
|
"Ping timeout"
|
||||||
|
} else {
|
||||||
|
"Registration timeout"
|
||||||
|
};
|
||||||
|
self.server
|
||||||
|
.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||||
|
self.quit_user(uid, reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fire queued notify-hooks. Draining a queue (not iterating in place) lets a
|
||||||
|
/// hook enqueue more work (e.g. a module forcing a join) without surprises.
|
||||||
|
fn drain_hooks(&mut self) {
|
||||||
|
while let Some(hook) = self.server.events.pop_front() {
|
||||||
|
match hook {
|
||||||
|
Hook::Connect(uid) => {
|
||||||
|
for m in &mut self.modules {
|
||||||
|
m.on_user_connect(&mut self.server, uid);
|
||||||
|
}
|
||||||
|
// burst to links after modules (so the cloak is already set)
|
||||||
|
self.server.introduce_to_links(uid);
|
||||||
|
}
|
||||||
|
Hook::Join(uid, chan) => {
|
||||||
|
for m in &mut self.modules {
|
||||||
|
m.on_join(&mut self.server, uid, &chan);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Hook::Part(uid, chan, reason) => {
|
||||||
|
for m in &mut self.modules {
|
||||||
|
m.on_part(&mut self.server, uid, &chan, &reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Hook::Quit(uid, reason) => {
|
||||||
|
for m in &mut self.modules {
|
||||||
|
m.on_user_quit(&mut self.server, uid, &reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/lib.rs
Normal file
33
src/lib.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
//! echoIRCd — a small, dependency-light IRC daemon, laid out like InspIRCd:
|
||||||
|
//!
|
||||||
|
//! - **engine** — `server` (the core + state), `users`, `channels`, `message`,
|
||||||
|
//! `numeric`, `config`.
|
||||||
|
//! - **`coremods`** — the built-in commands, grouped the way InspIRCd groups its
|
||||||
|
//! `coremods/` (core_user, core_channel, core_message, core_mode, core_info).
|
||||||
|
//! - **`modules`** — optional, pluggable behaviour via lifecycle hooks.
|
||||||
|
//! - **`socketengine`** — the I/O edge (accept + per-connection threads).
|
||||||
|
//! - **`ircd`** — the single-threaded core loop that ties it together.
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
/// A local connection id. (Server linking will later need real UUIDs.)
|
||||||
|
pub type Uid = u64;
|
||||||
|
|
||||||
|
pub mod accounts;
|
||||||
|
pub mod channels;
|
||||||
|
pub mod command;
|
||||||
|
pub mod config;
|
||||||
|
pub mod coremods;
|
||||||
|
pub mod extensible;
|
||||||
|
pub mod ircd;
|
||||||
|
pub mod link;
|
||||||
|
pub mod message;
|
||||||
|
pub mod mode;
|
||||||
|
pub mod module;
|
||||||
|
pub mod modules;
|
||||||
|
pub mod numeric;
|
||||||
|
pub mod server;
|
||||||
|
pub mod socketengine;
|
||||||
|
pub mod tls;
|
||||||
|
pub mod users;
|
||||||
|
pub mod watch;
|
||||||
|
pub mod xline;
|
||||||
996
src/link.rs
Normal file
996
src/link.rs
Normal file
|
|
@ -0,0 +1,996 @@
|
||||||
|
//! Server-to-server linking — echoIRCd's answer to InspIRCd's `m_spanningtree`.
|
||||||
|
//!
|
||||||
|
//! A link connection is a first-class peer, *not* a client `User`: it lives in
|
||||||
|
//! `Server.links` and is driven by [`Server::on_link`] instead of the client
|
||||||
|
//! command table. What's here:
|
||||||
|
//! * **handshake** — `SERVER <name> <pass> <sid> :<desc>` (shared per-block key),
|
||||||
|
//! then `BURST`/`ENDBURST`; a registry of linked servers (`Server.servers`).
|
||||||
|
//! * **users** — local users get network **UIDs**; on link-up they're burst as
|
||||||
|
//! `UID`, and NICK/QUIT propagate; remote users live in `remote_users`.
|
||||||
|
//! * **channels** — JOIN/PART/TOPIC/KICK/MODE (incl. ban/except/invex lists)
|
||||||
|
//! propagate; every server tracks a channel's full membership (`Channel.rmembers`,
|
||||||
|
//! with prefix modes), modes and bans; channel messages fan out **one copy per
|
||||||
|
//! link** (not per remote member), forwarded on but the origin; `FJOIN` bursts
|
||||||
|
//! channels (members + bans) on link-up.
|
||||||
|
//! * **collisions** — a nick already on the network is refused; an incoming `UID`
|
||||||
|
//! that clashes with a local user kills the local (both sides ⇒ both vanish).
|
||||||
|
//! * **netsplit** — dropping a link QUITs every user behind it.
|
||||||
|
//!
|
||||||
|
//! Toward full InspIRCd interop still: TS6 tie-breaking and the exact
|
||||||
|
//! CAPAB/FJOIN/metadata wire format. Also: SASL relays here once a services links in.
|
||||||
|
|
||||||
|
use std::net::{SocketAddr, TcpStream};
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use crate::channels::{Ban, Channel, Member, Topic};
|
||||||
|
use crate::message::Message;
|
||||||
|
use crate::server::{now, Server};
|
||||||
|
use crate::users::User;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// A local server-link connection (one hop away). Distinct from a client `User`.
|
||||||
|
pub struct Link {
|
||||||
|
pub uid: Uid,
|
||||||
|
pub out: Sender<String>,
|
||||||
|
pub outbound: bool, // we dialed them (so we introduce ourselves first)
|
||||||
|
pub registered: bool, // handshake complete
|
||||||
|
pub sent_server: bool, // we've sent our own SERVER line
|
||||||
|
pub sid: Option<String>,
|
||||||
|
pub name: Option<String>,
|
||||||
|
pub bursting: bool, // between the peer's BURST and ENDBURST
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server known on the network, for LINKS / MAP / routing.
|
||||||
|
pub struct RemoteServer {
|
||||||
|
pub sid: String,
|
||||||
|
pub name: String,
|
||||||
|
pub desc: String,
|
||||||
|
pub via: Uid, // the local link uid it is reachable through
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A user living on another server, reached via a link — not a local `User`.
|
||||||
|
pub struct RemoteUser {
|
||||||
|
pub uuid: String,
|
||||||
|
pub nick: String,
|
||||||
|
pub ident: String,
|
||||||
|
pub host: String,
|
||||||
|
pub realname: String,
|
||||||
|
pub account: Option<String>,
|
||||||
|
pub sid: String, // origin server id
|
||||||
|
pub via: Uid, // local link uid it is reached through
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RemoteUser {
|
||||||
|
pub fn prefix(&self) -> String {
|
||||||
|
format!("{}!{}@{}", self.nick, self.ident, self.host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A valid 3-char SID: digit, then two upper-case alphanumerics (InspIRCd's rule).
|
||||||
|
pub fn valid_sid(s: &str) -> bool {
|
||||||
|
let b = s.as_bytes();
|
||||||
|
b.len() == 3
|
||||||
|
&& b[0].is_ascii_digit()
|
||||||
|
&& b.iter()
|
||||||
|
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// Mint the next network-wide UID for a local user: our SID + 6 base-26 chars
|
||||||
|
/// (InspIRCd-style, e.g. `0AAAAAAAB`).
|
||||||
|
pub fn next_uuid(&mut self) -> String {
|
||||||
|
let mut x = self.uuid_counter;
|
||||||
|
self.uuid_counter += 1;
|
||||||
|
let mut suffix = [b'A'; 6];
|
||||||
|
for c in suffix.iter_mut().rev() {
|
||||||
|
*c = b'A' + (x % 26) as u8;
|
||||||
|
x /= 26;
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"{}{}",
|
||||||
|
self.sid,
|
||||||
|
std::str::from_utf8(&suffix).unwrap_or("AAAAAA")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register a new server-link connection. An **outbound** link introduces
|
||||||
|
/// itself right away with our `SERVER` line (using the dialled block's key).
|
||||||
|
pub fn add_link(
|
||||||
|
&mut self,
|
||||||
|
uid: Uid,
|
||||||
|
addr: SocketAddr,
|
||||||
|
out: Sender<String>,
|
||||||
|
_sock: TcpStream, // held by the reader/writer threads; closed gracefully
|
||||||
|
outbound: bool,
|
||||||
|
) {
|
||||||
|
let mut sent_server = false;
|
||||||
|
if outbound {
|
||||||
|
let pass = self
|
||||||
|
.link_blocks
|
||||||
|
.iter()
|
||||||
|
.find(|b| b.ip == addr.ip().to_string())
|
||||||
|
.map(|b| b.password.clone());
|
||||||
|
if let Some(pass) = pass {
|
||||||
|
let _ = out.send(format!(
|
||||||
|
"SERVER {} {} {} :{}",
|
||||||
|
self.name, pass, self.sid, self.server_desc
|
||||||
|
));
|
||||||
|
sent_server = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.links.insert(
|
||||||
|
uid,
|
||||||
|
Link {
|
||||||
|
uid,
|
||||||
|
out,
|
||||||
|
outbound,
|
||||||
|
registered: false,
|
||||||
|
sent_server,
|
||||||
|
sid: None,
|
||||||
|
name: None,
|
||||||
|
bursting: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_out(&self, uid: Uid, line: String) {
|
||||||
|
if let Some(l) = self.links.get(&uid) {
|
||||||
|
let _ = l.out.send(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch one parsed S2S line from link `uid`.
|
||||||
|
pub fn on_link(&mut self, uid: Uid, msg: &Message) {
|
||||||
|
let registered = self.links.get(&uid).map(|l| l.registered).unwrap_or(false);
|
||||||
|
match msg.command.as_str() {
|
||||||
|
"SERVER" if !registered => self.link_server(uid, msg),
|
||||||
|
"PING" if registered => {
|
||||||
|
let token = msg.params.first().cloned().unwrap_or_default();
|
||||||
|
self.link_out(uid, format!("PONG :{token}"));
|
||||||
|
}
|
||||||
|
"UID" if registered => self.link_uid_recv(uid, msg),
|
||||||
|
"NICK" if registered => self.link_nick_recv(uid, msg),
|
||||||
|
"QUIT" if registered => self.link_quit_recv(uid, msg),
|
||||||
|
"PRIVMSG" if registered => self.link_message_recv(uid, msg, false),
|
||||||
|
"NOTICE" if registered => self.link_message_recv(uid, msg, true),
|
||||||
|
"JOIN" if registered => self.link_join_recv(uid, msg),
|
||||||
|
"PART" if registered => self.link_part_recv(uid, msg),
|
||||||
|
"TOPIC" if registered => self.link_topic_recv(uid, msg),
|
||||||
|
"KICK" if registered => self.link_kick_recv(uid, msg),
|
||||||
|
"MODE" | "FMODE" if registered => self.link_mode_recv(uid, msg),
|
||||||
|
"FJOIN" if registered => self.link_fjoin_recv(uid, msg),
|
||||||
|
"BURST" => {
|
||||||
|
if let Some(l) = self.links.get_mut(&uid) {
|
||||||
|
l.bursting = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"ENDBURST" => {
|
||||||
|
if let Some(l) = self.links.get_mut(&uid) {
|
||||||
|
l.bursting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"SQUIT" => self.close_link(uid, "SQUIT"),
|
||||||
|
"ERROR" => {
|
||||||
|
eprintln!("[link] {uid} ERROR: {}", msg.params.join(" "));
|
||||||
|
self.close_link(uid, "peer error");
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle the `SERVER <name> <password> <sid> :<desc>` handshake line.
|
||||||
|
fn link_server(&mut self, uid: Uid, msg: &Message) {
|
||||||
|
if msg.params.len() < 4 {
|
||||||
|
self.reject_link(uid, "Not enough SERVER parameters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (name, pass, sid, desc) = (
|
||||||
|
msg.params[0].clone(),
|
||||||
|
msg.params[1].clone(),
|
||||||
|
msg.params[2].clone(),
|
||||||
|
msg.params[3].clone(),
|
||||||
|
);
|
||||||
|
let Some(block) = self.link_blocks.iter().find(|b| b.name == name).cloned() else {
|
||||||
|
self.reject_link(uid, "No link block for that server name");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if block.password != pass {
|
||||||
|
self.reject_link(uid, "Invalid link password");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !valid_sid(&sid) || sid == self.sid || self.servers.contains_key(&sid) {
|
||||||
|
self.reject_link(uid, "Bad or already-present SID");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let already_sent = self.links.get(&uid).map(|l| l.sent_server).unwrap_or(false);
|
||||||
|
if let Some(l) = self.links.get_mut(&uid) {
|
||||||
|
l.registered = true;
|
||||||
|
l.sid = Some(sid.clone());
|
||||||
|
l.name = Some(name.clone());
|
||||||
|
}
|
||||||
|
self.servers.insert(
|
||||||
|
sid.clone(),
|
||||||
|
RemoteServer {
|
||||||
|
sid: sid.clone(),
|
||||||
|
name: name.clone(),
|
||||||
|
desc: desc.clone(),
|
||||||
|
via: uid,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// if we accepted (inbound) we still owe them our SERVER line
|
||||||
|
if !already_sent {
|
||||||
|
self.link_out(
|
||||||
|
uid,
|
||||||
|
format!(
|
||||||
|
"SERVER {} {} {} :{}",
|
||||||
|
self.name, block.password, self.sid, self.server_desc
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if let Some(l) = self.links.get_mut(&uid) {
|
||||||
|
l.sent_server = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// netburst: introduce our local users (channels/FJOIN are phase 2b)
|
||||||
|
self.link_out(uid, format!("BURST {}", now()));
|
||||||
|
self.burst_users(uid);
|
||||||
|
self.burst_channels(uid);
|
||||||
|
self.link_out(uid, "ENDBURST".to_string());
|
||||||
|
eprintln!("[link] linked {name} ({sid}) — {desc}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reject_link(&mut self, uid: Uid, why: &str) {
|
||||||
|
self.link_out(uid, format!("ERROR :Link denied: {why}"));
|
||||||
|
eprintln!("[link] rejected {uid}: {why}");
|
||||||
|
self.close_link(uid, why);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop a link and every server reachable through it (a netsplit). We don't
|
||||||
|
/// force the socket shut: dropping the `Link` drops its `out` sender, so the
|
||||||
|
/// writer thread first flushes any queued line (e.g. an `ERROR`) and *then*
|
||||||
|
/// closes the socket — otherwise a rejection races its own disconnect.
|
||||||
|
pub fn close_link(&mut self, uid: Uid, reason: &str) {
|
||||||
|
let mut peer = String::new();
|
||||||
|
if let Some(l) = self.links.remove(&uid) {
|
||||||
|
peer = l.name.clone().unwrap_or_default();
|
||||||
|
if let Some(sid) = l.sid {
|
||||||
|
eprintln!("[link] netsplit {peer} ({sid}): {reason}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.servers.retain(|_, s| s.via != uid);
|
||||||
|
// every remote user reached through this link is now gone (netsplit) —
|
||||||
|
// drop them from channels and QUIT them to any local channel-mates.
|
||||||
|
let netreason = format!("{} {peer}", self.name);
|
||||||
|
let gone: Vec<String> = self
|
||||||
|
.remote_users
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, ru)| ru.via == uid)
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
.collect();
|
||||||
|
for uuid in &gone {
|
||||||
|
self.drop_remote_user(uuid, &netreason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Periodic keepalive: PING every registered link.
|
||||||
|
pub fn ping_links(&self) {
|
||||||
|
let token = self.sid.clone();
|
||||||
|
let uids: Vec<Uid> = self
|
||||||
|
.links
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, l)| l.registered)
|
||||||
|
.map(|(&u, _)| u)
|
||||||
|
.collect();
|
||||||
|
for u in uids {
|
||||||
|
self.link_out(u, format!("PING :{token}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relay `line` to every registered link except `except` (the origin).
|
||||||
|
pub fn propagate(&self, line: &str, except: Option<Uid>) {
|
||||||
|
let targets: Vec<Uid> = self
|
||||||
|
.links
|
||||||
|
.iter()
|
||||||
|
.filter(|(u, l)| l.registered && Some(**u) != except)
|
||||||
|
.map(|(&u, _)| u)
|
||||||
|
.collect();
|
||||||
|
for u in targets {
|
||||||
|
self.link_out(u, line.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `UID` introduction line for a local user.
|
||||||
|
fn uid_line(&self, u: &User) -> String {
|
||||||
|
let acct = u.account.clone().unwrap_or_else(|| "*".to_string());
|
||||||
|
format!(
|
||||||
|
":{} UID {} {} {} {} {} :{}",
|
||||||
|
self.sid,
|
||||||
|
u.uuid,
|
||||||
|
u.nick,
|
||||||
|
u.ident,
|
||||||
|
u.host_display(),
|
||||||
|
acct,
|
||||||
|
u.realname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Burst all local registered users to a freshly-linked peer.
|
||||||
|
fn burst_users(&self, link_uid: Uid) {
|
||||||
|
let lines: Vec<String> = self
|
||||||
|
.users
|
||||||
|
.values()
|
||||||
|
.filter(|u| u.registered)
|
||||||
|
.map(|u| self.uid_line(u))
|
||||||
|
.collect();
|
||||||
|
for l in lines {
|
||||||
|
self.link_out(link_uid, l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Announce a newly-registered local user to every link.
|
||||||
|
pub fn introduce_to_links(&self, uid: Uid) {
|
||||||
|
if self.links.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(u) = self.users.get(&uid) {
|
||||||
|
let line = self.uid_line(u);
|
||||||
|
self.propagate(&line, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Propagate a local user's nick change.
|
||||||
|
pub fn propagate_nick(&self, uid: Uid, newnick: &str) {
|
||||||
|
if let Some(u) = self.users.get(&uid) {
|
||||||
|
if u.registered && !self.links.is_empty() {
|
||||||
|
self.propagate(&format!(":{} NICK {newnick}", u.uuid), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find a remote user by nick: returns `(uuid, via-link)`.
|
||||||
|
pub fn find_remote(&self, nick: &str) -> Option<(String, Uid)> {
|
||||||
|
let uuid = self.remote_nick.get(&nick.to_ascii_lowercase())?;
|
||||||
|
let ru = self.remote_users.get(uuid)?;
|
||||||
|
Some((uuid.clone(), ru.via))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve any network uuid (remote or local) to a `nick!user@host` prefix.
|
||||||
|
pub fn uuid_prefix(&self, uuid: &str) -> Option<String> {
|
||||||
|
if let Some(ru) = self.remote_users.get(uuid) {
|
||||||
|
return Some(ru.prefix());
|
||||||
|
}
|
||||||
|
self.uuid_local
|
||||||
|
.get(uuid)
|
||||||
|
.and_then(|&uid| self.users.get(&uid))
|
||||||
|
.map(|u| u.prefix())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Route a message from a local sender to a remote user across its link.
|
||||||
|
pub fn send_to_remote(&self, sender: Uid, target_uuid: &str, via: Uid, cmd: &str, text: &str) {
|
||||||
|
if let Some(u) = self.users.get(&sender) {
|
||||||
|
self.link_out(via, format!(":{} {cmd} {target_uuid} :{text}", u.uuid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- inbound S2S records --------------------------------------------------
|
||||||
|
|
||||||
|
fn link_uid_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
// :<sid> UID <uuid> <nick> <ident> <host> <account> :<realname>
|
||||||
|
if msg.params.len() < 6 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let sid = msg.source.clone().unwrap_or_default();
|
||||||
|
let uuid = msg.params[0].clone();
|
||||||
|
let nick = msg.params[1].clone();
|
||||||
|
// nick collision: a local holder is killed (both sides do this, so both
|
||||||
|
// vanish deterministically); an existing remote holder simply wins.
|
||||||
|
if let Some(luid) = self.find_nick(&nick) {
|
||||||
|
self.send(luid, "ERROR :Closing link: Nick collision".to_string());
|
||||||
|
self.remove_user(luid, "Nick collision");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if self.remote_nick.contains_key(&nick.to_ascii_lowercase()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let account = if msg.params[4] == "*" {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(msg.params[4].clone())
|
||||||
|
};
|
||||||
|
self.remote_nick
|
||||||
|
.insert(nick.to_ascii_lowercase(), uuid.clone());
|
||||||
|
self.remote_users.insert(
|
||||||
|
uuid.clone(),
|
||||||
|
RemoteUser {
|
||||||
|
uuid,
|
||||||
|
nick,
|
||||||
|
ident: msg.params[2].clone(),
|
||||||
|
host: msg.params[3].clone(),
|
||||||
|
realname: msg.params[5].clone(),
|
||||||
|
account,
|
||||||
|
sid: sid.clone(),
|
||||||
|
via,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let line = format!(
|
||||||
|
":{sid} UID {} {} {} {} {} :{}",
|
||||||
|
msg.params[0],
|
||||||
|
msg.params[1],
|
||||||
|
msg.params[2],
|
||||||
|
msg.params[3],
|
||||||
|
msg.params[4],
|
||||||
|
msg.params[5]
|
||||||
|
);
|
||||||
|
self.propagate(&line, Some(via));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_nick_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
// :<uuid> NICK <newnick>
|
||||||
|
let Some(uuid) = msg.source.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(newnick) = msg.params.first().cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let old = match self.remote_users.get_mut(&uuid) {
|
||||||
|
Some(ru) => {
|
||||||
|
let old = ru.nick.clone();
|
||||||
|
ru.nick = newnick.clone();
|
||||||
|
old
|
||||||
|
}
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
self.remote_nick.remove(&old.to_ascii_lowercase());
|
||||||
|
self.remote_nick
|
||||||
|
.insert(newnick.to_ascii_lowercase(), uuid.clone());
|
||||||
|
self.propagate(&format!(":{uuid} NICK {newnick}"), Some(via));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_quit_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
// :<uuid> QUIT :<reason>
|
||||||
|
let Some(uuid) = msg.source.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let reason = msg.params.first().cloned().unwrap_or_default();
|
||||||
|
self.drop_remote_user(&uuid, &reason);
|
||||||
|
self.propagate(&format!(":{uuid} QUIT :{reason}"), Some(via));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_message_recv(&mut self, via: Uid, msg: &Message, notice: bool) {
|
||||||
|
// :<srcuuid> PRIVMSG <#chan|dstuuid> :<text>
|
||||||
|
let cmd = if notice { "NOTICE" } else { "PRIVMSG" };
|
||||||
|
let Some(src) = msg.source.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if msg.params.len() < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (target, text) = (msg.params[0].clone(), msg.params[1].clone());
|
||||||
|
let Some(prefix) = self.uuid_prefix(&src) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if target.starts_with('#') {
|
||||||
|
let key = target.to_ascii_lowercase();
|
||||||
|
if !self.channels.contains_key(&key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let line = format!(":{prefix} {cmd} {target} :{text}");
|
||||||
|
let members: Vec<Uid> = self.channels[&key].members.keys().copied().collect();
|
||||||
|
for m in members {
|
||||||
|
if self.users.get(&m).map(|u| u.flags.deaf).unwrap_or(false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self.send(m, line.clone());
|
||||||
|
}
|
||||||
|
// forward to the other links that have members in this channel
|
||||||
|
for l in self.channel_link_targets(&key, Some(via)) {
|
||||||
|
self.link_out(l, format!(":{src} {cmd} {target} :{text}"));
|
||||||
|
}
|
||||||
|
} else if let Some(&dst) = self.uuid_local.get(&target) {
|
||||||
|
let nick = self
|
||||||
|
.users
|
||||||
|
.get(&dst)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.send(dst, format!(":{prefix} {cmd} {nick} :{text}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell linked servers a local user joined a channel.
|
||||||
|
pub fn propagate_join(&self, uid: Uid, chan: &str) {
|
||||||
|
if self.links.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(u) = self.users.get(&uid) {
|
||||||
|
if u.registered {
|
||||||
|
self.propagate(&format!(":{} JOIN {chan}", u.uuid), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell linked servers a local user parted a channel.
|
||||||
|
pub fn propagate_part(&self, uid: Uid, chan: &str, reason: &str) {
|
||||||
|
if self.links.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(u) = self.users.get(&uid) {
|
||||||
|
if u.registered {
|
||||||
|
let line = if reason.is_empty() {
|
||||||
|
format!(":{} PART {chan}", u.uuid)
|
||||||
|
} else {
|
||||||
|
format!(":{} PART {chan} :{reason}", u.uuid)
|
||||||
|
};
|
||||||
|
self.propagate(&line, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The distinct links a channel's remote members sit behind (minus `except`).
|
||||||
|
fn channel_link_targets(&self, key: &str, except: Option<Uid>) -> Vec<Uid> {
|
||||||
|
let mut set: HashSet<Uid> = HashSet::new();
|
||||||
|
if let Some(ch) = self.channels.get(key) {
|
||||||
|
for uuid in ch.rmembers.keys() {
|
||||||
|
if let Some(ru) = self.remote_users.get(uuid) {
|
||||||
|
if Some(ru.via) != except {
|
||||||
|
set.insert(ru.via);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
set.into_iter().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relay a local user's channel message to every link with members there.
|
||||||
|
pub fn send_channel_to_links(
|
||||||
|
&self,
|
||||||
|
sender: Uid,
|
||||||
|
key: &str,
|
||||||
|
target: &str,
|
||||||
|
cmd: &str,
|
||||||
|
text: &str,
|
||||||
|
) {
|
||||||
|
let Some(uuid) = self.users.get(&sender).map(|u| u.uuid.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let line = format!(":{uuid} {cmd} {target} :{text}");
|
||||||
|
for l in self.channel_link_targets(key, None) {
|
||||||
|
self.link_out(l, line.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_join_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
// :<uuid> JOIN #chan
|
||||||
|
let Some(uuid) = msg.source.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(chan) = msg.params.first().cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !self.remote_users.contains_key(&uuid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
self.channels
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_insert_with(|| Channel::new(&chan))
|
||||||
|
.rmembers
|
||||||
|
.insert(uuid.clone(), Member::default());
|
||||||
|
let prefix = self
|
||||||
|
.remote_users
|
||||||
|
.get(&uuid)
|
||||||
|
.map(|r| r.prefix())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.to_channel(&key, &format!(":{prefix} JOIN {chan}"), None);
|
||||||
|
self.propagate(&format!(":{uuid} JOIN {chan}"), Some(via));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_part_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
// :<uuid> PART #chan [:reason]
|
||||||
|
let Some(uuid) = msg.source.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(chan) = msg.params.first().cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let reason = msg.params.get(1).cloned().unwrap_or_default();
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
let removed = self
|
||||||
|
.channels
|
||||||
|
.get_mut(&key)
|
||||||
|
.map(|c| c.rmembers.remove(&uuid).is_some())
|
||||||
|
.unwrap_or(false);
|
||||||
|
if !removed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let prefix = self
|
||||||
|
.remote_users
|
||||||
|
.get(&uuid)
|
||||||
|
.map(|r| r.prefix())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let line = if reason.is_empty() {
|
||||||
|
format!(":{prefix} PART {chan}")
|
||||||
|
} else {
|
||||||
|
format!(":{prefix} PART {chan} :{reason}")
|
||||||
|
};
|
||||||
|
self.to_channel(&key, &line, None);
|
||||||
|
self.channels.retain(|_, c| !c.is_empty());
|
||||||
|
let fwd = if reason.is_empty() {
|
||||||
|
format!(":{uuid} PART {chan}")
|
||||||
|
} else {
|
||||||
|
format!(":{uuid} PART {chan} :{reason}")
|
||||||
|
};
|
||||||
|
self.propagate(&fwd, Some(via));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a remote user everywhere (channels + registries) and QUIT them to
|
||||||
|
/// any local users who shared a channel.
|
||||||
|
fn drop_remote_user(&mut self, uuid: &str, reason: &str) {
|
||||||
|
let prefix = match self.remote_users.get(uuid) {
|
||||||
|
Some(ru) => ru.prefix(),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let mut notify: HashSet<Uid> = HashSet::new();
|
||||||
|
let chans: Vec<String> = self
|
||||||
|
.channels
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, c)| c.rmembers.contains_key(uuid))
|
||||||
|
.map(|(k, _)| k.clone())
|
||||||
|
.collect();
|
||||||
|
for key in &chans {
|
||||||
|
if let Some(c) = self.channels.get_mut(key) {
|
||||||
|
c.rmembers.remove(uuid);
|
||||||
|
for &m in c.members.keys() {
|
||||||
|
notify.insert(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let line = format!(":{prefix} QUIT :{reason}");
|
||||||
|
for m in notify {
|
||||||
|
self.send(m, line.clone());
|
||||||
|
}
|
||||||
|
self.channels.retain(|_, c| !c.is_empty());
|
||||||
|
if let Some(ru) = self.remote_users.remove(uuid) {
|
||||||
|
self.remote_nick.remove(&ru.nick.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Relay `:<sender-uuid> <rest>` to every link (MODE/TOPIC/KICK propagation).
|
||||||
|
pub fn propagate_from_user(&self, uid: Uid, rest: &str) {
|
||||||
|
if self.links.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(u) = self.users.get(&uid) {
|
||||||
|
if u.registered {
|
||||||
|
self.propagate(&format!(":{} {rest}", u.uuid), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a prefix mode on `nick` in `key`, be they a local or remote member.
|
||||||
|
fn set_member_prefix(&mut self, key: &str, nick: &str, letter: char, adding: bool) {
|
||||||
|
if let Some(uid) = self.find_nick(nick) {
|
||||||
|
if let Some(m) = self
|
||||||
|
.channels
|
||||||
|
.get_mut(key)
|
||||||
|
.and_then(|c| c.members.get_mut(&uid))
|
||||||
|
{
|
||||||
|
m.set_prefix(letter, adding);
|
||||||
|
}
|
||||||
|
} else if let Some((uuid, _)) = self.find_remote(nick) {
|
||||||
|
if let Some(m) = self
|
||||||
|
.channels
|
||||||
|
.get_mut(key)
|
||||||
|
.and_then(|c| c.rmembers.get_mut(&uuid))
|
||||||
|
{
|
||||||
|
m.set_prefix(letter, adding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_topic_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
// :<uuid> TOPIC #chan :<text>
|
||||||
|
let Some(src) = msg.source.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if msg.params.len() < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let chan = msg.params[0].clone();
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
let text = msg.params[1].clone();
|
||||||
|
if !self.channels.contains_key(&key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let setter = self
|
||||||
|
.remote_users
|
||||||
|
.get(&src)
|
||||||
|
.map(|r| r.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if let Some(c) = self.channels.get_mut(&key) {
|
||||||
|
c.topic = Some(Topic {
|
||||||
|
text: text.clone(),
|
||||||
|
setter,
|
||||||
|
ts: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let prefix = self.uuid_prefix(&src).unwrap_or_default();
|
||||||
|
self.to_channel(&key, &format!(":{prefix} TOPIC {chan} :{text}"), None);
|
||||||
|
self.propagate(&format!(":{src} TOPIC {chan} :{text}"), Some(via));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_kick_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
// :<kicker-uuid> KICK #chan <victim-nick> :<reason>
|
||||||
|
let Some(src) = msg.source.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if msg.params.len() < 2 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let chan = msg.params[0].clone();
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
let victim = msg.params[1].clone();
|
||||||
|
let reason = msg.params.get(2).cloned().unwrap_or_else(|| victim.clone());
|
||||||
|
let prefix = self.uuid_prefix(&src).unwrap_or_default();
|
||||||
|
let mut removed = false;
|
||||||
|
if let Some(vuid) = self.find_nick(&victim) {
|
||||||
|
if let Some(c) = self.channels.get_mut(&key) {
|
||||||
|
removed = c.members.remove(&vuid).is_some();
|
||||||
|
}
|
||||||
|
if removed {
|
||||||
|
if let Some(u) = self.users.get_mut(&vuid) {
|
||||||
|
u.channels.remove(&key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some((vuuid, _)) = self.find_remote(&victim) {
|
||||||
|
if let Some(c) = self.channels.get_mut(&key) {
|
||||||
|
removed = c.rmembers.remove(&vuuid).is_some();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !removed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.to_channel(
|
||||||
|
&key,
|
||||||
|
&format!(":{prefix} KICK {chan} {victim} :{reason}"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
self.channels.retain(|_, c| !c.is_empty());
|
||||||
|
self.propagate(&format!(":{src} KICK {chan} {victim} :{reason}"), Some(via));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_mode_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
// :<uuid> MODE #chan <modestring> [params...] (applied without re-checking)
|
||||||
|
let Some(src) = msg.source.clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if msg.params.len() < 2 || !msg.params[0].starts_with('#') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let chan = msg.params[0].clone();
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
if !self.channels.contains_key(&key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let modestring = msg.params[1].clone();
|
||||||
|
let args: Vec<String> = msg.params[2..].to_vec();
|
||||||
|
let mut argi = 0usize;
|
||||||
|
let mut sign = '+';
|
||||||
|
for c in modestring.chars() {
|
||||||
|
if c == '+' || c == '-' {
|
||||||
|
sign = c;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let adding = sign == '+';
|
||||||
|
match c {
|
||||||
|
'q' | 'a' | 'o' | 'h' | 'v' => {
|
||||||
|
if let Some(n) = args.get(argi).cloned() {
|
||||||
|
argi += 1;
|
||||||
|
self.set_member_prefix(&key, &n, c, adding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'k' => {
|
||||||
|
let p = args.get(argi).cloned();
|
||||||
|
if p.is_some() {
|
||||||
|
argi += 1;
|
||||||
|
}
|
||||||
|
if let Some(ch) = self.channels.get_mut(&key) {
|
||||||
|
ch.modes.key = if adding { p } else { None };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'l' => {
|
||||||
|
if adding {
|
||||||
|
if let Some(n) = args.get(argi).and_then(|s| s.parse::<u32>().ok()) {
|
||||||
|
argi += 1;
|
||||||
|
if let Some(ch) = self.channels.get_mut(&key) {
|
||||||
|
ch.modes.limit = Some(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let Some(ch) = self.channels.get_mut(&key) {
|
||||||
|
ch.modes.limit = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
'b' | 'e' | 'I' => {
|
||||||
|
if let Some(mask) = args.get(argi).cloned() {
|
||||||
|
argi += 1;
|
||||||
|
let setter = self
|
||||||
|
.remote_users
|
||||||
|
.get(&src)
|
||||||
|
.map(|r| r.nick.clone())
|
||||||
|
.unwrap_or_else(|| src.clone());
|
||||||
|
if let Some(ch) = self.channels.get_mut(&key) {
|
||||||
|
let list = match c {
|
||||||
|
'b' => &mut ch.bans,
|
||||||
|
'e' => &mut ch.excepts,
|
||||||
|
_ => &mut ch.invex,
|
||||||
|
};
|
||||||
|
if adding {
|
||||||
|
if !list.iter().any(|b| b.mask == mask) {
|
||||||
|
list.push(Ban {
|
||||||
|
mask,
|
||||||
|
setter,
|
||||||
|
ts: now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
list.retain(|b| b.mask != mask);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
if let Some(ch) = self.channels.get_mut(&key) {
|
||||||
|
ch.modes.set_by_letter(c, adding);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// source is a user (uuid) or, for a burst, a server (sid)
|
||||||
|
let prefix = self
|
||||||
|
.uuid_prefix(&src)
|
||||||
|
.or_else(|| self.servers.get(&src).map(|s| s.name.clone()))
|
||||||
|
.unwrap_or_else(|| self.name.clone());
|
||||||
|
let paramstr = if args.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" {}", args.join(" "))
|
||||||
|
};
|
||||||
|
self.to_channel(
|
||||||
|
&key,
|
||||||
|
&format!(":{prefix} MODE {chan} {modestring}{paramstr}"),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
self.propagate(
|
||||||
|
&format!(":{src} MODE {chan} {modestring}{paramstr}"),
|
||||||
|
Some(via),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Burst every channel (name, ts, modes, prefixed members) to a new peer.
|
||||||
|
fn burst_channels(&self, link_uid: Uid) {
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
for ch in self.channels.values() {
|
||||||
|
let mut mem: Vec<String> = Vec::new();
|
||||||
|
for (uid, m) in &ch.members {
|
||||||
|
if let Some(u) = self.users.get(uid) {
|
||||||
|
mem.push(format!("{}{}", m.all_prefixes(), u.uuid));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (uuid, m) in &ch.rmembers {
|
||||||
|
mem.push(format!("{}{}", m.all_prefixes(), uuid));
|
||||||
|
}
|
||||||
|
if mem.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
lines.push(format!(
|
||||||
|
":{} FJOIN {} {} {} :{}",
|
||||||
|
self.sid,
|
||||||
|
ch.name,
|
||||||
|
ch.created,
|
||||||
|
ch.modes.render(false),
|
||||||
|
mem.join(" ")
|
||||||
|
));
|
||||||
|
// burst the ban / except / invite-exception lists too
|
||||||
|
for (letter, list) in [('b', &ch.bans), ('e', &ch.excepts), ('I', &ch.invex)] {
|
||||||
|
for b in list {
|
||||||
|
lines.push(format!(
|
||||||
|
":{} MODE {} +{letter} {}",
|
||||||
|
self.sid, ch.name, b.mask
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for l in lines {
|
||||||
|
self.link_out(link_uid, l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn link_fjoin_recv(&mut self, via: Uid, msg: &Message) {
|
||||||
|
// :<sid> FJOIN #chan <ts> <modes> :<pfx>uuid <pfx>uuid ...
|
||||||
|
if msg.params.len() < 4 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let chan = msg.params[0].clone();
|
||||||
|
let key = chan.to_ascii_lowercase();
|
||||||
|
let ts: u64 = msg.params[1].parse().unwrap_or_else(|_| now());
|
||||||
|
let modes = msg.params[2].clone();
|
||||||
|
let memberlist = msg.params[3].clone();
|
||||||
|
let is_new = !self.channels.contains_key(&key);
|
||||||
|
{
|
||||||
|
let ch = self
|
||||||
|
.channels
|
||||||
|
.entry(key.clone())
|
||||||
|
.or_insert_with(|| Channel::new(&chan));
|
||||||
|
if is_new {
|
||||||
|
ch.created = ts;
|
||||||
|
let mut sign = '+';
|
||||||
|
for c in modes.chars() {
|
||||||
|
match c {
|
||||||
|
'+' => sign = '+',
|
||||||
|
'-' => sign = '-',
|
||||||
|
_ => ch.modes.set_by_letter(c, sign == '+'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut adds: Vec<(String, Member)> = Vec::new();
|
||||||
|
for tok in memberlist.split_whitespace() {
|
||||||
|
let (pfx, uuid) = split_member(tok);
|
||||||
|
if self.uuid_local.contains_key(&uuid) || !self.remote_users.contains_key(&uuid) {
|
||||||
|
continue; // our own user, or one we don't know yet
|
||||||
|
}
|
||||||
|
let mut m = Member::default();
|
||||||
|
for pc in pfx.chars() {
|
||||||
|
m.set_prefix(prefix_letter(pc), true);
|
||||||
|
}
|
||||||
|
adds.push((uuid, m));
|
||||||
|
}
|
||||||
|
if let Some(ch) = self.channels.get_mut(&key) {
|
||||||
|
for (uuid, m) in adds {
|
||||||
|
ch.rmembers.insert(uuid, m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let raw = format!(
|
||||||
|
":{} FJOIN {chan} {ts} {modes} :{memberlist}",
|
||||||
|
msg.source.clone().unwrap_or_default()
|
||||||
|
);
|
||||||
|
self.propagate(&raw, Some(via));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a bursted member token `@+0AAAAAAAB` into its prefix chars and uuid.
|
||||||
|
fn split_member(tok: &str) -> (String, String) {
|
||||||
|
let idx = tok
|
||||||
|
.find(|c: char| !"~&@%+".contains(c))
|
||||||
|
.unwrap_or(tok.len());
|
||||||
|
(tok[..idx].to_string(), tok[idx..].to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a prefix char to its mode letter (`@` → `o`, …).
|
||||||
|
fn prefix_letter(c: char) -> char {
|
||||||
|
match c {
|
||||||
|
'~' => 'q',
|
||||||
|
'&' => 'a',
|
||||||
|
'@' => 'o',
|
||||||
|
'%' => 'h',
|
||||||
|
'+' => 'v',
|
||||||
|
_ => ' ',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sid_validation() {
|
||||||
|
assert!(valid_sid("0AA"));
|
||||||
|
assert!(valid_sid("1Z9"));
|
||||||
|
assert!(valid_sid("9ZZ"));
|
||||||
|
assert!(!valid_sid("AAA")); // must start with a digit
|
||||||
|
assert!(!valid_sid("0a1")); // no lowercase
|
||||||
|
assert!(!valid_sid("0A")); // too short
|
||||||
|
assert!(!valid_sid("0ABC")); // too long
|
||||||
|
}
|
||||||
|
}
|
||||||
106
src/main.rs
Normal file
106
src/main.rs
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
//! echoIRCd entry point: read config, bind the plaintext (and, if configured,
|
||||||
|
//! the TLS) listener, then run the single-threaded core while the accept loops
|
||||||
|
//! feed it connections.
|
||||||
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use std::net::TcpListener;
|
||||||
|
use std::sync::atomic::AtomicU64;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
|
||||||
|
use echoircd::config::Config;
|
||||||
|
use echoircd::ircd::{Event, Ircd};
|
||||||
|
use echoircd::socketengine;
|
||||||
|
use echoircd::tls::{OpensslBackend, TlsBackend};
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let path = std::env::args()
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| "echoircd.conf".to_string());
|
||||||
|
let cfg = Config::load(&path);
|
||||||
|
|
||||||
|
let listener = match TcpListener::bind(&cfg.bind) {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("echoircd: cannot bind {}: {e}", cfg.bind);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
eprintln!(
|
||||||
|
"echoircd {} on {} (network {}, server {})",
|
||||||
|
env!("CARGO_PKG_VERSION"),
|
||||||
|
cfg.bind,
|
||||||
|
cfg.network,
|
||||||
|
cfg.servername
|
||||||
|
);
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let core_cfg = cfg.clone();
|
||||||
|
let core = thread::spawn(move || Ircd::new(core_cfg).run(rx));
|
||||||
|
|
||||||
|
// background timer: drives ping/idle timeouts
|
||||||
|
let tick_tx = tx.clone();
|
||||||
|
thread::spawn(move || loop {
|
||||||
|
thread::sleep(std::time::Duration::from_secs(echoircd::server::TICK_SECS));
|
||||||
|
if tick_tx.send(Event::Tick).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// one uid counter shared by every listener so ids stay unique
|
||||||
|
let counter = Arc::new(AtomicU64::new(1));
|
||||||
|
|
||||||
|
// optional TLS listener (bind_tls + tls_cert + tls_key). A cert/bind problem
|
||||||
|
// disables TLS but never takes the plaintext listener down.
|
||||||
|
if let (Some(bind_tls), Some(cert), Some(key)) = (&cfg.bind_tls, &cfg.tls_cert, &cfg.tls_key) {
|
||||||
|
match OpensslBackend::new(cert, key) {
|
||||||
|
Ok(backend) => match TcpListener::bind(bind_tls) {
|
||||||
|
Ok(tls_listener) => {
|
||||||
|
eprintln!("echoircd TLS on {bind_tls} (openssl)");
|
||||||
|
let backend: Arc<dyn TlsBackend> = Arc::new(backend);
|
||||||
|
let tls_tx = tx.clone();
|
||||||
|
let tls_counter = counter.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
socketengine::accept_loop(
|
||||||
|
tls_listener,
|
||||||
|
tls_tx,
|
||||||
|
Some(backend),
|
||||||
|
tls_counter,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("echoircd: cannot bind TLS {bind_tls}: {e}"),
|
||||||
|
},
|
||||||
|
Err(e) => eprintln!("echoircd: TLS disabled (cert/key error): {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// server-to-server link listener (see crate::link)
|
||||||
|
if let Some(bind_srv) = &cfg.bind_server {
|
||||||
|
match TcpListener::bind(bind_srv) {
|
||||||
|
Ok(sl) => {
|
||||||
|
eprintln!("echoircd S2S link listener on {bind_srv} (sid {})", cfg.sid);
|
||||||
|
let s_tx = tx.clone();
|
||||||
|
let s_counter = counter.clone();
|
||||||
|
thread::spawn(move || socketengine::accept_loop(sl, s_tx, None, s_counter, true));
|
||||||
|
}
|
||||||
|
Err(e) => eprintln!("echoircd: cannot bind server port {bind_srv}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dial any autoconnect uplinks (after a short delay so the peer can boot)
|
||||||
|
for block in cfg.links.iter().filter(|b| b.autoconnect) {
|
||||||
|
let addr = format!("{}:{}", block.ip, block.port);
|
||||||
|
let u_tx = tx.clone();
|
||||||
|
let u_counter = counter.clone();
|
||||||
|
thread::spawn(move || {
|
||||||
|
thread::sleep(std::time::Duration::from_secs(2));
|
||||||
|
socketengine::connect_link(&addr, u_tx, u_counter);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
socketengine::accept_loop(listener, tx, None, counter, false);
|
||||||
|
let _ = core.join();
|
||||||
|
}
|
||||||
124
src/message.rs
Normal file
124
src/message.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
//! IRC line parsing (RFC 1459 + IRCv3 message tags). One line in → an optional
|
||||||
|
//! [`Message`] out. Client-only tags (the `+`-prefixed ones) are captured so
|
||||||
|
//! TAGMSG / PRIVMSG can relay them onward; server tags from clients are dropped.
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq)]
|
||||||
|
pub struct Message {
|
||||||
|
/// The `:source` prefix, if any (clients rarely send one).
|
||||||
|
pub source: Option<String>,
|
||||||
|
/// Command, upper-cased (`PRIVMSG`, `JOIN`, …) or a 3-digit numeric.
|
||||||
|
pub command: String,
|
||||||
|
/// Parameters, with the trailing `:param` unwrapped into the last element.
|
||||||
|
pub params: Vec<String>,
|
||||||
|
/// Client-only IRCv3 tags (`+key=val;…`) re-serialised for relay; `""` if none.
|
||||||
|
pub ctags: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse one wire line. Returns `None` for an empty/garbage line.
|
||||||
|
pub fn parse(line: &str) -> Option<Message> {
|
||||||
|
let mut rest = line.trim_start();
|
||||||
|
|
||||||
|
// IRCv3 message tags — keep the client-only (`+`) tags for relay, drop the rest.
|
||||||
|
let mut ctags = String::new();
|
||||||
|
if let Some(after_at) = rest.strip_prefix('@') {
|
||||||
|
let (tags, r) = after_at.split_once(' ')?;
|
||||||
|
ctags = tags
|
||||||
|
.split(';')
|
||||||
|
.filter(|t| t.starts_with('+'))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(";");
|
||||||
|
rest = r.trim_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut source = None;
|
||||||
|
if let Some(after_colon) = rest.strip_prefix(':') {
|
||||||
|
let (src, r) = after_colon.split_once(' ')?;
|
||||||
|
source = Some(src.to_string());
|
||||||
|
rest = r.trim_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cmd, mut rest) = match rest.split_once(' ') {
|
||||||
|
Some((c, r)) => (c, r.trim_start()),
|
||||||
|
None => (rest, ""),
|
||||||
|
};
|
||||||
|
if cmd.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut params = Vec::new();
|
||||||
|
while !rest.is_empty() {
|
||||||
|
if let Some(trailing) = rest.strip_prefix(':') {
|
||||||
|
params.push(trailing.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match rest.split_once(' ') {
|
||||||
|
Some((p, r)) => {
|
||||||
|
params.push(p.to_string());
|
||||||
|
rest = r.trim_start();
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
params.push(rest.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Message {
|
||||||
|
source,
|
||||||
|
command: cmd.to_ascii_uppercase(),
|
||||||
|
params,
|
||||||
|
ctags,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn simple_command() {
|
||||||
|
let m = parse("NICK reverse").unwrap();
|
||||||
|
assert_eq!(m.command, "NICK");
|
||||||
|
assert_eq!(m.params, vec!["reverse"]);
|
||||||
|
assert!(m.source.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trailing_keeps_spaces() {
|
||||||
|
let m = parse("PRIVMSG #argentina :hola que tal").unwrap();
|
||||||
|
assert_eq!(m.command, "PRIVMSG");
|
||||||
|
assert_eq!(m.params, vec!["#argentina", "hola que tal"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn source_and_lowercase_command_upcased() {
|
||||||
|
let m = parse(":nick!u@h privmsg x :y").unwrap();
|
||||||
|
assert_eq!(m.source.as_deref(), Some("nick!u@h"));
|
||||||
|
assert_eq!(m.command, "PRIVMSG");
|
||||||
|
assert_eq!(m.params, vec!["x", "y"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tags_are_skipped() {
|
||||||
|
let m = parse("@id=1;time=x PING :token").unwrap();
|
||||||
|
assert_eq!(m.command, "PING");
|
||||||
|
assert_eq!(m.params, vec!["token"]);
|
||||||
|
assert_eq!(m.ctags, ""); // no client-only tags here
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn client_only_tags_kept_for_relay() {
|
||||||
|
let m = parse("@time=x;+typing=done;account=z TAGMSG #devs").unwrap();
|
||||||
|
assert_eq!(m.command, "TAGMSG");
|
||||||
|
assert_eq!(m.params, vec!["#devs"]);
|
||||||
|
assert_eq!(m.ctags, "+typing=done"); // server tags dropped, `+` kept
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_junk() {
|
||||||
|
assert!(parse("").is_none());
|
||||||
|
assert!(parse(" ").is_none());
|
||||||
|
// a lone colon prefix with nothing after is not a message
|
||||||
|
assert!(parse(":only").is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
1002
src/mode.rs
Normal file
1002
src/mode.rs
Normal file
File diff suppressed because it is too large
Load diff
69
src/module.rs
Normal file
69
src/module.rs
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
//! The module API — echoIRCd's answer to InspIRCd's `Module` class.
|
||||||
|
//!
|
||||||
|
//! Modules hook lifecycle events. "Pre" hooks return a [`ModResult`] and can
|
||||||
|
//! **deny** an action; "notify" hooks are informational. The core fires pre-hooks
|
||||||
|
//! inline (so a `Deny` actually blocks) and notify-hooks from a queue after the
|
||||||
|
//! triggering command finishes — so a handler can emit an event without ever
|
||||||
|
//! touching the module list. All hooks get `&mut Server`, so a module can act
|
||||||
|
//! (send lines, force a join, …), exactly like an InspIRCd module gets the
|
||||||
|
//! `ServerInstance`.
|
||||||
|
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// A pre-hook's verdict. `Passthru` = no opinion; `Allow` = force-allow (skip
|
||||||
|
/// remaining checks); `Deny` = block the action.
|
||||||
|
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
|
||||||
|
pub enum ModResult {
|
||||||
|
Passthru,
|
||||||
|
Allow,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A queued notify-event, drained by the core after each command.
|
||||||
|
pub enum Hook {
|
||||||
|
Connect(Uid),
|
||||||
|
Join(Uid, String),
|
||||||
|
Part(Uid, String, String),
|
||||||
|
Quit(Uid, String),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(unused_variables)]
|
||||||
|
pub trait Module: Send {
|
||||||
|
fn name(&self) -> &'static str;
|
||||||
|
|
||||||
|
// --- pre-hooks (can Deny) ------------------------------------------------
|
||||||
|
|
||||||
|
/// Last gate before a client finishes registration. `Deny` refuses the link.
|
||||||
|
fn on_user_register(&mut self, srv: &mut Server, uid: Uid) -> ModResult {
|
||||||
|
ModResult::Passthru
|
||||||
|
}
|
||||||
|
/// Before any command runs. `Deny` swallows the command silently.
|
||||||
|
fn on_pre_command(
|
||||||
|
&mut self,
|
||||||
|
srv: &mut Server,
|
||||||
|
uid: Uid,
|
||||||
|
cmd: &str,
|
||||||
|
params: &[String],
|
||||||
|
) -> ModResult {
|
||||||
|
ModResult::Passthru
|
||||||
|
}
|
||||||
|
/// Before a PRIVMSG/NOTICE is delivered. `Deny` drops it.
|
||||||
|
fn on_pre_message(
|
||||||
|
&mut self,
|
||||||
|
srv: &mut Server,
|
||||||
|
uid: Uid,
|
||||||
|
target: &str,
|
||||||
|
text: &str,
|
||||||
|
) -> ModResult {
|
||||||
|
ModResult::Passthru
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- notify-hooks --------------------------------------------------------
|
||||||
|
|
||||||
|
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {}
|
||||||
|
fn on_post_command(&mut self, srv: &mut Server, uid: Uid, cmd: &str) {}
|
||||||
|
fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {}
|
||||||
|
fn on_part(&mut self, srv: &mut Server, uid: Uid, chan: &str, reason: &str) {}
|
||||||
|
fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) {}
|
||||||
|
}
|
||||||
371
src/modules/antimixedutf8.rs
Normal file
371
src/modules/antimixedutf8.rs
Normal file
|
|
@ -0,0 +1,371 @@
|
||||||
|
//! antimixedutf8 — blocks spam that mixes Unicode scripts within words (Latin
|
||||||
|
//! letters swapped for Cyrillic/Greek look-alikes: "FᏒee Ⅴ1аgrа"), a very common
|
||||||
|
//! obfuscation. This is reverse's own detection model — the scoring rules and the
|
||||||
|
//! confusable / fancy-Latin / zero-width tables — implemented from scratch in
|
||||||
|
//! native Rust. (The *tables and weights* are the detector's spec: they define
|
||||||
|
//! what counts as spam. Everything around them is original echoIRCd code.)
|
||||||
|
//!
|
||||||
|
//! Per word: letters from more than one script score; so do words that are ASCII
|
||||||
|
//! mixed with Latin-confusable letters, words built almost entirely of confusables,
|
||||||
|
//! and "fancy" styled-Latin words. Zero-width chars score too. At/above the
|
||||||
|
//! configured threshold the action fires (block | kill | gline | kline | zline).
|
||||||
|
//!
|
||||||
|
//! Rust strings are valid UTF-8, so we walk codepoints straight from `chars()` and
|
||||||
|
//! fold the per-word state through a `Scorer` — idiomatic Rust, no manual UTF-8
|
||||||
|
//! decoding and no ref-capturing lambdas.
|
||||||
|
|
||||||
|
use crate::module::{ModResult, Module};
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::xline::XKind;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum Script {
|
||||||
|
Other = 0, // digits, punctuation, symbols — ignored for mixing
|
||||||
|
Latin,
|
||||||
|
Cyrillic,
|
||||||
|
Greek,
|
||||||
|
Armenian,
|
||||||
|
Hebrew,
|
||||||
|
Arabic,
|
||||||
|
Cjk,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map a codepoint to a script; `Other` for anything that isn't a letter we track.
|
||||||
|
fn classify_script(cp: u32) -> Script {
|
||||||
|
match cp {
|
||||||
|
0x41..=0x5A | 0x61..=0x7A => Script::Latin, // ASCII A-Z a-z
|
||||||
|
0x00C0..=0x024F => Script::Latin, // Latin-1 suppl + extended
|
||||||
|
0x0370..=0x03FF => Script::Greek,
|
||||||
|
0x0400..=0x04FF => Script::Cyrillic,
|
||||||
|
0x0530..=0x058F => Script::Armenian,
|
||||||
|
0x0590..=0x05FF => Script::Hebrew,
|
||||||
|
0x0600..=0x06FF => Script::Arabic,
|
||||||
|
0x4E00..=0x9FFF => Script::Cjk, // CJK unified
|
||||||
|
0x3040..=0x30FF => Script::Cjk, // hiragana / katakana
|
||||||
|
_ => Script::Other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A non-Latin letter that LOOKS like an ASCII Latin letter (the homoglyphs
|
||||||
|
/// spammers swap in). Catches pure-homoglyph words that script-mixing misses,
|
||||||
|
/// without tripping on genuine monolingual text.
|
||||||
|
fn is_latin_confusable(cp: u32) -> bool {
|
||||||
|
matches!(
|
||||||
|
cp,
|
||||||
|
// Cyrillic look-alikes
|
||||||
|
0x0430 | 0x0410 | 0x0435 | 0x0415 | 0x043E | 0x041E | 0x0440 | 0x0420 |
|
||||||
|
0x0441 | 0x0421 | 0x0443 | 0x0423 | 0x0445 | 0x0425 | 0x0456 | 0x0406 |
|
||||||
|
0x0455 | 0x0405 | 0x0458 | 0x0408 | 0x043A | 0x041A | 0x043C | 0x041C |
|
||||||
|
0x043D | 0x041D | 0x0432 | 0x0412 | 0x0442 | 0x0422 |
|
||||||
|
// Greek look-alikes
|
||||||
|
0x03BF | 0x039F | 0x03B1 | 0x0391 | 0x03B5 | 0x0395 | 0x03C1 | 0x03A1 |
|
||||||
|
0x03C5 | 0x03A5 | 0x03BD | 0x03BA | 0x039A | 0x03B9 | 0x0399 | 0x03BC |
|
||||||
|
0x0392 | 0x039D | 0x03A4 | 0x0397 | 0x03A7 | 0x0396
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "Fancy" Latin: fullwidth, mathematical alphanumerics, enclosed/circled letters.
|
||||||
|
/// These render as styled ASCII ("𝐅𝐫𝐞𝐞", "Free", "🅵🆁🅴🅴") — pure obfuscation.
|
||||||
|
fn is_fancy_latin(cp: u32) -> bool {
|
||||||
|
matches!(
|
||||||
|
cp,
|
||||||
|
0xFF21..=0xFF5A // fullwidth A-Z a-z
|
||||||
|
| 0x1D400..=0x1D7FF // mathematical alphanumeric symbols
|
||||||
|
| 0x1F130..=0x1F189 // squared/enclosed latin
|
||||||
|
| 0x24B6..=0x24E9 // circled latin
|
||||||
|
| 0x2460..=0x24FF // enclosed alphanumerics (loose)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Invisible / zero-width characters used to split words and evade filters.
|
||||||
|
fn is_invisible(cp: u32) -> bool {
|
||||||
|
matches!(
|
||||||
|
cp,
|
||||||
|
0x00AD | 0x200B | 0x200C | 0x200D | 0x2060 | 0xFEFF | 0x180E
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-message tally, folded word by word: the per-word scoring state as a struct.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct Scorer {
|
||||||
|
mixedwords: u32, // words mixing >1 real script
|
||||||
|
homoglyphwords: u32, // ASCII + confusable letters in one word
|
||||||
|
purehomowords: u32, // word made (almost) entirely of confusables
|
||||||
|
fancywords: u32, // words containing fancy/styled latin
|
||||||
|
invisibles: u32, // zero-width chars anywhere
|
||||||
|
totalletters: u32,
|
||||||
|
latinletters: u32,
|
||||||
|
wordhas: [bool; 8],
|
||||||
|
word_has_ascii: bool,
|
||||||
|
word_has_confusable: bool,
|
||||||
|
word_has_fancy: bool,
|
||||||
|
word_letters: u32,
|
||||||
|
word_confusables: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Scorer {
|
||||||
|
fn word_scripts(&self) -> u32 {
|
||||||
|
(1..8).filter(|&s| self.wordhas[s]).count() as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_word(&mut self) {
|
||||||
|
self.wordhas = [false; 8];
|
||||||
|
self.word_has_ascii = false;
|
||||||
|
self.word_has_confusable = false;
|
||||||
|
self.word_has_fancy = false;
|
||||||
|
self.word_letters = 0;
|
||||||
|
self.word_confusables = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn end_word(&mut self) {
|
||||||
|
// Confusable mixed WITH real ASCII in one word = the classic "swap a few
|
||||||
|
// letters" attack (already script-mixing) — count it ONCE here so a single
|
||||||
|
// stray homoglyph doesn't double-score.
|
||||||
|
if self.word_has_confusable && self.word_has_ascii {
|
||||||
|
self.homoglyphwords += 1;
|
||||||
|
} else if self.word_scripts() >= 2 {
|
||||||
|
self.mixedwords += 1;
|
||||||
|
} else if !self.word_has_ascii
|
||||||
|
&& self.word_scripts() == 1
|
||||||
|
&& self.word_letters >= 4
|
||||||
|
&& self.word_confusables * 100 / self.word_letters >= 80
|
||||||
|
{
|
||||||
|
// single-script word with no ASCII, ≥80% confusables = Latin in disguise
|
||||||
|
self.purehomowords += 1;
|
||||||
|
}
|
||||||
|
if self.word_has_fancy {
|
||||||
|
self.fancywords += 1;
|
||||||
|
}
|
||||||
|
self.reset_word();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Score a message for look-alike / obfuscated-text spam. Higher = worse; genuine
|
||||||
|
/// monolingual text (any script) stays at 0.
|
||||||
|
fn score_message(text: &str) -> u32 {
|
||||||
|
let mut sc = Scorer::default();
|
||||||
|
for cp in text.chars().map(|c| c as u32) {
|
||||||
|
if is_invisible(cp) {
|
||||||
|
sc.invisibles += 1;
|
||||||
|
continue; // not a word boundary
|
||||||
|
}
|
||||||
|
let fancy = is_fancy_latin(cp);
|
||||||
|
let confusable = is_latin_confusable(cp);
|
||||||
|
let script = classify_script(cp);
|
||||||
|
let isletter = script != Script::Other || fancy;
|
||||||
|
let isboundary = matches!(cp, 0x20 | 0x09 | 0x2C | 0x2E | 0x21 | 0x3F | 0xFFFD);
|
||||||
|
|
||||||
|
if isletter {
|
||||||
|
if script != Script::Other {
|
||||||
|
sc.wordhas[script as usize] = true;
|
||||||
|
}
|
||||||
|
sc.totalletters += 1;
|
||||||
|
sc.word_letters += 1;
|
||||||
|
if script == Script::Latin {
|
||||||
|
sc.latinletters += 1;
|
||||||
|
sc.word_has_ascii = true;
|
||||||
|
}
|
||||||
|
if confusable {
|
||||||
|
sc.word_has_confusable = true;
|
||||||
|
sc.word_confusables += 1;
|
||||||
|
}
|
||||||
|
if fancy {
|
||||||
|
sc.word_has_fancy = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isboundary {
|
||||||
|
sc.end_word();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sc.end_word(); // final word
|
||||||
|
|
||||||
|
// One disguised word is usually an accident (a pasted Cyrillic letter); real
|
||||||
|
// attacks disguise MANY. Grant a 1-word grace.
|
||||||
|
let disguised = sc.homoglyphwords + sc.mixedwords + sc.purehomowords + sc.fancywords;
|
||||||
|
let effective = disguised.saturating_sub(1);
|
||||||
|
|
||||||
|
let mut score = 0u32;
|
||||||
|
score += effective * 5; // each disguised word past the first
|
||||||
|
score += sc.fancywords; // styled unicode is rarely innocent
|
||||||
|
score += sc.invisibles * 3; // zero-width evasion is always suspicious
|
||||||
|
|
||||||
|
// Ratio bonus: only with real disguise (≥2 words) and non-Latin dominance.
|
||||||
|
if sc.totalletters >= 8 && disguised >= 2 {
|
||||||
|
let nonlatin = sc.totalletters - sc.latinletters;
|
||||||
|
if nonlatin > 0 && sc.latinletters > 0 && nonlatin * 100 / sc.totalletters >= 40 {
|
||||||
|
score += 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
score
|
||||||
|
}
|
||||||
|
|
||||||
|
/// If `text` is a CTCP, return the ACTION body to check, else `None` to skip
|
||||||
|
/// (non-ACTION CTCPs aren't scanned). Plain messages return the text unchanged.
|
||||||
|
fn checkable(text: &str) -> Option<&str> {
|
||||||
|
let Some(inner) = text.strip_prefix('\u{01}') else {
|
||||||
|
return Some(text);
|
||||||
|
};
|
||||||
|
let inner = inner.strip_suffix('\u{01}').unwrap_or(inner);
|
||||||
|
let (name, body) = inner.split_once(' ').unwrap_or((inner, ""));
|
||||||
|
if name.eq_ignore_ascii_case("ACTION") {
|
||||||
|
Some(body)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single-line, length-capped snippet of a blocked message for the oper
|
||||||
|
/// snotice. Keeps the look-alike glyphs visible (that's the point) but neutralises
|
||||||
|
/// every control byte (CR/LF, mIRC formatting) so it can't inject into or break
|
||||||
|
/// the protocol line the snotice is embedded in.
|
||||||
|
fn snippet(text: &str) -> String {
|
||||||
|
const MAX: usize = 120;
|
||||||
|
let mut out = String::new();
|
||||||
|
for (i, ch) in text.chars().enumerate() {
|
||||||
|
if i >= MAX {
|
||||||
|
out.push('…');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (ch as u32) < 0x20 || ch == '\u{7f}' {
|
||||||
|
out.push(' ');
|
||||||
|
} else {
|
||||||
|
out.push(ch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct AntiMixedUtf8;
|
||||||
|
|
||||||
|
impl Module for AntiMixedUtf8 {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"antimixedutf8"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_pre_message(
|
||||||
|
&mut self,
|
||||||
|
srv: &mut Server,
|
||||||
|
uid: Uid,
|
||||||
|
target: &str,
|
||||||
|
text: &str,
|
||||||
|
) -> ModResult {
|
||||||
|
if !srv.amu.enable {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
}
|
||||||
|
// exempt opers and users logged into an account
|
||||||
|
if srv.is_oper(uid) || srv.is_logged_in(uid) {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
}
|
||||||
|
let is_channel = target.starts_with('#');
|
||||||
|
if (is_channel && !srv.amu.check_channel) || (!is_channel && !srv.amu.check_private) {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
}
|
||||||
|
let Some(body) = checkable(text) else {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
};
|
||||||
|
if body.chars().count() < srv.amu.minlen {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
}
|
||||||
|
let score = score_message(body);
|
||||||
|
if score < srv.amu.threshold {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (nick, mask, host, ip) = {
|
||||||
|
let Some(u) = srv.users.get(&uid) else {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
};
|
||||||
|
(
|
||||||
|
u.nick.clone(),
|
||||||
|
u.prefix(),
|
||||||
|
u.host.clone(),
|
||||||
|
u.addr.ip().to_string(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// Show opers WHAT was blocked (a sanitized snippet) so they can judge the
|
||||||
|
// catch and spot false positives — the whole point of an antispam log.
|
||||||
|
srv.snotice(&format!(
|
||||||
|
"ANTIMIXEDUTF8: blocked spam from {mask} to {target} (score {score} >= {}): {}",
|
||||||
|
srv.amu.threshold,
|
||||||
|
snippet(body)
|
||||||
|
));
|
||||||
|
|
||||||
|
// Always tell the sender their message was blocked and that opers were
|
||||||
|
// told — even for punitive actions, since the writer flushes queued lines
|
||||||
|
// before a disconnect.
|
||||||
|
srv.send(
|
||||||
|
uid,
|
||||||
|
format!(
|
||||||
|
":{} NOTICE {nick} :*** {} (Flagged by the spam filter; network operators have been notified.)",
|
||||||
|
srv.name, srv.amu.block_msg
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let action = srv.amu.action.to_ascii_lowercase();
|
||||||
|
let (dur, reason, setter) = (
|
||||||
|
srv.amu.duration,
|
||||||
|
srv.amu.reason.clone(),
|
||||||
|
format!("antimixedutf8@{}", srv.name),
|
||||||
|
);
|
||||||
|
match action.as_str() {
|
||||||
|
"gline" => srv.add_xline(XKind::Gline, &format!("*@{host}"), dur, &setter, &reason),
|
||||||
|
"kline" => srv.add_xline(XKind::Kline, &format!("*@{host}"), dur, &setter, &reason),
|
||||||
|
"zline" => srv.add_xline(XKind::Zline, &ip, dur, &setter, &reason),
|
||||||
|
"kill" => srv.remove_user(uid, &reason),
|
||||||
|
// "block": also emit the standard channel-failure numeric so clients
|
||||||
|
// render the drop inline; the explanatory NOTICE above covers the rest.
|
||||||
|
_ if is_channel => srv.numeric(
|
||||||
|
uid,
|
||||||
|
crate::numeric::ERR_CANNOTSENDTOCHAN,
|
||||||
|
&format!("{target} :Message blocked by the spam filter"),
|
||||||
|
),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
ModResult::Deny
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn genuine_monolingual_text_scores_zero() {
|
||||||
|
assert_eq!(score_message("hello everyone how are you today"), 0); // Latin
|
||||||
|
assert_eq!(score_message("привет всем как у вас дела сегодня"), 0); // Russian
|
||||||
|
assert_eq!(score_message("γεια σας πως ειστε ολοι σημερα εδω"), 0); // Greek
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mixed_script_spam_scores_high() {
|
||||||
|
// Cyrillic look-alikes swapped into Latin words (multi-word disguise)
|
||||||
|
assert!(score_message("Ѕесurіtу аlеrt сlісk hеrе nоw рlеаѕе") >= 8);
|
||||||
|
// fancy/fullwidth styled word run
|
||||||
|
assert!(score_message("Free V1agra now click here") >= 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_stray_homoglyph_is_tolerated() {
|
||||||
|
// a single disguised word gets the 1-word grace → stays under threshold
|
||||||
|
assert!(score_message("hello wоrld this is a normal message") < 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zero_width_evasion_scores() {
|
||||||
|
// three zero-width joiners = 3*3 = 9
|
||||||
|
assert!(score_message("buy\u{200b}now\u{200b}cheap\u{200b}deal") >= 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snippet_is_one_clean_line_and_capped() {
|
||||||
|
// CR/LF and mIRC control bytes are neutralised (no protocol injection)
|
||||||
|
assert_eq!(snippet("hi\r\nthere"), "hi there");
|
||||||
|
assert!(!snippet("x\u{03}04red").contains('\u{03}'));
|
||||||
|
// look-alike glyphs survive so opers can see what was caught
|
||||||
|
assert!(snippet("Ѕесurіtу").contains('Ѕ'));
|
||||||
|
// long input is capped with an ellipsis
|
||||||
|
let s = snippet(&"a".repeat(200));
|
||||||
|
assert!(s.ends_with('…') && s.chars().count() == 121);
|
||||||
|
}
|
||||||
|
}
|
||||||
168
src/modules/cloak.rs
Normal file
168
src/modules/cloak.rs
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
//! cloak — echoIRCd's host-masking module (InspIRCd's `m_cloak_*`, our way).
|
||||||
|
//!
|
||||||
|
//! Every user gets a deterministic, keyed **cloak** of their host that hides the
|
||||||
|
//! real IP while *preserving subnet structure*, so a channel ban on a whole /24
|
||||||
|
//! or /16 still bites. The cloak is shown under user mode **+x**, which this
|
||||||
|
//! module auto-sets on connect; only opers may drop it (see [`crate::mode`]),
|
||||||
|
//! which stops +x from becoming a ban-evasion switch.
|
||||||
|
//!
|
||||||
|
//! Format follows InspIRCd's `SegmentIP`: one hashed segment per cumulative IP
|
||||||
|
//! octet-prefix, most-specific on the left, ending in the literal `.IP` suffix
|
||||||
|
//! that marks a cloaked address (as opposed to a cloaked hostname, which keeps
|
||||||
|
//! its domain). For `a.b.c.d`:
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! HASH(a.b.c.d) . HASH(a.b.c) . HASH(a.b) . HASH(a) . IP
|
||||||
|
//! (/32) (/24) (/16) (/8)
|
||||||
|
//! ```
|
||||||
|
//!
|
||||||
|
//! so two IPs in the same /24 share the `…/24./16./8.IP` tail (same /16 shares
|
||||||
|
//! `…/16./8.IP`), and the exact address never leaks. Where this improves on the
|
||||||
|
//! C++ original: the hash is **SHA-256** (via the `openssl` we already link for
|
||||||
|
//! TLS) instead of MD5, it needs no separate hashing module, and the whole path
|
||||||
|
//! stays `#![forbid(unsafe_code)]`. The key lives in the config (`cloak_key = …`);
|
||||||
|
//! with no key set, cloaking is simply off and +x is a no-op.
|
||||||
|
|
||||||
|
use openssl::sha::sha256;
|
||||||
|
|
||||||
|
use crate::module::Module;
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// The suffix marking a cloaked IP address (InspIRCd's default is `.IP` too).
|
||||||
|
const IP_SUFFIX: &str = ".IP";
|
||||||
|
|
||||||
|
pub struct Cloak;
|
||||||
|
|
||||||
|
impl Module for Cloak {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"cloak"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute the cloak once, at connect, and cloak the user by default (+x).
|
||||||
|
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {
|
||||||
|
let Some(key) = srv.cloak_key.clone() else {
|
||||||
|
return; // no cloak key configured -> cloaking disabled
|
||||||
|
};
|
||||||
|
let Some(host) = srv.users.get(&uid).map(|u| u.host.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let cloak = cloak_host(&key, &host);
|
||||||
|
if let Some(u) = srv.users.get_mut(&uid) {
|
||||||
|
u.cloak = cloak;
|
||||||
|
u.flags.cloak = true; // cloaked by default; -x is oper-only
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One cloak label: the first `n` hex chars of `SHA-256(key ‖ NUL ‖ data)`.
|
||||||
|
fn label(key: &str, data: &str, n: usize) -> String {
|
||||||
|
let digest = sha256(format!("{key}\u{0}{data}").as_bytes());
|
||||||
|
let mut s = String::with_capacity(n + 1);
|
||||||
|
for b in &digest {
|
||||||
|
s.push_str(&format!("{b:02x}"));
|
||||||
|
if s.len() >= n {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.truncate(n);
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `"a.b.c.d"` into four octets, or `None` if it isn't a dotted IPv4.
|
||||||
|
fn parse_v4(host: &str) -> Option<(u8, u8, u8, u8)> {
|
||||||
|
let mut it = host.split('.');
|
||||||
|
let a = it.next()?.parse().ok()?;
|
||||||
|
let b = it.next()?.parse().ok()?;
|
||||||
|
let c = it.next()?.parse().ok()?;
|
||||||
|
let d = it.next()?.parse().ok()?;
|
||||||
|
if it.next().is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some((a, b, c, d))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a user's cloak from their real host.
|
||||||
|
///
|
||||||
|
/// - IPv4 `a.b.c.d` → `H(a.b.c.d).H(a.b.c).H(a.b).H(a).IP` — one keyed segment per
|
||||||
|
/// octet-prefix tier (/32 · /24 · /16 · /8), so subnet bans keep working while
|
||||||
|
/// the exact address never appears.
|
||||||
|
/// - IPv6 → `ALPHA.BETA.GAMMA.IP` (mirrors InspIRCd), coarsened by hextet groups.
|
||||||
|
/// - hostname → keep the last two labels (the domain), mask everything to the left
|
||||||
|
/// (no `.IP` — a resolved name isn't a raw address).
|
||||||
|
pub fn cloak_host(key: &str, host: &str) -> String {
|
||||||
|
if let Some((a, b, c, d)) = parse_v4(host) {
|
||||||
|
let h32 = label(key, &format!("{a}.{b}.{c}.{d}"), 6);
|
||||||
|
let h24 = label(key, &format!("{a}.{b}.{c}"), 5);
|
||||||
|
let h16 = label(key, &format!("{a}.{b}"), 4);
|
||||||
|
let h8 = label(key, &format!("{a}"), 4);
|
||||||
|
format!("{h32}.{h24}.{h16}.{h8}{IP_SUFFIX}")
|
||||||
|
} else if host.contains(':') {
|
||||||
|
let groups: Vec<&str> = host.split(':').filter(|g| !g.is_empty()).collect();
|
||||||
|
let mid = groups.iter().take(4).copied().collect::<Vec<_>>().join(":");
|
||||||
|
let wide = groups.iter().take(2).copied().collect::<Vec<_>>().join(":");
|
||||||
|
let alpha = label(key, host, 6);
|
||||||
|
let beta = label(key, &mid, 5);
|
||||||
|
let gamma = label(key, &wide, 4);
|
||||||
|
format!("{alpha}.{beta}.{gamma}{IP_SUFFIX}")
|
||||||
|
} else {
|
||||||
|
let parts: Vec<&str> = host.split('.').filter(|p| !p.is_empty()).collect();
|
||||||
|
if parts.len() >= 3 {
|
||||||
|
let suffix = parts[parts.len() - 2..].join(".");
|
||||||
|
format!("{}.{suffix}", label(key, host, 8))
|
||||||
|
} else {
|
||||||
|
format!("{}.cloak", label(key, host, 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn v4_cloak_is_deterministic_and_hides_the_ip() {
|
||||||
|
let c = cloak_host("secret", "203.0.113.7");
|
||||||
|
assert_eq!(c, cloak_host("secret", "203.0.113.7")); // stable
|
||||||
|
assert!(!c.contains("203.0.113")); // the dotted IP never appears
|
||||||
|
assert!(c.ends_with(".IP")); // InspIRCd-style IP suffix
|
||||||
|
assert_eq!(c.split('.').count(), 5); // H32.H24.H16.H8.IP
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn same_subnet_shares_a_suffix_but_host_differs() {
|
||||||
|
let a = cloak_host("secret", "203.0.113.7");
|
||||||
|
let b = cloak_host("secret", "203.0.113.9"); // same /24
|
||||||
|
let e = cloak_host("secret", "8.8.8.8"); // different net
|
||||||
|
let tail = |s: &str| s.split_once('.').unwrap().1.to_string();
|
||||||
|
assert_eq!(tail(&a), tail(&b)); // /24 ban still matches both
|
||||||
|
assert_ne!(a, b); // but the exact host label differs
|
||||||
|
assert_ne!(tail(&a), tail(&e)); // unrelated net -> unrelated tail
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wider_ban_matches_the_whole_16() {
|
||||||
|
// two different /24s inside the same /16 share only the /16./8.IP tail
|
||||||
|
let a = cloak_host("secret", "203.0.113.7");
|
||||||
|
let b = cloak_host("secret", "203.0.200.4");
|
||||||
|
let net16_tail = |s: &str| s.splitn(3, '.').nth(2).unwrap().to_string();
|
||||||
|
assert_eq!(net16_tail(&a), net16_tail(&b)); // H16.H8.IP shared
|
||||||
|
assert_ne!(a.split_once('.').unwrap().1, b.split_once('.').unwrap().1); // /24 differs
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_key_changes_the_cloak() {
|
||||||
|
assert_ne!(
|
||||||
|
cloak_host("key-one", "203.0.113.7"),
|
||||||
|
cloak_host("key-two", "203.0.113.7"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hostname_keeps_its_domain_and_has_no_ip_suffix() {
|
||||||
|
let c = cloak_host("secret", "host.dyn.example.com");
|
||||||
|
assert!(c.ends_with(".example.com"));
|
||||||
|
assert!(!c.ends_with(".IP"));
|
||||||
|
assert!(!c.starts_with("host"));
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/modules/flood.rs
Normal file
71
src/modules/flood.rs
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
//! Flood protection — a module that rate-limits messages.
|
||||||
|
//!
|
||||||
|
//! It keeps each user's recent message times in that user's typed
|
||||||
|
//! [`crate::extensible::Extensible`] slot. Because the state is *owned by the
|
||||||
|
//! `User`*, it vanishes the moment the user quits — no cleanup callback, no cull
|
||||||
|
//! list, no chance of a dangling reference (the C++ InspIRCd failure mode this
|
||||||
|
//! design rules out at compile time).
|
||||||
|
|
||||||
|
use crate::module::{ModResult, Module};
|
||||||
|
use crate::server::{now, Server};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
const FLOOD_MAX: usize = 8; // messages allowed…
|
||||||
|
const FLOOD_WINDOW: u64 = 4; // …within this many seconds
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FloodState {
|
||||||
|
times: Vec<u64>,
|
||||||
|
warned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Flood;
|
||||||
|
|
||||||
|
impl Module for Flood {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"flood"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn on_pre_message(
|
||||||
|
&mut self,
|
||||||
|
srv: &mut Server,
|
||||||
|
uid: Uid,
|
||||||
|
_target: &str,
|
||||||
|
_text: &str,
|
||||||
|
) -> ModResult {
|
||||||
|
let now = now();
|
||||||
|
let (over, warn) = {
|
||||||
|
let Some(u) = srv.users.get_mut(&uid) else {
|
||||||
|
return ModResult::Passthru;
|
||||||
|
};
|
||||||
|
if u.flags.oper {
|
||||||
|
return ModResult::Passthru; // opers bypass flood limits
|
||||||
|
}
|
||||||
|
let st = u.ext.get_or_insert_with(FloodState::default);
|
||||||
|
st.times.retain(|&t| now.saturating_sub(t) < FLOOD_WINDOW);
|
||||||
|
st.times.push(now);
|
||||||
|
let over = st.times.len() > FLOOD_MAX;
|
||||||
|
let warn = over && !st.warned; // notice once per burst
|
||||||
|
st.warned = over;
|
||||||
|
(over, warn)
|
||||||
|
};
|
||||||
|
if over {
|
||||||
|
if warn {
|
||||||
|
let nick = srv
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
srv.send(
|
||||||
|
uid,
|
||||||
|
format!(
|
||||||
|
":{} NOTICE {nick} :*** Flood detected — slow down",
|
||||||
|
srv.name
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return ModResult::Deny;
|
||||||
|
}
|
||||||
|
ModResult::Passthru
|
||||||
|
}
|
||||||
|
}
|
||||||
19
src/modules/mod.rs
Normal file
19
src/modules/mod.rs
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
//! Optional, pluggable modules — echoIRCd's answer to InspIRCd's `src/modules/`.
|
||||||
|
//! Each hooks lifecycle events via the [`crate::module::Module`] trait.
|
||||||
|
|
||||||
|
pub mod antimixedutf8;
|
||||||
|
pub mod cloak;
|
||||||
|
pub mod flood;
|
||||||
|
pub mod snoop;
|
||||||
|
|
||||||
|
use crate::module::Module;
|
||||||
|
|
||||||
|
/// The modules loaded at boot. (Later: load by name from the config.)
|
||||||
|
pub fn default_modules() -> Vec<Box<dyn Module>> {
|
||||||
|
vec![
|
||||||
|
Box::new(snoop::Snoop),
|
||||||
|
Box::new(flood::Flood),
|
||||||
|
Box::new(cloak::Cloak),
|
||||||
|
Box::new(antimixedutf8::AntiMixedUtf8),
|
||||||
|
]
|
||||||
|
}
|
||||||
36
src/modules/snoop.rs
Normal file
36
src/modules/snoop.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
//! A tiny example module: log connects, joins and quits to stderr. It exercises
|
||||||
|
//! the hook wiring end-to-end and is the template for real modules.
|
||||||
|
|
||||||
|
use crate::module::Module;
|
||||||
|
use crate::server::Server;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub struct Snoop;
|
||||||
|
|
||||||
|
impl Module for Snoop {
|
||||||
|
fn name(&self) -> &'static str {
|
||||||
|
"snoop"
|
||||||
|
}
|
||||||
|
fn on_user_connect(&mut self, srv: &mut Server, uid: Uid) {
|
||||||
|
let info = srv
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| (u.nick.clone(), u.ident.clone(), u.host.clone()));
|
||||||
|
if let Some((nick, ident, host)) = info {
|
||||||
|
eprintln!("[snoop] connect {nick} ({ident}@{host})");
|
||||||
|
srv.snotice(&format!("Client connecting: {nick} ({ident}@{host})"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn on_join(&mut self, srv: &mut Server, uid: Uid, chan: &str) {
|
||||||
|
if let Some(u) = srv.users.get(&uid) {
|
||||||
|
eprintln!("[snoop] {} joined {chan}", u.nick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn on_user_quit(&mut self, srv: &mut Server, uid: Uid, reason: &str) {
|
||||||
|
let nick = srv.users.get(&uid).map(|u| u.nick.clone());
|
||||||
|
eprintln!("[snoop] quit uid={uid} ({reason})");
|
||||||
|
if let Some(nick) = nick {
|
||||||
|
srv.snotice(&format!("Client exiting: {nick} ({reason})"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
145
src/numeric.rs
Normal file
145
src/numeric.rs
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
//! IRC numeric replies (RFC 1459/2812 subset) used by the core.
|
||||||
|
|
||||||
|
pub const RPL_MAP: u16 = 6;
|
||||||
|
pub const RPL_MAPEND: u16 = 7;
|
||||||
|
pub const RPL_STATSCOMMANDS: u16 = 212;
|
||||||
|
pub const RPL_ENDOFSTATS: u16 = 219;
|
||||||
|
pub const RPL_STATSUPTIME: u16 = 242;
|
||||||
|
pub const RPL_STATSOLINE: u16 = 243;
|
||||||
|
pub const RPL_STATSXLINE: u16 = 223; // K/G/Z-line listing
|
||||||
|
pub const RPL_ADMINME: u16 = 256;
|
||||||
|
pub const RPL_ADMINLOC1: u16 = 257;
|
||||||
|
pub const RPL_ADMINLOC2: u16 = 258;
|
||||||
|
pub const RPL_ADMINEMAIL: u16 = 259;
|
||||||
|
pub const RPL_USERHOST: u16 = 302;
|
||||||
|
pub const RPL_ISON: u16 = 303;
|
||||||
|
pub const RPL_WHOISIDLE: u16 = 317;
|
||||||
|
pub const RPL_LISTSTART: u16 = 321;
|
||||||
|
pub const RPL_LIST: u16 = 322;
|
||||||
|
pub const RPL_LISTEND: u16 = 323;
|
||||||
|
pub const RPL_WHOWASUSER: u16 = 314;
|
||||||
|
pub const RPL_ENDOFWHOWAS: u16 = 369;
|
||||||
|
pub const RPL_INFO: u16 = 371;
|
||||||
|
pub const RPL_ENDOFINFO: u16 = 374;
|
||||||
|
pub const RPL_REHASHING: u16 = 382;
|
||||||
|
pub const RPL_TIME: u16 = 391;
|
||||||
|
pub const ERR_NOSUCHSERVER: u16 = 402;
|
||||||
|
pub const ERR_WASNOSUCHNICK: u16 = 406;
|
||||||
|
pub const ERR_UNAVAILRESOURCE: u16 = 437; // channel temporarily unavailable (+j)
|
||||||
|
pub const ERR_LINKCHANNEL: u16 = 470; // +L — you were redirected to another channel
|
||||||
|
pub const RPL_ENDOFSPAMFILTER: u16 = 940; // end of the +g word-filter list
|
||||||
|
pub const RPL_SPAMFILTER: u16 = 941; // one +g word-filter entry
|
||||||
|
pub const RPL_KNOCK: u16 = 710; // channel gets the knock
|
||||||
|
pub const RPL_KNOCKDLVR: u16 = 711; // knocker's ack
|
||||||
|
|
||||||
|
// SILENCE (server-side ignore list)
|
||||||
|
pub const RPL_SILELIST: u16 = 271;
|
||||||
|
pub const RPL_ENDOFSILENCE: u16 = 272;
|
||||||
|
pub const ERR_SILELISTFULL: u16 = 511;
|
||||||
|
|
||||||
|
// WATCH (notify list)
|
||||||
|
pub const RPL_LOGON: u16 = 600;
|
||||||
|
pub const RPL_LOGOFF: u16 = 601;
|
||||||
|
pub const RPL_WATCHOFF: u16 = 602;
|
||||||
|
pub const RPL_WATCHSTAT: u16 = 603;
|
||||||
|
pub const RPL_NOWON: u16 = 604;
|
||||||
|
pub const RPL_NOWOFF: u16 = 605;
|
||||||
|
pub const RPL_WATCHLIST: u16 = 606;
|
||||||
|
pub const RPL_ENDOFWATCHLIST: u16 = 607;
|
||||||
|
pub const ERR_TOOMANYWATCH: u16 = 512;
|
||||||
|
|
||||||
|
// MONITOR (IRCv3 notify list)
|
||||||
|
pub const RPL_MONONLINE: u16 = 730;
|
||||||
|
pub const RPL_MONOFFLINE: u16 = 731;
|
||||||
|
pub const RPL_MONLIST: u16 = 732;
|
||||||
|
pub const RPL_ENDOFMONLIST: u16 = 733;
|
||||||
|
pub const ERR_MONLISTFULL: u16 = 734;
|
||||||
|
|
||||||
|
pub const RPL_WELCOME: u16 = 1;
|
||||||
|
pub const RPL_YOURHOST: u16 = 2;
|
||||||
|
pub const RPL_CREATED: u16 = 3;
|
||||||
|
pub const RPL_MYINFO: u16 = 4;
|
||||||
|
pub const RPL_ISUPPORT: u16 = 5;
|
||||||
|
|
||||||
|
pub const RPL_UMODEIS: u16 = 221;
|
||||||
|
pub const RPL_YOUREOPER: u16 = 381;
|
||||||
|
pub const ERR_PASSWDMISMATCH: u16 = 464;
|
||||||
|
pub const ERR_NOPRIVILEGES: u16 = 481;
|
||||||
|
pub const ERR_UMODEUNKNOWNFLAG: u16 = 501;
|
||||||
|
pub const RPL_LUSERCLIENT: u16 = 251;
|
||||||
|
|
||||||
|
pub const RPL_WHOISUSER: u16 = 311;
|
||||||
|
pub const RPL_WHOISSERVER: u16 = 312;
|
||||||
|
pub const RPL_ENDOFWHO: u16 = 315;
|
||||||
|
pub const RPL_WHOISCHANNELS: u16 = 319;
|
||||||
|
pub const RPL_ENDOFWHOIS: u16 = 318;
|
||||||
|
pub const RPL_WHOISOPERATOR: u16 = 313; // "is an IRC operator" (hidden by +H)
|
||||||
|
pub const RPL_WHOISBOT: u16 = 335; // "is a bot" (umode +B)
|
||||||
|
pub const RPL_WHOISACCOUNT: u16 = 330; // "<nick> <account> :is logged in as"
|
||||||
|
pub const ERR_NEEDREGGEDNICK: u16 = 477; // chan +R/+M — must be logged into an account
|
||||||
|
pub const RPL_WHOISHOST: u16 = 378; // oper-only: real host/ip behind a cloak
|
||||||
|
pub const RPL_WHOISSECURE: u16 = 671; // "is using a secure connection" (sslinfo)
|
||||||
|
pub const RPL_HOSTHIDDEN: u16 = 396; // "is now your displayed host" (cloak on/off)
|
||||||
|
|
||||||
|
pub const RPL_CHANNELMODEIS: u16 = 324;
|
||||||
|
pub const RPL_CREATIONTIME: u16 = 329;
|
||||||
|
pub const RPL_NOTOPIC: u16 = 331;
|
||||||
|
pub const RPL_TOPIC: u16 = 332;
|
||||||
|
pub const RPL_WHOREPLY: u16 = 352;
|
||||||
|
pub const RPL_NAMREPLY: u16 = 353;
|
||||||
|
pub const RPL_ENDOFNAMES: u16 = 366;
|
||||||
|
|
||||||
|
pub const RPL_MOTD: u16 = 372;
|
||||||
|
pub const RPL_MOTDSTART: u16 = 375;
|
||||||
|
pub const RPL_ENDOFMOTD: u16 = 376;
|
||||||
|
pub const RPL_LINKS: u16 = 364;
|
||||||
|
pub const RPL_ENDOFLINKS: u16 = 365;
|
||||||
|
|
||||||
|
pub const ERR_NOSUCHNICK: u16 = 401;
|
||||||
|
pub const ERR_NOSUCHCHANNEL: u16 = 403;
|
||||||
|
pub const ERR_CANNOTSENDTOCHAN: u16 = 404;
|
||||||
|
pub const ERR_NORECIPIENT: u16 = 411;
|
||||||
|
pub const ERR_NOTEXTTOSEND: u16 = 412;
|
||||||
|
pub const ERR_UNKNOWNCOMMAND: u16 = 421;
|
||||||
|
pub const ERR_NOMOTD: u16 = 422;
|
||||||
|
pub const ERR_NONICKNAMEGIVEN: u16 = 431;
|
||||||
|
pub const ERR_ERRONEUSNICKNAME: u16 = 432;
|
||||||
|
pub const ERR_NICKNAMEINUSE: u16 = 433;
|
||||||
|
pub const ERR_USERNOTINCHANNEL: u16 = 441;
|
||||||
|
pub const ERR_NOTONCHANNEL: u16 = 442;
|
||||||
|
pub const ERR_CHANNELISFULL: u16 = 471;
|
||||||
|
pub const ERR_UNKNOWNMODE: u16 = 472;
|
||||||
|
pub const ERR_INVITEONLYCHAN: u16 = 473;
|
||||||
|
pub const ERR_BADCHANNELKEY: u16 = 475;
|
||||||
|
pub const ERR_CHANOPRIVSNEEDED: u16 = 482;
|
||||||
|
pub const ERR_SECUREONLYCHAN: u16 = 489; // can't join a +z channel without TLS
|
||||||
|
pub const ERR_ALLMUSTSSL: u16 = 490; // can't set +z while a member isn't on TLS
|
||||||
|
pub const ERR_NOTREGISTERED: u16 = 451;
|
||||||
|
pub const ERR_NEEDMOREPARAMS: u16 = 461;
|
||||||
|
pub const ERR_ALREADYREGISTERED: u16 = 462;
|
||||||
|
pub const ERR_USERSDONTMATCH: u16 = 502;
|
||||||
|
|
||||||
|
pub const RPL_AWAY: u16 = 301;
|
||||||
|
pub const RPL_UNAWAY: u16 = 305;
|
||||||
|
pub const RPL_NOWAWAY: u16 = 306;
|
||||||
|
pub const RPL_INVITING: u16 = 341;
|
||||||
|
pub const RPL_BANLIST: u16 = 367;
|
||||||
|
pub const RPL_ENDOFBANLIST: u16 = 368;
|
||||||
|
pub const RPL_INVEXLIST: u16 = 346;
|
||||||
|
pub const RPL_ENDOFINVEXLIST: u16 = 347;
|
||||||
|
pub const RPL_EXCEPTLIST: u16 = 348;
|
||||||
|
pub const RPL_ENDOFEXCEPTLIST: u16 = 349;
|
||||||
|
pub const ERR_CANTCHANGENICK: u16 = 447; // +N — no nick change on channel
|
||||||
|
pub const ERR_CANTJOINOPERSONLY: u16 = 520; // +O — IRC operators only
|
||||||
|
pub const ERR_USERONCHANNEL: u16 = 443;
|
||||||
|
pub const ERR_BANNEDFROMCHAN: u16 = 474;
|
||||||
|
|
||||||
|
// SASL (IRCv3)
|
||||||
|
pub const RPL_LOGGEDIN: u16 = 900;
|
||||||
|
pub const RPL_LOGGEDOUT: u16 = 901;
|
||||||
|
pub const ERR_NICKLOCKED: u16 = 902;
|
||||||
|
pub const RPL_SASLSUCCESS: u16 = 903;
|
||||||
|
pub const ERR_SASLFAIL: u16 = 904;
|
||||||
|
pub const ERR_SASLTOOLONG: u16 = 905;
|
||||||
|
pub const ERR_SASLABORTED: u16 = 906;
|
||||||
|
pub const RPL_SASLMECHS: u16 = 908;
|
||||||
645
src/server.rs
Normal file
645
src/server.rs
Normal file
|
|
@ -0,0 +1,645 @@
|
||||||
|
//! The engine core: the `Server` struct that owns all state, the output
|
||||||
|
//! primitives (send / numeric / to_channel) and the connection lifecycle.
|
||||||
|
//! Per-subsystem behaviour lives beside its data — [`crate::users`] and
|
||||||
|
//! [`crate::channels`] add their own `impl Server` blocks, the way InspIRCd
|
||||||
|
//! keeps usermanager / channelmanager separate from the core. No locks: only the
|
||||||
|
//! single core thread ever holds a `Server`.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
|
use std::net::{SocketAddr, TcpStream};
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use crate::channels::Channel;
|
||||||
|
use crate::config::{Config, LinkBlock};
|
||||||
|
use crate::extensible::Extensible;
|
||||||
|
use crate::link::{Link, RemoteServer, RemoteUser};
|
||||||
|
use crate::module::Hook;
|
||||||
|
use crate::users::{Caps, User, UserFlags};
|
||||||
|
use crate::xline::XLine;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
|
|
||||||
|
/// Background timer cadence + idle/ping timeouts, in seconds.
|
||||||
|
pub const TICK_SECS: u64 = 15;
|
||||||
|
pub const PING_AFTER: u64 = 90;
|
||||||
|
pub const PING_TIMEOUT: u64 = 60;
|
||||||
|
pub const REG_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
|
pub fn now() -> u64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs())
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format a unix timestamp as an IRCv3 `server-time` tag value
|
||||||
|
/// (`2026-08-05T07:58:03.000Z`), computing the civil date with std only.
|
||||||
|
pub fn iso_time(secs: u64) -> String {
|
||||||
|
let days = (secs / 86400) as i64;
|
||||||
|
let (h, mi, s) = ((secs % 86400) / 3600, (secs % 3600) / 60, secs % 60);
|
||||||
|
// civil date from days since 1970-01-01 (Howard Hinnant's algorithm)
|
||||||
|
let z = days + 719468;
|
||||||
|
let era = if z >= 0 { z } else { z - 146096 } / 146097;
|
||||||
|
let doe = z - era * 146097;
|
||||||
|
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
|
||||||
|
let y = yoe + era * 400;
|
||||||
|
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||||
|
let mp = (5 * doy + 2) / 153;
|
||||||
|
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||||
|
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||||
|
let y = if m <= 2 { y + 1 } else { y };
|
||||||
|
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recently-departed identity, kept for WHOWAS.
|
||||||
|
pub struct WhowasEntry {
|
||||||
|
pub nick: String,
|
||||||
|
pub ident: String,
|
||||||
|
pub host: String,
|
||||||
|
pub realname: String,
|
||||||
|
pub account: Option<String>,
|
||||||
|
pub ts: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Server {
|
||||||
|
pub name: String,
|
||||||
|
pub network: String,
|
||||||
|
pub created: u64,
|
||||||
|
pub motd: Vec<String>,
|
||||||
|
pub users: HashMap<Uid, User>,
|
||||||
|
pub nick_index: HashMap<String, Uid>, // lower nick -> uid
|
||||||
|
pub channels: HashMap<String, Channel>, // lower name -> channel
|
||||||
|
pub events: VecDeque<Hook>,
|
||||||
|
pub opers: Vec<(String, String)>, // (name, password) from config
|
||||||
|
pub cloak_key: Option<String>, // host-cloaking key (see modules::cloak)
|
||||||
|
pub line_ctags: String, // client-only tags of the line being handled
|
||||||
|
// --- server-to-server (see crate::link) ---
|
||||||
|
pub sid: String, // our 3-char server id
|
||||||
|
pub server_desc: String, // our description
|
||||||
|
pub link_blocks: Vec<LinkBlock>, // peers we accept / dial
|
||||||
|
pub links: HashMap<Uid, Link>, // local link connections
|
||||||
|
pub servers: HashMap<String, RemoteServer>, // sid -> linked server
|
||||||
|
pub uuid_counter: u64, // mints local user UIDs
|
||||||
|
pub msgid_counter: u64, // mints IRCv3 `msgid` message tags
|
||||||
|
pub uuid_local: HashMap<String, Uid>, // our users, by network uuid
|
||||||
|
pub remote_users: HashMap<String, RemoteUser>, // users on other servers
|
||||||
|
pub remote_nick: HashMap<String, String>, // lower nick -> remote uuid
|
||||||
|
pub whowas: VecDeque<WhowasEntry>, // recent nick history (WHOWAS)
|
||||||
|
pub conf_path: String, // config path, for REHASH
|
||||||
|
pub xlines: Vec<XLine>, // server bans (KLINE/GLINE/ZLINE)
|
||||||
|
pub mode_sudo: bool, // SAMODE/SAKICK: bypass rank checks
|
||||||
|
pub in_redirect: bool, // +L: guards against redirect loops
|
||||||
|
pub censor: Vec<(String, String)>, // +G bad words: (find, replace)
|
||||||
|
pub amu: crate::config::AntiMixedCfg, // antimixedutf8 module config
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn new(cfg: Config) -> Server {
|
||||||
|
Server {
|
||||||
|
name: cfg.servername,
|
||||||
|
network: cfg.network,
|
||||||
|
created: now(),
|
||||||
|
motd: cfg.motd,
|
||||||
|
users: HashMap::new(),
|
||||||
|
nick_index: HashMap::new(),
|
||||||
|
channels: HashMap::new(),
|
||||||
|
events: VecDeque::new(),
|
||||||
|
opers: cfg.opers,
|
||||||
|
cloak_key: cfg.cloak_key,
|
||||||
|
line_ctags: String::new(),
|
||||||
|
sid: cfg.sid,
|
||||||
|
server_desc: cfg.serverdesc,
|
||||||
|
link_blocks: cfg.links,
|
||||||
|
links: HashMap::new(),
|
||||||
|
servers: HashMap::new(),
|
||||||
|
uuid_counter: 0,
|
||||||
|
msgid_counter: 0,
|
||||||
|
uuid_local: HashMap::new(),
|
||||||
|
remote_users: HashMap::new(),
|
||||||
|
remote_nick: HashMap::new(),
|
||||||
|
whowas: VecDeque::new(),
|
||||||
|
conf_path: cfg.conf_path,
|
||||||
|
xlines: Vec::new(),
|
||||||
|
mode_sudo: false,
|
||||||
|
in_redirect: false,
|
||||||
|
censor: cfg.censor,
|
||||||
|
amu: cfg.amu,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remember an identity for WHOWAS (capped ring, newest first).
|
||||||
|
pub fn push_whowas(
|
||||||
|
&mut self,
|
||||||
|
nick: &str,
|
||||||
|
ident: &str,
|
||||||
|
host: &str,
|
||||||
|
realname: &str,
|
||||||
|
account: Option<String>,
|
||||||
|
) {
|
||||||
|
if nick.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.whowas.push_front(WhowasEntry {
|
||||||
|
nick: nick.to_string(),
|
||||||
|
ident: ident.to_string(),
|
||||||
|
host: host.to_string(),
|
||||||
|
realname: realname.to_string(),
|
||||||
|
account,
|
||||||
|
ts: now(),
|
||||||
|
});
|
||||||
|
while self.whowas.len() > 256 {
|
||||||
|
self.whowas.pop_back();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- connection lifecycle ------------------------------------------------
|
||||||
|
|
||||||
|
pub fn add_conn(
|
||||||
|
&mut self,
|
||||||
|
uid: Uid,
|
||||||
|
addr: SocketAddr,
|
||||||
|
out: Sender<String>,
|
||||||
|
sock: TcpStream,
|
||||||
|
secure: bool,
|
||||||
|
) {
|
||||||
|
let uuid = self.next_uuid();
|
||||||
|
self.uuid_local.insert(uuid.clone(), uid);
|
||||||
|
self.users.insert(
|
||||||
|
uid,
|
||||||
|
User {
|
||||||
|
uid,
|
||||||
|
uuid,
|
||||||
|
nick: String::new(),
|
||||||
|
ident: String::new(),
|
||||||
|
realname: String::new(),
|
||||||
|
host: addr.ip().to_string(),
|
||||||
|
cloak: String::new(),
|
||||||
|
vhost: None,
|
||||||
|
secure,
|
||||||
|
account: None,
|
||||||
|
signon: now(),
|
||||||
|
addr,
|
||||||
|
registered: false,
|
||||||
|
cap: false,
|
||||||
|
cap_302: false,
|
||||||
|
caps: Caps::default(),
|
||||||
|
sasl_mech: None,
|
||||||
|
channels: HashSet::new(),
|
||||||
|
watch: Vec::new(),
|
||||||
|
monitor: Vec::new(),
|
||||||
|
silence: Vec::new(),
|
||||||
|
quitting: None,
|
||||||
|
flags: UserFlags::default(),
|
||||||
|
last_active: now(),
|
||||||
|
ping_sent: false,
|
||||||
|
ext: Extensible::default(),
|
||||||
|
out,
|
||||||
|
sock: Some(sock),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a user as quitting; the core turns this into a full quit after the
|
||||||
|
/// current command returns (so hooks fire while the user still exists).
|
||||||
|
pub fn mark_quit(&mut self, uid: Uid, reason: String) {
|
||||||
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
|
u.quitting = Some(reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn take_quit(&mut self, uid: Uid) -> Option<String> {
|
||||||
|
self.users.get_mut(&uid).and_then(|u| u.quitting.take())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a user: broadcast QUIT to everyone sharing a channel, drop them
|
||||||
|
/// from all channels, free the nick, and close the socket.
|
||||||
|
pub fn remove_user(&mut self, uid: Uid, reason: &str) {
|
||||||
|
let Some(user) = self.users.remove(&uid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
self.uuid_local.remove(&user.uuid);
|
||||||
|
if user.registered {
|
||||||
|
self.push_whowas(
|
||||||
|
&user.nick,
|
||||||
|
&user.ident,
|
||||||
|
user.host_display(),
|
||||||
|
&user.realname,
|
||||||
|
user.account.clone(),
|
||||||
|
);
|
||||||
|
self.propagate(&format!(":{} QUIT :{reason}", user.uuid), None); // tell links
|
||||||
|
}
|
||||||
|
// NB: we do *not* force-shutdown the socket here. When `user` drops at
|
||||||
|
// the end of this function its `out` Sender drops with it, so the writer
|
||||||
|
// thread drains any still-queued lines — e.g. a KILL / x-line ERROR —
|
||||||
|
// and then closes the socket itself once the channel is empty.
|
||||||
|
if !user.nick.is_empty() {
|
||||||
|
self.nick_index.remove(&user.nick.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
if user.registered {
|
||||||
|
let line = format!(":{} QUIT :{reason}", user.prefix());
|
||||||
|
let mut seen: HashSet<Uid> = HashSet::new();
|
||||||
|
for key in &user.channels {
|
||||||
|
if let Some(ch) = self.channels.get_mut(key) {
|
||||||
|
ch.members.remove(&uid);
|
||||||
|
for &m in ch.members.keys() {
|
||||||
|
seen.insert(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m in seen {
|
||||||
|
self.send(m, line.clone());
|
||||||
|
}
|
||||||
|
self.channels.retain(|_, c| !c.is_empty());
|
||||||
|
self.watch_notify_offline(&user.nick); // tell WATCH/MONITOR watchers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- output primitives ---------------------------------------------------
|
||||||
|
|
||||||
|
/// Queue one raw line to a connection (no-op if it's gone).
|
||||||
|
pub fn send(&self, uid: Uid, line: String) {
|
||||||
|
if let Some(u) = self.users.get(&uid) {
|
||||||
|
// server-time: tag sourced (`:prefix …`) lines for clients that asked
|
||||||
|
let line = if u.caps.server_time && line.starts_with(':') {
|
||||||
|
format!("@time={} {line}", iso_time(now()))
|
||||||
|
} else {
|
||||||
|
line
|
||||||
|
};
|
||||||
|
let _ = u.out.send(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a numeric: `:server NNN <target> <rest>`. `<target>` is the client's
|
||||||
|
/// nick, or `*` before it has one.
|
||||||
|
pub fn numeric(&self, uid: Uid, code: u16, rest: &str) {
|
||||||
|
let target = self
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| {
|
||||||
|
if u.nick.is_empty() {
|
||||||
|
"*".to_string()
|
||||||
|
} else {
|
||||||
|
u.nick.clone()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "*".to_string());
|
||||||
|
self.send(
|
||||||
|
uid,
|
||||||
|
format!(":{} {:03} {} {}", self.name, code, target, rest),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a server notice to every operator who has snomask (+s) on.
|
||||||
|
pub fn snotice(&self, msg: &str) {
|
||||||
|
let opers: Vec<Uid> = self
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, u)| u.flags.oper && u.flags.snomask)
|
||||||
|
.map(|(&u, _)| u)
|
||||||
|
.collect();
|
||||||
|
for o in opers {
|
||||||
|
let nick = self
|
||||||
|
.users
|
||||||
|
.get(&o)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.send(o, format!(":{} NOTICE {nick} :*** {msg}", self.name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a line to every member of a channel, optionally skipping one uid.
|
||||||
|
pub fn to_channel(&self, key: &str, line: &str, except: Option<Uid>) {
|
||||||
|
if let Some(ch) = self.channels.get(key) {
|
||||||
|
for &uid in ch.members.keys() {
|
||||||
|
if Some(uid) != except {
|
||||||
|
self.send(uid, line.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a message body (`:prefix CMD …`) to `uid`, composing its IRCv3 tag
|
||||||
|
/// prefix from that client's caps: `time=` (server-time) plus the client-only
|
||||||
|
/// tags `ctags` (message-tags). Used for PRIVMSG / NOTICE / TAGMSG delivery.
|
||||||
|
pub fn send_tagged(&self, uid: Uid, ctags: &str, msgid: &str, body: &str) {
|
||||||
|
if let Some(u) = self.users.get(&uid) {
|
||||||
|
let mut tags: Vec<String> = Vec::new();
|
||||||
|
if u.caps.server_time {
|
||||||
|
tags.push(format!("time={}", iso_time(now())));
|
||||||
|
}
|
||||||
|
// msgid (IRCv3): a unique, server-assigned id per message so clients
|
||||||
|
// can reference it (reactions, replies, redaction). Tag-only feature,
|
||||||
|
// so it goes to message-tags clients alongside any client `+`-tags.
|
||||||
|
if u.caps.message_tags {
|
||||||
|
if !msgid.is_empty() {
|
||||||
|
tags.push(format!("msgid={msgid}"));
|
||||||
|
}
|
||||||
|
if !ctags.is_empty() {
|
||||||
|
tags.push(ctags.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let line = if tags.is_empty() {
|
||||||
|
body.to_string()
|
||||||
|
} else {
|
||||||
|
format!("@{} {body}", tags.join(";"))
|
||||||
|
};
|
||||||
|
let _ = u.out.send(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a unique IRCv3 `msgid` for one message. Generated once per PRIVMSG/
|
||||||
|
/// NOTICE/TAGMSG and shared across all its recipients so they correlate.
|
||||||
|
/// `<server-start>-<counter>` in hex: unique for this run, distinct across
|
||||||
|
/// restarts (the start time changes).
|
||||||
|
pub fn next_msgid(&mut self) -> String {
|
||||||
|
self.msgid_counter = self.msgid_counter.wrapping_add(1);
|
||||||
|
format!("{:x}-{:x}", self.created, self.msgid_counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send `line` to every user sharing a channel with `uid` (except `uid`) whose
|
||||||
|
/// capabilities satisfy `want`. Drives away-/account-/chghost-/setname-notify.
|
||||||
|
pub fn notify_peers(&self, uid: Uid, line: &str, want: fn(&Caps) -> bool) {
|
||||||
|
let chans: Vec<String> = self
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.channels.iter().cloned().collect())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let mut seen: HashSet<Uid> = HashSet::new();
|
||||||
|
for k in &chans {
|
||||||
|
if let Some(ch) = self.channels.get(k) {
|
||||||
|
for &m in ch.members.keys() {
|
||||||
|
seen.insert(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// extended-monitor (IRCv3): clients that MONITOR this nick and negotiated
|
||||||
|
// `extended-monitor` are treated as able to see it — so away/account/
|
||||||
|
// chghost/setname reach them even without a shared channel. The `want`
|
||||||
|
// filter below still requires the matching base cap, per spec.
|
||||||
|
if let Some(low) = self.users.get(&uid).map(|u| u.nick.to_ascii_lowercase()) {
|
||||||
|
for (&m, u) in &self.users {
|
||||||
|
if u.caps.extended_monitor && u.monitor.contains(&low) {
|
||||||
|
seen.insert(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for m in seen {
|
||||||
|
if m != uid && self.users.get(&m).map(|u| want(&u.caps)).unwrap_or(false) {
|
||||||
|
self.send(m, line.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Change a user's displayed host and/or ident (CHGHOST/CHGIDENT/SETHOST/
|
||||||
|
/// SETIDENT). Announces it via the `chghost` cap to peers that speak it and
|
||||||
|
/// to the user, and sends RPL_HOSTHIDDEN (396) when the host changed. Mirrors
|
||||||
|
/// InspIRCd's ChangeDisplayedHost / ChangeIdent (local scope for now).
|
||||||
|
pub fn change_host_ident(&mut self, uid: Uid, new_ident: Option<&str>, new_host: Option<&str>) {
|
||||||
|
let Some(u) = self.users.get(&uid) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let old_prefix = u.prefix();
|
||||||
|
if let Some(h) = new_host {
|
||||||
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
|
u.vhost = Some(h.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(i) = new_ident {
|
||||||
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
|
u.ident = i.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let (ident, host, aware) = {
|
||||||
|
let u = &self.users[&uid];
|
||||||
|
(
|
||||||
|
u.ident.clone(),
|
||||||
|
u.host_display().to_string(),
|
||||||
|
u.caps.chghost,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
// chghost-cap peers (and, if it speaks it, the user) get a CHGHOST line
|
||||||
|
let line = format!(":{old_prefix} CHGHOST {ident} {host}");
|
||||||
|
self.notify_peers(uid, &line, |c| c.chghost);
|
||||||
|
if aware {
|
||||||
|
self.send(uid, line);
|
||||||
|
}
|
||||||
|
if new_host.is_some() {
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
crate::numeric::RPL_HOSTHIDDEN,
|
||||||
|
&format!("{host} :is now your displayed host"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decide which connections to PING and which to drop, given `now`.
|
||||||
|
/// Returns `(to_ping, to_quit)`. Pure over the state, so it's unit-testable.
|
||||||
|
pub fn idle_check(&self, now: u64) -> (Vec<Uid>, Vec<Uid>) {
|
||||||
|
let mut ping = Vec::new();
|
||||||
|
let mut quit = Vec::new();
|
||||||
|
for (&uid, u) in &self.users {
|
||||||
|
let idle = now.saturating_sub(u.last_active);
|
||||||
|
if !u.registered {
|
||||||
|
if idle >= REG_TIMEOUT {
|
||||||
|
quit.push(uid); // never registered in time
|
||||||
|
}
|
||||||
|
} else if u.ping_sent {
|
||||||
|
if idle >= PING_AFTER + PING_TIMEOUT {
|
||||||
|
quit.push(uid); // no reply to our PING
|
||||||
|
}
|
||||||
|
} else if idle >= PING_AFTER {
|
||||||
|
ping.push(uid); // idle — poke it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(ping, quit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::channels::valid_chan;
|
||||||
|
use crate::users::{valid_nick, User, UserFlags};
|
||||||
|
use std::sync::mpsc::{self, Receiver};
|
||||||
|
|
||||||
|
/// Insert a registered user with an output channel we can read in the test.
|
||||||
|
fn add_user(s: &mut Server, uid: Uid, nick: &str) -> Receiver<String> {
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
s.users.insert(
|
||||||
|
uid,
|
||||||
|
User {
|
||||||
|
uid,
|
||||||
|
uuid: format!("TST{uid:06}"),
|
||||||
|
nick: nick.to_string(),
|
||||||
|
ident: "u".to_string(),
|
||||||
|
realname: "real".to_string(),
|
||||||
|
host: "localhost".to_string(),
|
||||||
|
cloak: String::new(),
|
||||||
|
vhost: None,
|
||||||
|
secure: false,
|
||||||
|
account: None,
|
||||||
|
signon: 0,
|
||||||
|
addr: "127.0.0.1:1".parse().unwrap(),
|
||||||
|
registered: true,
|
||||||
|
cap: false,
|
||||||
|
cap_302: false,
|
||||||
|
caps: Caps::default(),
|
||||||
|
sasl_mech: None,
|
||||||
|
channels: HashSet::new(),
|
||||||
|
watch: Vec::new(),
|
||||||
|
monitor: Vec::new(),
|
||||||
|
silence: Vec::new(),
|
||||||
|
quitting: None,
|
||||||
|
flags: UserFlags::default(),
|
||||||
|
last_active: 0,
|
||||||
|
ping_sent: false,
|
||||||
|
ext: Extensible::default(),
|
||||||
|
out: tx,
|
||||||
|
sock: None,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
s.nick_index.insert(nick.to_ascii_lowercase(), uid);
|
||||||
|
rx
|
||||||
|
}
|
||||||
|
|
||||||
|
fn srv() -> Server {
|
||||||
|
Server::new(Config::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn join_broadcasts_and_tracks_membership() {
|
||||||
|
let mut s = srv();
|
||||||
|
let arx = add_user(&mut s, 1, "ann");
|
||||||
|
let brx = add_user(&mut s, 2, "bob");
|
||||||
|
s.join(1, "#c", None); // ann creates -> gets @
|
||||||
|
s.join(2, "#c", None); // bob joins
|
||||||
|
|
||||||
|
assert!(s.channels["#c"].members[&1].op);
|
||||||
|
assert!(!s.channels["#c"].members[&2].op);
|
||||||
|
assert_eq!(s.channels["#c"].members.len(), 2);
|
||||||
|
|
||||||
|
let ann: Vec<String> = arx.try_iter().collect();
|
||||||
|
assert!(ann
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains("JOIN #c") && l.contains("ann!")));
|
||||||
|
assert!(ann
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains(":bob!") && l.contains("JOIN #c")));
|
||||||
|
let bob: Vec<String> = brx.try_iter().collect();
|
||||||
|
assert!(bob.iter().any(|l| l.contains("353") && l.contains("@ann")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nick_change_reindexes_and_notifies_channel() {
|
||||||
|
let mut s = srv();
|
||||||
|
let arx = add_user(&mut s, 1, "ann");
|
||||||
|
add_user(&mut s, 2, "bob");
|
||||||
|
s.join(1, "#c", None);
|
||||||
|
s.join(2, "#c", None);
|
||||||
|
s.set_nick(1, "annie");
|
||||||
|
assert_eq!(s.find_nick("annie"), Some(1));
|
||||||
|
assert_eq!(s.find_nick("ann"), None);
|
||||||
|
let ann: Vec<String> = arx.try_iter().collect();
|
||||||
|
assert!(ann
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains("NICK :annie") && l.contains("ann!")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quit_frees_the_nick_and_tells_neighbors() {
|
||||||
|
let mut s = srv();
|
||||||
|
add_user(&mut s, 1, "ann");
|
||||||
|
let brx = add_user(&mut s, 2, "bob");
|
||||||
|
s.join(1, "#c", None);
|
||||||
|
s.join(2, "#c", None);
|
||||||
|
s.remove_user(1, "bye");
|
||||||
|
assert!(s.find_nick("ann").is_none());
|
||||||
|
assert!(!s.channels["#c"].members.contains_key(&1));
|
||||||
|
let bob: Vec<String> = brx.try_iter().collect();
|
||||||
|
assert!(bob
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains(":ann!") && l.contains("QUIT :bye")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moderated_channel_needs_voice() {
|
||||||
|
let mut s = srv();
|
||||||
|
add_user(&mut s, 1, "ann");
|
||||||
|
add_user(&mut s, 2, "bob");
|
||||||
|
s.join(1, "#c", None); // ann = op
|
||||||
|
s.join(2, "#c", None);
|
||||||
|
s.channels.get_mut("#c").unwrap().modes.moderated = true;
|
||||||
|
assert!(s.is_op(1, "#c") && !s.is_op(2, "#c"));
|
||||||
|
assert!(s.is_member(2, "#c"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn secure_only_channel_rejects_plaintext() {
|
||||||
|
let mut s = srv();
|
||||||
|
add_user(&mut s, 1, "tls");
|
||||||
|
s.users.get_mut(&1).unwrap().secure = true; // on TLS
|
||||||
|
add_user(&mut s, 2, "plain"); // add_user defaults secure=false
|
||||||
|
s.join(1, "#z", None); // tls creates -> op
|
||||||
|
s.channels.get_mut("#z").unwrap().modes.secure_only = true;
|
||||||
|
s.join(2, "#z", None); // plaintext tries to join
|
||||||
|
assert!(s.is_member(1, "#z")); // the TLS user stays
|
||||||
|
assert!(!s.is_member(2, "#z")); // the plaintext user is refused
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reg_only_channel_rejects_unregistered() {
|
||||||
|
let mut s = srv();
|
||||||
|
add_user(&mut s, 1, "member");
|
||||||
|
s.users.get_mut(&1).unwrap().account = Some("acct".to_string()); // logged in
|
||||||
|
add_user(&mut s, 2, "guest"); // account None
|
||||||
|
s.join(1, "#r", None); // logged-in user creates -> op
|
||||||
|
s.channels.get_mut("#r").unwrap().modes.reg_only = true;
|
||||||
|
s.join(2, "#r", None); // guest tries to join
|
||||||
|
assert!(s.is_member(1, "#r")); // the logged-in user stays
|
||||||
|
assert!(!s.is_member(2, "#r")); // the guest is refused
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iso_time_formats_server_time() {
|
||||||
|
assert_eq!(iso_time(0), "1970-01-01T00:00:00.000Z");
|
||||||
|
assert_eq!(iso_time(1_000_000_000), "2001-09-09T01:46:40.000Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nick_and_chan_validation() {
|
||||||
|
assert!(valid_nick("reverse"));
|
||||||
|
assert!(valid_nick("[abc]`"));
|
||||||
|
assert!(!valid_nick("1abc")); // can't start with a digit
|
||||||
|
assert!(!valid_nick(""));
|
||||||
|
assert!(valid_chan("#argentina"));
|
||||||
|
assert!(!valid_chan("argentina"));
|
||||||
|
assert!(!valid_chan("#a b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idle_check_pings_then_times_out() {
|
||||||
|
let mut s = srv();
|
||||||
|
add_user(&mut s, 1, "ann"); // registered, last_active = 0
|
||||||
|
let now = 1000;
|
||||||
|
let (ping, quit) = s.idle_check(now);
|
||||||
|
assert_eq!(ping, vec![1]); // idle -> PING
|
||||||
|
assert!(quit.is_empty());
|
||||||
|
|
||||||
|
s.users.get_mut(&1).unwrap().ping_sent = true;
|
||||||
|
let (ping, quit) = s.idle_check(now);
|
||||||
|
assert!(ping.is_empty()); // already pinged
|
||||||
|
assert_eq!(quit, vec![1]); // no reply -> ping timeout
|
||||||
|
|
||||||
|
s.users.get_mut(&1).unwrap().ping_sent = false;
|
||||||
|
s.users.get_mut(&1).unwrap().last_active = now;
|
||||||
|
let (ping, quit) = s.idle_check(now);
|
||||||
|
assert!(ping.is_empty() && quit.is_empty()); // active -> left alone
|
||||||
|
|
||||||
|
s.users.get_mut(&1).unwrap().registered = false;
|
||||||
|
s.users.get_mut(&1).unwrap().last_active = 0;
|
||||||
|
let (_, quit) = s.idle_check(now);
|
||||||
|
assert_eq!(quit, vec![1]); // unregistered + idle -> registration timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
255
src/socketengine.rs
Normal file
255
src/socketengine.rs
Normal file
|
|
@ -0,0 +1,255 @@
|
||||||
|
//! The socket engine: the I/O edge. Accept connections and, per socket, ferry
|
||||||
|
//! the wire to/from the core. Plaintext sockets get a blocking reader thread +
|
||||||
|
//! writer thread; TLS sockets get one thread that owns the session and polls
|
||||||
|
//! (a single TLS object can't be split across two threads). The core never
|
||||||
|
//! touches a socket except to shut it down. (InspIRCd has a `socketengines/`
|
||||||
|
//! dir of epoll/kqueue/select backends; ours is threads.)
|
||||||
|
|
||||||
|
use std::io::{self, BufRead, BufReader, Write};
|
||||||
|
use std::net::{Shutdown, SocketAddr, TcpListener, TcpStream};
|
||||||
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
|
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use crate::ircd::Event;
|
||||||
|
use crate::tls::TlsBackend;
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// Longest single line we'll buffer before dropping it (crude flood guard).
|
||||||
|
const MAX_LINE: usize = 16 * 1024;
|
||||||
|
/// How long a TLS thread blocks on a read before draining its write queue.
|
||||||
|
const TLS_POLL: Duration = Duration::from_millis(100);
|
||||||
|
|
||||||
|
/// Accept forever, wiring each connection to the core. `tls` = the backend to
|
||||||
|
/// wrap sockets in (None for a plaintext listener). `counter` is shared across
|
||||||
|
/// every listener so uids stay unique.
|
||||||
|
pub fn accept_loop(
|
||||||
|
listener: TcpListener,
|
||||||
|
core: Sender<Event>,
|
||||||
|
tls: Option<Arc<dyn TlsBackend>>,
|
||||||
|
counter: Arc<AtomicU64>,
|
||||||
|
link: bool,
|
||||||
|
) {
|
||||||
|
for conn in listener.incoming() {
|
||||||
|
let Ok(stream) = conn else { continue };
|
||||||
|
let Ok(addr) = stream.peer_addr() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
let uid = counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
|
match &tls {
|
||||||
|
None => {
|
||||||
|
let Ok(reader) = stream.try_clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(shutdown) = stream.try_clone() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let (out_tx, out_rx) = mpsc::channel::<String>();
|
||||||
|
thread::spawn(move || writer_loop(stream, out_rx));
|
||||||
|
if core
|
||||||
|
.send(Event::Connect {
|
||||||
|
uid,
|
||||||
|
addr,
|
||||||
|
out: out_tx,
|
||||||
|
sock: shutdown,
|
||||||
|
secure: false,
|
||||||
|
link,
|
||||||
|
outbound: false,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break; // core gone
|
||||||
|
}
|
||||||
|
let core_tx = core.clone();
|
||||||
|
thread::spawn(move || reader_loop(reader, uid, core_tx));
|
||||||
|
}
|
||||||
|
Some(backend) => {
|
||||||
|
let backend = backend.clone();
|
||||||
|
let core_tx = core.clone();
|
||||||
|
thread::spawn(move || tls_conn(backend, stream, uid, addr, core_tx, link));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dial an outbound server link and wire it to the core (an `outbound` link that
|
||||||
|
/// introduces itself first). Used for auto-connecting to a configured uplink.
|
||||||
|
pub fn connect_link(addr: &str, core: Sender<Event>, counter: Arc<AtomicU64>) {
|
||||||
|
let stream = match TcpStream::connect(addr) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("[link] cannot dial {addr}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let _ = stream.set_nodelay(true);
|
||||||
|
let Ok(peer) = stream.peer_addr() else { return };
|
||||||
|
let Ok(reader) = stream.try_clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Ok(shutdown) = stream.try_clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let uid = counter.fetch_add(1, Ordering::Relaxed);
|
||||||
|
let (out_tx, out_rx) = mpsc::channel::<String>();
|
||||||
|
thread::spawn(move || writer_loop(stream, out_rx));
|
||||||
|
if core
|
||||||
|
.send(Event::Connect {
|
||||||
|
uid,
|
||||||
|
addr: peer,
|
||||||
|
out: out_tx,
|
||||||
|
sock: shutdown,
|
||||||
|
secure: false,
|
||||||
|
link: true,
|
||||||
|
outbound: true,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
thread::spawn(move || reader_loop(reader, uid, core));
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- plaintext: two blocking threads ----------------------------------------
|
||||||
|
|
||||||
|
fn reader_loop(stream: TcpStream, uid: Uid, core: Sender<Event>) {
|
||||||
|
let mut buf = BufReader::new(stream);
|
||||||
|
let mut line = String::new();
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
match buf.read_line(&mut line) {
|
||||||
|
Ok(0) => break, // EOF
|
||||||
|
Ok(_) => {
|
||||||
|
if line.len() > MAX_LINE {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let l = line.trim_end_matches(['\r', '\n']);
|
||||||
|
if l.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if core
|
||||||
|
.send(Event::Line {
|
||||||
|
uid,
|
||||||
|
line: l.to_string(),
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = core.send(Event::Disconnect { uid });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn writer_loop(mut stream: TcpStream, rx: Receiver<String>) {
|
||||||
|
// Ends when every sender (the user's `out`) is dropped by the core.
|
||||||
|
for line in rx {
|
||||||
|
if stream.write_all(line.as_bytes()).is_err() || stream.write_all(b"\r\n").is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = stream.shutdown(Shutdown::Both);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- TLS: one thread owning the session -------------------------------------
|
||||||
|
|
||||||
|
fn tls_conn(
|
||||||
|
backend: Arc<dyn TlsBackend>,
|
||||||
|
stream: TcpStream,
|
||||||
|
uid: Uid,
|
||||||
|
addr: SocketAddr,
|
||||||
|
core: Sender<Event>,
|
||||||
|
link: bool,
|
||||||
|
) {
|
||||||
|
// Keep a raw handle so the core can force the socket shut later.
|
||||||
|
let Ok(shutdown) = stream.try_clone() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut conn = match backend.accept(stream) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(_) => {
|
||||||
|
let _ = shutdown.shutdown(Shutdown::Both);
|
||||||
|
return; // handshake failed
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (out_tx, out_rx) = mpsc::channel::<String>();
|
||||||
|
if core
|
||||||
|
.send(Event::Connect {
|
||||||
|
uid,
|
||||||
|
addr,
|
||||||
|
out: out_tx,
|
||||||
|
sock: shutdown,
|
||||||
|
secure: true,
|
||||||
|
link,
|
||||||
|
outbound: false,
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
conn.shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = conn.set_read_timeout(Some(TLS_POLL));
|
||||||
|
let mut acc: Vec<u8> = Vec::new();
|
||||||
|
let mut chunk = [0u8; 4096];
|
||||||
|
'io: loop {
|
||||||
|
match conn.read(&mut chunk) {
|
||||||
|
Ok(0) => break, // EOF
|
||||||
|
Ok(n) => {
|
||||||
|
acc.extend_from_slice(&chunk[..n]);
|
||||||
|
while let Some(pos) = acc.iter().position(|&b| b == b'\n') {
|
||||||
|
let raw: Vec<u8> = acc.drain(..=pos).collect();
|
||||||
|
let text = String::from_utf8_lossy(&raw);
|
||||||
|
let l = text.trim_end_matches(['\r', '\n']);
|
||||||
|
if !l.is_empty()
|
||||||
|
&& core
|
||||||
|
.send(Event::Line {
|
||||||
|
uid,
|
||||||
|
line: l.to_string(),
|
||||||
|
})
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
break 'io;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if acc.len() > MAX_LINE {
|
||||||
|
acc.clear(); // overlong line with no newline: drop it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e)
|
||||||
|
if matches!(
|
||||||
|
e.kind(),
|
||||||
|
io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
|
||||||
|
) => {}
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
|
||||||
|
// drain everything the core queued for this connection
|
||||||
|
loop {
|
||||||
|
match out_rx.try_recv() {
|
||||||
|
Ok(line) => {
|
||||||
|
if conn.write_all(line.as_bytes()).is_err() || conn.write_all(b"\r\n").is_err()
|
||||||
|
{
|
||||||
|
break 'io;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(TryRecvError::Empty) => break,
|
||||||
|
Err(TryRecvError::Disconnected) => {
|
||||||
|
// the core dropped us (user removed); nothing more to do
|
||||||
|
conn.shutdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = conn.flush();
|
||||||
|
}
|
||||||
|
conn.shutdown();
|
||||||
|
let _ = core.send(Event::Disconnect { uid });
|
||||||
|
}
|
||||||
81
src/tls.rs
Normal file
81
src/tls.rs
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
//! TLS backends — echoIRCd's answer to InspIRCd's `IOHook` seam and its
|
||||||
|
//! `ssl_openssl` / `ssl_gnutls` modules. A [`TlsBackend`] wraps an accepted
|
||||||
|
//! socket in a TLS session; the socket engine then drives the resulting
|
||||||
|
//! [`TlsConn`] for any listener that has a backend attached.
|
||||||
|
//!
|
||||||
|
//! This backend is openssl. The `openssl` crate keeps all its `unsafe` internal,
|
||||||
|
//! so the daemon itself stays `#![forbid(unsafe_code)]`. A pure-Rust `rustls`
|
||||||
|
//! backend (or a gnutls one) only has to implement these same two traits and it
|
||||||
|
//! slots straight in — exactly the pluggable-provider shape InspIRCd uses.
|
||||||
|
|
||||||
|
use std::io::{self, Read, Write};
|
||||||
|
use std::net::{Shutdown, TcpStream};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use openssl::ssl::{SslAcceptor, SslFiletype, SslMethod, SslStream};
|
||||||
|
|
||||||
|
/// A live TLS connection: read/write plaintext, tune the read timeout (the
|
||||||
|
/// socket engine polls with one to interleave reads and queued writes), and shut
|
||||||
|
/// it down. The concrete backend type stays hidden behind this.
|
||||||
|
pub trait TlsConn: Send {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize>;
|
||||||
|
fn write_all(&mut self, buf: &[u8]) -> io::Result<()>;
|
||||||
|
fn flush(&mut self) -> io::Result<()>;
|
||||||
|
fn set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()>;
|
||||||
|
fn shutdown(&self);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A TLS backend: performs the server-side handshake on an accepted socket.
|
||||||
|
pub trait TlsBackend: Send + Sync {
|
||||||
|
fn accept(&self, sock: TcpStream) -> io::Result<Box<dyn TlsConn>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn err<E: std::fmt::Display>(e: E) -> io::Error {
|
||||||
|
io::Error::other(e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- openssl backend --------------------------------------------------------
|
||||||
|
|
||||||
|
pub struct OpensslBackend {
|
||||||
|
acceptor: SslAcceptor,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OpensslBackend {
|
||||||
|
/// Build an acceptor from a PEM certificate chain + private key.
|
||||||
|
pub fn new(cert: &str, key: &str) -> io::Result<OpensslBackend> {
|
||||||
|
let mut b = SslAcceptor::mozilla_intermediate(SslMethod::tls()).map_err(err)?;
|
||||||
|
b.set_private_key_file(key, SslFiletype::PEM).map_err(err)?;
|
||||||
|
b.set_certificate_chain_file(cert).map_err(err)?;
|
||||||
|
b.check_private_key().map_err(err)?;
|
||||||
|
Ok(OpensslBackend {
|
||||||
|
acceptor: b.build(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TlsBackend for OpensslBackend {
|
||||||
|
fn accept(&self, sock: TcpStream) -> io::Result<Box<dyn TlsConn>> {
|
||||||
|
let stream = self.acceptor.accept(sock).map_err(err)?;
|
||||||
|
Ok(Box::new(OpensslConn(stream)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct OpensslConn(SslStream<TcpStream>);
|
||||||
|
|
||||||
|
impl TlsConn for OpensslConn {
|
||||||
|
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
|
||||||
|
self.0.read(buf)
|
||||||
|
}
|
||||||
|
fn write_all(&mut self, buf: &[u8]) -> io::Result<()> {
|
||||||
|
self.0.write_all(buf)
|
||||||
|
}
|
||||||
|
fn flush(&mut self) -> io::Result<()> {
|
||||||
|
self.0.flush()
|
||||||
|
}
|
||||||
|
fn set_read_timeout(&self, dur: Option<Duration>) -> io::Result<()> {
|
||||||
|
self.0.get_ref().set_read_timeout(dur)
|
||||||
|
}
|
||||||
|
fn shutdown(&self) {
|
||||||
|
let _ = self.0.get_ref().shutdown(Shutdown::Both);
|
||||||
|
}
|
||||||
|
}
|
||||||
470
src/users.rs
Normal file
470
src/users.rs
Normal file
|
|
@ -0,0 +1,470 @@
|
||||||
|
//! Users: the `User` record plus nick handling, user modes, oper status and the
|
||||||
|
//! registration/welcome burst — the same job InspIRCd splits across users/
|
||||||
|
//! usermanager, written from scratch in Rust (InspIRCd is a behaviour reference).
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::net::{SocketAddr, TcpStream};
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use crate::extensible::Extensible;
|
||||||
|
use crate::module::Hook;
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::{Server, VERSION};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
/// User modes and session flags. Kept in one `Default` bag so adding a mode
|
||||||
|
/// doesn't ripple through every `User { .. }` constructor.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct UserFlags {
|
||||||
|
pub oper: bool, // +o (granted by OPER only)
|
||||||
|
pub invisible: bool, // +i
|
||||||
|
pub wallops: bool, // +w (receives WALLOPS)
|
||||||
|
pub cloak: bool, // +x (host cloak shown; see modules::cloak)
|
||||||
|
pub bot: bool, // +B (marked as a bot; WHOIS 335)
|
||||||
|
pub deaf: bool, // +D (doesn't receive channel messages)
|
||||||
|
pub hidechans: bool, // +I (channels hidden in WHOIS)
|
||||||
|
pub hideoper: bool, // +H (oper status hidden in WHOIS)
|
||||||
|
pub logged_in: bool, // +r (logged into an account; services-managed)
|
||||||
|
pub reg_only_pm: bool, // +R (only accept PMs from logged-in users)
|
||||||
|
pub ssl_pm: bool, // +z (only accept PMs from TLS users)
|
||||||
|
pub snomask: bool, // +s (oper: receive server notices)
|
||||||
|
pub away: Option<String>, // AWAY message, if set
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserFlags {
|
||||||
|
pub fn umodes(&self) -> String {
|
||||||
|
let mut s = String::from("+");
|
||||||
|
if self.invisible {
|
||||||
|
s.push('i');
|
||||||
|
}
|
||||||
|
if self.wallops {
|
||||||
|
s.push('w');
|
||||||
|
}
|
||||||
|
if self.oper {
|
||||||
|
s.push('o');
|
||||||
|
}
|
||||||
|
if self.cloak {
|
||||||
|
s.push('x');
|
||||||
|
}
|
||||||
|
if self.bot {
|
||||||
|
s.push('B');
|
||||||
|
}
|
||||||
|
if self.deaf {
|
||||||
|
s.push('D');
|
||||||
|
}
|
||||||
|
if self.hidechans {
|
||||||
|
s.push('I');
|
||||||
|
}
|
||||||
|
if self.hideoper {
|
||||||
|
s.push('H');
|
||||||
|
}
|
||||||
|
if self.logged_in {
|
||||||
|
s.push('r');
|
||||||
|
}
|
||||||
|
if self.reg_only_pm {
|
||||||
|
s.push('R');
|
||||||
|
}
|
||||||
|
if self.ssl_pm {
|
||||||
|
s.push('z');
|
||||||
|
}
|
||||||
|
if self.snomask {
|
||||||
|
s.push('s');
|
||||||
|
}
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The IRCv3 capabilities echoIRCd advertises. Order = the CAP LS order.
|
||||||
|
pub const SUPPORTED_CAPS: &[&str] = &[
|
||||||
|
"sasl",
|
||||||
|
"server-time",
|
||||||
|
"message-tags",
|
||||||
|
"multi-prefix",
|
||||||
|
"away-notify",
|
||||||
|
"account-notify",
|
||||||
|
"extended-join",
|
||||||
|
"chghost",
|
||||||
|
"userhost-in-names",
|
||||||
|
"echo-message",
|
||||||
|
"invite-notify",
|
||||||
|
"setname",
|
||||||
|
"extended-monitor",
|
||||||
|
"cap-notify",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Per-connection IRCv3 capability state — echoIRCd's answer to InspIRCd's `m_cap`
|
||||||
|
/// plus the individual `m_ircv3_*` modules, as one flat set (not a plugin per cap).
|
||||||
|
/// Toggled by `CAP REQ`; consulted wherever a line is formatted per-client.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Caps {
|
||||||
|
pub sasl: bool,
|
||||||
|
pub server_time: bool,
|
||||||
|
pub message_tags: bool,
|
||||||
|
pub multi_prefix: bool,
|
||||||
|
pub away_notify: bool,
|
||||||
|
pub account_notify: bool,
|
||||||
|
pub extended_join: bool,
|
||||||
|
pub chghost: bool,
|
||||||
|
pub userhost_in_names: bool,
|
||||||
|
pub echo_message: bool,
|
||||||
|
pub invite_notify: bool,
|
||||||
|
pub setname: bool,
|
||||||
|
pub extended_monitor: bool, // route away/account/chghost/setname for MONITOR targets
|
||||||
|
pub cap_notify: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Caps {
|
||||||
|
pub fn is_known(name: &str) -> bool {
|
||||||
|
SUPPORTED_CAPS.contains(&name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `CAP LS` token list; `sasl` carries its mechanisms for 302 clients.
|
||||||
|
pub fn ls_line(cap302: bool) -> String {
|
||||||
|
SUPPORTED_CAPS
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
if *c == "sasl" && cap302 {
|
||||||
|
"sasl=PLAIN".to_string()
|
||||||
|
} else {
|
||||||
|
(*c).to_string()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has(&self, name: &str) -> bool {
|
||||||
|
match name {
|
||||||
|
"sasl" => self.sasl,
|
||||||
|
"server-time" => self.server_time,
|
||||||
|
"message-tags" => self.message_tags,
|
||||||
|
"multi-prefix" => self.multi_prefix,
|
||||||
|
"away-notify" => self.away_notify,
|
||||||
|
"account-notify" => self.account_notify,
|
||||||
|
"extended-join" => self.extended_join,
|
||||||
|
"chghost" => self.chghost,
|
||||||
|
"userhost-in-names" => self.userhost_in_names,
|
||||||
|
"echo-message" => self.echo_message,
|
||||||
|
"invite-notify" => self.invite_notify,
|
||||||
|
"setname" => self.setname,
|
||||||
|
"extended-monitor" => self.extended_monitor,
|
||||||
|
"cap-notify" => self.cap_notify,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable/disable a cap by name; returns whether the name was recognised.
|
||||||
|
pub fn set(&mut self, name: &str, on: bool) -> bool {
|
||||||
|
let field = match name {
|
||||||
|
"sasl" => &mut self.sasl,
|
||||||
|
"server-time" => &mut self.server_time,
|
||||||
|
"message-tags" => &mut self.message_tags,
|
||||||
|
"multi-prefix" => &mut self.multi_prefix,
|
||||||
|
"away-notify" => &mut self.away_notify,
|
||||||
|
"account-notify" => &mut self.account_notify,
|
||||||
|
"extended-join" => &mut self.extended_join,
|
||||||
|
"chghost" => &mut self.chghost,
|
||||||
|
"userhost-in-names" => &mut self.userhost_in_names,
|
||||||
|
"echo-message" => &mut self.echo_message,
|
||||||
|
"invite-notify" => &mut self.invite_notify,
|
||||||
|
"setname" => &mut self.setname,
|
||||||
|
"extended-monitor" => &mut self.extended_monitor,
|
||||||
|
"cap-notify" => &mut self.cap_notify,
|
||||||
|
_ => return false,
|
||||||
|
};
|
||||||
|
*field = on;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Space-separated list of the currently-enabled caps (for `CAP LIST`).
|
||||||
|
pub fn enabled(&self) -> String {
|
||||||
|
SUPPORTED_CAPS
|
||||||
|
.iter()
|
||||||
|
.filter(|c| self.has(c))
|
||||||
|
.copied()
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct User {
|
||||||
|
pub uid: Uid,
|
||||||
|
pub uuid: String, // network-wide id (SID + 6) for S2S
|
||||||
|
pub nick: String, // "" until NICK
|
||||||
|
pub ident: String, // "" until USER
|
||||||
|
pub realname: String,
|
||||||
|
pub host: String, // real host (ip string; no rDNS)
|
||||||
|
pub cloak: String, // masked host shown under +x ("" until computed)
|
||||||
|
pub vhost: Option<String>, // displayed-host override (CHGHOST/SETHOST vhost)
|
||||||
|
pub secure: bool, // connected over TLS (drives WHOIS 671 / sslinfo)
|
||||||
|
pub account: Option<String>, // logged-in account name (set by services)
|
||||||
|
pub signon: u64, // unix secs at registration (WHOIS 317)
|
||||||
|
pub addr: SocketAddr,
|
||||||
|
pub registered: bool,
|
||||||
|
pub cap: bool, // CAP negotiation in progress (holds registration)
|
||||||
|
pub cap_302: bool, // client sent CAP LS 302 (cap-notify aware)
|
||||||
|
pub caps: Caps, // enabled IRCv3 capabilities
|
||||||
|
pub sasl_mech: Option<String>, // SASL mechanism chosen, mid-handshake
|
||||||
|
pub channels: HashSet<String>, // lowercased channel keys
|
||||||
|
pub watch: Vec<String>, // WATCH list — lowercased nicks
|
||||||
|
pub monitor: Vec<String>, // MONITOR list — lowercased nicks
|
||||||
|
pub silence: Vec<String>, // SILENCE masks — nick!user@host globs
|
||||||
|
pub quitting: Option<String>, // set by QUIT; drained by the core
|
||||||
|
pub flags: UserFlags,
|
||||||
|
pub last_active: u64, // unix secs of the last line we received
|
||||||
|
pub ping_sent: bool, // a server PING is outstanding
|
||||||
|
pub ext: Extensible, // typed, module-owned per-user metadata
|
||||||
|
pub out: Sender<String>,
|
||||||
|
pub sock: Option<TcpStream>, // core-side fd handle; dropped on quit so the
|
||||||
|
// writer thread flushes then closes (None in tests)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl User {
|
||||||
|
/// The host others see: an explicit vhost (CHGHOST/SETHOST) wins, then the
|
||||||
|
/// cloak when +x is set (and one was computed), otherwise the real host.
|
||||||
|
/// Everything that broadcasts a prefix — JOIN, QUIT, NICK, PRIVMSG source, ban
|
||||||
|
/// matching — goes through here, so the displayed host is consistent for free.
|
||||||
|
pub fn host_display(&self) -> &str {
|
||||||
|
if let Some(v) = &self.vhost {
|
||||||
|
v
|
||||||
|
} else if self.flags.cloak && !self.cloak.is_empty() {
|
||||||
|
&self.cloak
|
||||||
|
} else {
|
||||||
|
&self.host
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn prefix(&self) -> String {
|
||||||
|
format!("{}!{}@{}", self.nick, self.ident, self.host_display())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
pub fn find_nick(&self, nick: &str) -> Option<Uid> {
|
||||||
|
self.nick_index.get(&nick.to_ascii_lowercase()).copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_oper(&self, uid: Uid) -> bool {
|
||||||
|
self.users.get(&uid).map(|u| u.flags.oper).unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Grant IRC-operator status and tell the user.
|
||||||
|
pub fn oper_up(&mut self, uid: Uid) {
|
||||||
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
|
u.flags.oper = true;
|
||||||
|
u.flags.snomask = true; // opers get server notices by default
|
||||||
|
}
|
||||||
|
let nick = self
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.numeric(uid, RPL_YOUREOPER, ":You are now an IRC operator");
|
||||||
|
self.send(uid, format!(":{} MODE {nick} :+os", self.name));
|
||||||
|
self.snotice(&format!("{nick} is now an IRC operator"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send a WALLOPS to every oper and every +w user.
|
||||||
|
pub fn wallops(&self, from: &str, text: &str) {
|
||||||
|
let line = format!(":{from} WALLOPS :{text}");
|
||||||
|
let targets: Vec<Uid> = self
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, u)| u.flags.oper || u.flags.wallops)
|
||||||
|
.map(|(&uid, _)| uid)
|
||||||
|
.collect();
|
||||||
|
for uid in targets {
|
||||||
|
self.send(uid, line.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set or change a user's nick, keeping the index in sync and broadcasting
|
||||||
|
/// the change to the user + everyone in their channels once registered.
|
||||||
|
pub fn set_nick(&mut self, uid: Uid, newnick: &str) {
|
||||||
|
let (old, registered, prefix, ident, host, realname, account) = match self.users.get(&uid) {
|
||||||
|
Some(u) => (
|
||||||
|
u.nick.clone(),
|
||||||
|
u.registered,
|
||||||
|
u.prefix(),
|
||||||
|
u.ident.clone(),
|
||||||
|
u.host_display().to_string(),
|
||||||
|
u.realname.clone(),
|
||||||
|
u.account.clone(),
|
||||||
|
),
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
if registered {
|
||||||
|
self.push_whowas(&old, &ident, &host, &realname, account);
|
||||||
|
}
|
||||||
|
if !old.is_empty() {
|
||||||
|
self.nick_index.remove(&old.to_ascii_lowercase());
|
||||||
|
}
|
||||||
|
self.nick_index.insert(newnick.to_ascii_lowercase(), uid);
|
||||||
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
|
u.nick = newnick.to_string();
|
||||||
|
}
|
||||||
|
if registered {
|
||||||
|
let line = format!(":{prefix} NICK :{newnick}");
|
||||||
|
let mut targets: HashSet<Uid> = HashSet::new();
|
||||||
|
targets.insert(uid);
|
||||||
|
let chans: Vec<String> = self.users[&uid].channels.iter().cloned().collect();
|
||||||
|
for key in &chans {
|
||||||
|
if let Some(ch) = self.channels.get(key) {
|
||||||
|
for &m in ch.members.keys() {
|
||||||
|
targets.insert(m);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for t in targets {
|
||||||
|
self.send(t, line.clone());
|
||||||
|
}
|
||||||
|
// WATCH/MONITOR: the old nick is now gone, the new one is here
|
||||||
|
self.watch_notify_offline(&old);
|
||||||
|
self.watch_notify_online(newnick);
|
||||||
|
}
|
||||||
|
self.propagate_nick(uid, newnick); // tell linked servers
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Finish registration: send the welcome burst + MOTD and queue the connect
|
||||||
|
/// hook. The core calls this once NICK, USER and CAP are all satisfied.
|
||||||
|
pub fn welcome(&mut self, uid: Uid) {
|
||||||
|
if let Some(u) = self.users.get_mut(&uid) {
|
||||||
|
u.registered = true;
|
||||||
|
}
|
||||||
|
let nick = self
|
||||||
|
.users
|
||||||
|
.get(&uid)
|
||||||
|
.map(|u| u.nick.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_WELCOME,
|
||||||
|
&format!(":Welcome to the {} IRC Network, {nick}", self.network),
|
||||||
|
);
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_YOURHOST,
|
||||||
|
&format!(":Your host is {}, running echoircd-{VERSION}", self.name),
|
||||||
|
);
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_CREATED,
|
||||||
|
&format!(":This server was created at unix {}", self.created),
|
||||||
|
);
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_MYINFO,
|
||||||
|
&format!(
|
||||||
|
"{} echoircd-{VERSION} iowxsBDIHrRz qaohvbeIklimnpstzCTcSNORMfjFLgGu",
|
||||||
|
self.name
|
||||||
|
),
|
||||||
|
);
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_ISUPPORT,
|
||||||
|
&format!(
|
||||||
|
"CHANTYPES=# PREFIX=(qaohv)~&@%+ CHANMODES=beIg,k,lfjFL,CGMNORSTcimnpstuz EXTBAN=,cmn WATCH=128 MONITOR=128 SILENCE=32 CASEMAPPING=ascii NICKLEN=30 CHANNELLEN=50 NETWORK={} :are supported by this server",
|
||||||
|
self.network
|
||||||
|
),
|
||||||
|
);
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_LUSERCLIENT,
|
||||||
|
&format!(":There are {} users on 1 server", self.users.len()),
|
||||||
|
);
|
||||||
|
self.send_motd(uid);
|
||||||
|
self.watch_notify_online(&nick); // tell WATCH/MONITOR watchers
|
||||||
|
self.events.push_back(Hook::Connect(uid));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_motd(&self, uid: Uid) {
|
||||||
|
if self.motd.is_empty() {
|
||||||
|
self.numeric(uid, ERR_NOMOTD, ":MOTD File is missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_MOTDSTART,
|
||||||
|
&format!(":- {} Message of the day -", self.name),
|
||||||
|
);
|
||||||
|
for line in &self.motd {
|
||||||
|
self.numeric(uid, RPL_MOTD, &format!(":- {line}"));
|
||||||
|
}
|
||||||
|
self.numeric(uid, RPL_ENDOFMOTD, ":End of /MOTD command.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A nick is 1–30 chars: first is a letter or `[]\`_^{}|`, rest add digits/`-`.
|
||||||
|
/// A syntactically valid hostname for CHGHOST/SETHOST: letters, digits, `.` `-`
|
||||||
|
/// `_` `/` (the last two allowed for cloak-style vhosts), 1..=64 chars.
|
||||||
|
pub fn valid_host(h: &str) -> bool {
|
||||||
|
!h.is_empty()
|
||||||
|
&& h.len() <= 64
|
||||||
|
&& h.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '/'))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A syntactically valid ident/username for CHGIDENT/SETIDENT.
|
||||||
|
pub fn valid_ident(i: &str) -> bool {
|
||||||
|
!i.is_empty()
|
||||||
|
&& i.len() <= 20
|
||||||
|
&& i.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn valid_nick(n: &str) -> bool {
|
||||||
|
let special = |c: char| "[]\\`_^{}|".contains(c);
|
||||||
|
let mut chars = n.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(c) if c.is_ascii_alphabetic() || special(c) => {}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
n.len() <= 30
|
||||||
|
&& n.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || special(c) || c == '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn a USER-supplied username into a safe ident (≤ 10 chars, no funny bytes).
|
||||||
|
pub fn ident_of(user: &str) -> String {
|
||||||
|
let s: String = user
|
||||||
|
.chars()
|
||||||
|
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_' || *c == '.')
|
||||||
|
.take(10)
|
||||||
|
.collect();
|
||||||
|
if s.is_empty() {
|
||||||
|
"user".to_string()
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn caps_set_has_enabled_and_ls() {
|
||||||
|
let mut c = Caps::default();
|
||||||
|
assert!(c.set("server-time", true));
|
||||||
|
assert!(c.set("multi-prefix", true));
|
||||||
|
assert!(!c.set("bogus-cap", true)); // unknown cap rejected
|
||||||
|
assert!(c.has("server-time") && c.has("multi-prefix") && !c.has("sasl"));
|
||||||
|
assert_eq!(c.enabled(), "server-time multi-prefix"); // SUPPORTED order
|
||||||
|
assert!(Caps::ls_line(true).contains("sasl=PLAIN")); // 302 shows mechs
|
||||||
|
assert!(Caps::ls_line(false).contains("sasl") && !Caps::ls_line(false).contains("sasl="));
|
||||||
|
c.set("server-time", false);
|
||||||
|
assert!(!c.has("server-time"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn host_ident_validators() {
|
||||||
|
assert!(valid_host("cloaked-a1b2.users.echo"));
|
||||||
|
assert!(valid_host("some/vhost"));
|
||||||
|
assert!(!valid_host("bad host")); // space
|
||||||
|
assert!(!valid_host("")); // empty
|
||||||
|
assert!(!valid_host(&"x".repeat(65))); // too long
|
||||||
|
assert!(valid_ident("reverse"));
|
||||||
|
assert!(!valid_ident("re verse")); // space
|
||||||
|
assert!(!valid_ident("")); // empty
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/watch.rs
Normal file
84
src/watch.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
//! WATCH / MONITOR notification plumbing + SILENCE matching.
|
||||||
|
//!
|
||||||
|
//! The lists themselves live on the [`crate::users::User`] (`watch` / `monitor` /
|
||||||
|
//! `silence`); the commands are in [`crate::coremods::core_watch`]. Whenever a
|
||||||
|
//! nick's online-state flips — registration, quit, or a nick change — the
|
||||||
|
//! lifecycle code calls [`Server::watch_notify_online`] / [`Server::watch_notify_offline`],
|
||||||
|
//! which scan for anyone WATCHing/MONITORing that nick and send the right numeric.
|
||||||
|
//!
|
||||||
|
//! An O(users) scan, not a reverse index: correct-by-construction (nothing to keep
|
||||||
|
//! in sync) and fine on a small server — a reverse index can slot in later if it
|
||||||
|
//! ever needs to scale, exactly the kind of change the borrow checker makes safe.
|
||||||
|
|
||||||
|
use crate::channels::glob_match;
|
||||||
|
use crate::numeric::*;
|
||||||
|
use crate::server::{now, Server};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
pub const WATCH_MAX: usize = 128;
|
||||||
|
pub const MONITOR_MAX: usize = 128;
|
||||||
|
pub const SILENCE_MAX: usize = 32;
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// A nick just came online (registered, or someone renamed to it): tell its
|
||||||
|
/// WATCHers (600 RPL_LOGON) and MONITORers (730 RPL_MONONLINE).
|
||||||
|
pub fn watch_notify_online(&self, nick: &str) {
|
||||||
|
let low = nick.to_ascii_lowercase();
|
||||||
|
let Some((dnick, ident, host, ts)) = self.find_nick(nick).and_then(|tu| {
|
||||||
|
self.users.get(&tu).map(|x| {
|
||||||
|
(
|
||||||
|
x.nick.clone(),
|
||||||
|
x.ident.clone(),
|
||||||
|
x.host_display().to_string(),
|
||||||
|
x.signon,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (&uid, u) in &self.users {
|
||||||
|
if u.watch.contains(&low) {
|
||||||
|
self.numeric(
|
||||||
|
uid,
|
||||||
|
RPL_LOGON,
|
||||||
|
&format!("{dnick} {ident} {host} {ts} :is now online"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if u.monitor.contains(&low) {
|
||||||
|
self.numeric(uid, RPL_MONONLINE, &format!(":{dnick}!{ident}@{host}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A nick just went offline (quit, or renamed away): tell its WATCHers
|
||||||
|
/// (601 RPL_LOGOFF) and MONITORers (731 RPL_MONOFFLINE).
|
||||||
|
pub fn watch_notify_offline(&self, nick: &str) {
|
||||||
|
let low = nick.to_ascii_lowercase();
|
||||||
|
let ts = now();
|
||||||
|
for (&uid, u) in &self.users {
|
||||||
|
if u.watch.contains(&low) {
|
||||||
|
self.numeric(uid, RPL_LOGOFF, &format!("{nick} * * {ts} :is now offline"));
|
||||||
|
}
|
||||||
|
if u.monitor.contains(&low) {
|
||||||
|
self.numeric(uid, RPL_MONOFFLINE, &format!(":{nick}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many users currently WATCH `nick` (for `WATCH S` stats).
|
||||||
|
pub fn watchers_of(&self, nick: &str) -> usize {
|
||||||
|
let low = nick.to_ascii_lowercase();
|
||||||
|
self.users
|
||||||
|
.values()
|
||||||
|
.filter(|u| u.watch.contains(&low))
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True if user `by` has silenced someone whose prefix is `sender_mask`.
|
||||||
|
pub fn is_silenced(&self, by: Uid, sender_mask: &str) -> bool {
|
||||||
|
self.users
|
||||||
|
.get(&by)
|
||||||
|
.map(|u| u.silence.iter().any(|m| glob_match(m, sender_mask)))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
140
src/xline.rs
Normal file
140
src/xline.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
//! X-lines — server bans (InspIRCd's `m_xline`): KLINE/GLINE on `user@host`,
|
||||||
|
//! ZLINE on an IP. Matched at registration (a banned client is refused) and when
|
||||||
|
//! the line is added (matching clients are killed); expired lines are reaped on
|
||||||
|
//! the tick. Kept in `Server.xlines`.
|
||||||
|
|
||||||
|
use crate::channels::glob_match;
|
||||||
|
use crate::server::{now, Server};
|
||||||
|
use crate::Uid;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum XKind {
|
||||||
|
Kline, // user@host, this server
|
||||||
|
Gline, // user@host, "global" (locally the same until services span it)
|
||||||
|
Zline, // an IP address
|
||||||
|
}
|
||||||
|
|
||||||
|
impl XKind {
|
||||||
|
pub fn tag(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
XKind::Kline => "K",
|
||||||
|
XKind::Gline => "G",
|
||||||
|
XKind::Zline => "Z",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct XLine {
|
||||||
|
pub kind: XKind,
|
||||||
|
pub mask: String, // user@host glob (K/G) or ip glob (Z)
|
||||||
|
pub reason: String,
|
||||||
|
pub setter: String,
|
||||||
|
pub expires: u64, // 0 = permanent
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a duration: bare number = seconds; `s`/`m`/`h`/`d`/`w` suffixes; `0`/"" = permanent.
|
||||||
|
pub fn parse_duration(s: &str) -> Option<u64> {
|
||||||
|
if s.is_empty() || s == "0" {
|
||||||
|
return Some(0);
|
||||||
|
}
|
||||||
|
let last = s.chars().last()?;
|
||||||
|
if last.is_ascii_digit() {
|
||||||
|
return s.parse::<u64>().ok();
|
||||||
|
}
|
||||||
|
let n: u64 = s[..s.len() - 1].parse().ok()?;
|
||||||
|
let mul = match last {
|
||||||
|
's' => 1,
|
||||||
|
'm' => 60,
|
||||||
|
'h' => 3600,
|
||||||
|
'd' => 86400,
|
||||||
|
'w' => 604800,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some(n.saturating_mul(mul))
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Server {
|
||||||
|
/// The reason a `user@host` / `ip` is banned by an active x-line, if any.
|
||||||
|
pub fn matched_xline(&self, ident: &str, host: &str, ip: &str) -> Option<String> {
|
||||||
|
let uh = format!("{ident}@{host}");
|
||||||
|
let n = now();
|
||||||
|
for x in &self.xlines {
|
||||||
|
if x.expires != 0 && x.expires <= n {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let hit = match x.kind {
|
||||||
|
XKind::Zline => glob_match(&x.mask, ip),
|
||||||
|
_ => glob_match(&x.mask, &uh),
|
||||||
|
};
|
||||||
|
if hit {
|
||||||
|
return Some(format!("{}-lined: {}", x.kind.tag(), x.reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add (or replace) an x-line, then kill every connected user it matches.
|
||||||
|
pub fn add_xline(
|
||||||
|
&mut self,
|
||||||
|
kind: XKind,
|
||||||
|
mask: &str,
|
||||||
|
duration: u64,
|
||||||
|
setter: &str,
|
||||||
|
reason: &str,
|
||||||
|
) {
|
||||||
|
let n = now();
|
||||||
|
self.xlines.retain(|x| !(x.kind == kind && x.mask == mask));
|
||||||
|
self.xlines.push(XLine {
|
||||||
|
kind,
|
||||||
|
mask: mask.to_string(),
|
||||||
|
reason: reason.to_string(),
|
||||||
|
setter: setter.to_string(),
|
||||||
|
expires: if duration == 0 { 0 } else { n + duration },
|
||||||
|
});
|
||||||
|
self.snotice(&format!(
|
||||||
|
"{setter} added a {}-line on {mask}: {reason}",
|
||||||
|
kind.tag()
|
||||||
|
));
|
||||||
|
self.enforce_xlines();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove an x-line by kind + mask; returns whether one was found.
|
||||||
|
pub fn remove_xline(&mut self, kind: XKind, mask: &str) -> bool {
|
||||||
|
let before = self.xlines.len();
|
||||||
|
self.xlines.retain(|x| !(x.kind == kind && x.mask == mask));
|
||||||
|
self.xlines.len() < before
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Kill every connected local user that now matches an active x-line.
|
||||||
|
pub fn enforce_xlines(&mut self) {
|
||||||
|
let candidates: Vec<(Uid, String, String, String)> = self
|
||||||
|
.users
|
||||||
|
.iter()
|
||||||
|
.filter(|(_, u)| u.registered)
|
||||||
|
.map(|(&uid, u)| {
|
||||||
|
(
|
||||||
|
uid,
|
||||||
|
u.ident.clone(),
|
||||||
|
u.host.clone(),
|
||||||
|
u.addr.ip().to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let victims: Vec<(Uid, String)> = candidates
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|(uid, ident, host, ip)| {
|
||||||
|
self.matched_xline(&ident, &host, &ip).map(|r| (uid, r))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for (uid, reason) in victims {
|
||||||
|
self.send(uid, format!("ERROR :Closing link: ({reason})"));
|
||||||
|
self.remove_user(uid, &reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop expired x-lines (called on the background tick).
|
||||||
|
pub fn purge_xlines(&mut self) {
|
||||||
|
let n = now();
|
||||||
|
self.xlines.retain(|x| x.expires == 0 || x.expires > n);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue