From 61954f6c5ce6d2371ac15d9e4c4569fe631df195 Mon Sep 17 00:00:00 2001 From: reverse Date: Sun, 9 Aug 2026 23:04:14 +0000 Subject: [PATCH] conn_waitpong: optionally require a PONG cookie before registration (anti-bot) --- echoircd.conf.example | 5 ++++ src/coremods/core_user.rs | 6 ++-- src/ircd.rs | 1 + src/modules/conn_waitpong.rs | 54 ++++++++++++++++++++++++++++++++++++ src/modules/mod.rs | 1 + src/server.rs | 4 +++ src/users.rs | 1 + 7 files changed, 70 insertions(+), 2 deletions(-) create mode 100644 src/modules/conn_waitpong.rs diff --git a/echoircd.conf.example b/echoircd.conf.example index 230fdc6..207de2d 100644 --- a/echoircd.conf.example +++ b/echoircd.conf.example @@ -117,6 +117,11 @@ amu_target = both # hidewhois_hide_server = yes # hide 312 # hidewhois_hide_idle = yes # hide 317 # hidewhois_hide_secure = yes # hide 671 +# --- conn_waitpong (m_conn_waitpong): hold registration until the client answers +# a server PING with the exact cookie — filters bots that never PONG. Real +# clients auto-reply, so it's transparent to them. +# conn_waitpong = yes +# conn_waitpong_killonbadreply = yes # drop on a wrong pong (default: keep waiting) # --- showfile (m_showfile): serve a text file as its own command. One line per # file: `showfile = `. The file is read fresh each use, so # edits show without a rehash. e.g. make /RULES stream a rules file: diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index da7501e..506bd0d 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -555,8 +555,10 @@ impl Command for 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 + fn handle(&self, s: &mut Server, uid: Uid, params: &[String]) -> CmdResult { + // conn_waitpong: a pre-registration PONG may be answering our cookie + crate::modules::conn_waitpong::on_pong(s, uid, params); + CmdResult::Ok // otherwise just a keepalive } } diff --git a/src/ircd.rs b/src/ircd.rs index 05645cd..8ebcd30 100644 --- a/src/ircd.rs +++ b/src/ircd.rs @@ -384,6 +384,7 @@ impl Ircd { && !u.ident.is_empty() && !u.cap && !u.dns_pending + && u.waitpong.is_none() }) .unwrap_or(false); if ready { diff --git a/src/modules/conn_waitpong.rs b/src/modules/conn_waitpong.rs new file mode 100644 index 0000000..45107f2 --- /dev/null +++ b/src/modules/conn_waitpong.rs @@ -0,0 +1,54 @@ +//! conn_waitpong — optionally hold registration until the client answers a server +//! PING with the exact cookie we sent, filtering bots that never PONG. Config: +//! +//! ```text +//! conn_waitpong = yes # require the pong before registering (default off) +//! conn_waitpong_killonbadreply = yes # disconnect on a wrong pong (default: keep waiting) +//! ``` +//! +//! The gate itself is the core `User.waitpong` field (checked in `try_register`, +//! like `dns_pending`); this module just arms it at connect and clears it on the +//! matching PONG. Behaviour reference: InspIRCd's `m_conn_waitpong`. Native Rust. + +use crate::server::Server; +use crate::Uid; + +/// A short random cookie the client must echo back in its PONG. +fn cookie() -> String { + let mut b = [0u8; 8]; + let _ = openssl::rand::rand_bytes(&mut b); + b.iter().map(|x| format!("{x:02x}")).collect() +} + +/// At connect: if enabled, stash a cookie on the user and PING it. `try_register` +/// will not complete while `User.waitpong` is set. +pub fn arm(s: &mut Server, uid: Uid) { + if !s.conf_bool("conn_waitpong", false) { + return; + } + let c = cookie(); + if let Some(u) = s.users.get_mut(&uid) { + u.waitpong = Some(c.clone()); + } + s.send(uid, format!(":{} PING :{c}", s.name)); +} + +/// On PONG: clear the gate if the cookie matches. On a wrong reply, optionally drop +/// the client (else keep waiting — a real client will retry on the next PING). +pub fn on_pong(s: &mut Server, uid: Uid, params: &[String]) { + let Some(want) = s.users.get(&uid).and_then(|u| u.waitpong.clone()) else { + return; // not waiting (already satisfied, or feature off) + }; + let got = params.last().map(String::as_str).unwrap_or(""); + if got == want { + if let Some(u) = s.users.get_mut(&uid) { + u.waitpong = None; + } + } else if s.conf_bool("conn_waitpong_killonbadreply", false) { + s.send( + uid, + "ERROR :Closing link (incorrect ping reply)".to_string(), + ); + s.remove_user(uid, "Incorrect ping reply"); + } +} diff --git a/src/modules/mod.rs b/src/modules/mod.rs index 7675066..e542bd4 100644 --- a/src/modules/mod.rs +++ b/src/modules/mod.rs @@ -12,6 +12,7 @@ pub mod channelban; pub mod chathistory; pub mod cloak; pub mod cloudflare_challenge; +pub mod conn_waitpong; pub mod connectban; pub mod connflood; pub mod customtitle; diff --git a/src/server.rs b/src/server.rs index a968729..a8d4e68 100644 --- a/src/server.rs +++ b/src/server.rs @@ -318,6 +318,7 @@ impl Server { addr, registered: false, dns_pending: false, + waitpong: None, deferred: Vec::new(), cap: false, cap_302: false, @@ -393,6 +394,8 @@ impl Server { "Couldn't look up your hostname; using your IP address instead", ); } + // conn_waitpong: optionally hold registration until the client PONGs a cookie + crate::modules::conn_waitpong::arm(self, uid); } /// Fire an HTTP POST on a worker thread and deliver `(status, body)` back to @@ -1039,6 +1042,7 @@ mod tests { addr: "127.0.0.1:1".parse().unwrap(), registered: true, dns_pending: false, + waitpong: None, deferred: Vec::new(), cap: false, cap_302: false, diff --git a/src/users.rs b/src/users.rs index 63c36d1..56fba67 100644 --- a/src/users.rs +++ b/src/users.rs @@ -286,6 +286,7 @@ pub struct User { pub addr: SocketAddr, pub registered: bool, pub dns_pending: bool, // holding registration for a reverse-DNS lookup + pub waitpong: Option, // conn_waitpong: cookie the client must PONG before registering pub deferred: Vec, // handshake lines held while dns_pending (replayed after) pub cap: bool, // CAP negotiation in progress (holds registration) pub cap_302: bool, // client sent CAP LS 302 (cap-notify aware)