import echoircd — from-scratch irc daemon in native rust

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

View file

@ -0,0 +1,371 @@
//! antimixedutf8 — blocks spam that mixes Unicode scripts within words (Latin
//! letters swapped for Cyrillic/Greek look-alikes: "e 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 ("𝐅𝐫𝐞𝐞", "", "🅵🆁🅴🅴") — 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(" ") >= 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
View 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
View 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
View 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
View 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})"));
}
}
}