conn_waitpong: exempt loopback (per ipv4/ipv6) and per-class from the registration ping cookie

This commit is contained in:
Jean Chevronnet 2026-08-22 23:49:46 +00:00
parent 70a4ecb0a0
commit 5c286a94cb
No known key found for this signature in database
GPG key ID: 439666D63A9477E4
2 changed files with 34 additions and 4 deletions

View file

@ -2,13 +2,18 @@
//! 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)
//! conn_waitpong = yes # require the pong before registering (default off)
//! conn_waitpong_killonbadreply = yes # disconnect on a wrong pong (default: keep waiting)
//! conn_waitpong_exempt_localhost4 = yes # skip the cookie for 127.0.0.0/8 (default off)
//! conn_waitpong_exempt_localhost6 = yes # skip the cookie for ::1 (default off)
//! connectclass ... waitpongexempt=yes # skip the cookie for a whole class
//! ```
//!
//! The gate is the core `User.waitpong` field (checked in `try_register`); this
//! module arms it at connect and clears it on the matching PONG.
use std::net::IpAddr;
use crate::server::Server;
use crate::Uid;
@ -19,10 +24,29 @@ fn cookie() -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}
/// Whether this client is exempt from the cookie: a per-class opt-out, or a
/// loopback address whose family the config trusts. Both default off, so the
/// challenge still applies everywhere unless explicitly relaxed.
fn exempt(s: &Server, uid: Uid) -> bool {
if crate::modules::connclass::waitpong_exempt(s, uid) {
return true;
}
let Some(ip) = s.users.get(&uid).map(|u| u.addr.ip()) else {
return false;
};
match ip {
IpAddr::V4(a) if a.is_loopback() => s.conf_bool("conn_waitpong_exempt_localhost4", false),
IpAddr::V6(a) if a.is_loopback() => s.conf_bool("conn_waitpong_exempt_localhost6", false),
// a loopback client on an IPv6 listener can arrive v4-mapped (::ffff:127.0.0.1)
IpAddr::V6(a) => a.to_ipv4_mapped().is_some_and(|m| m.is_loopback()) && s.conf_bool("conn_waitpong_exempt_localhost4", false),
_ => false,
}
}
/// At connect: if enabled, stash a cookie on the user and PING it. `try_register`
/// will not complete while `User.waitpong` is set.
/// will not complete while `User.waitpong` is set. Exempt sources skip it.
pub fn arm(s: &mut Server, uid: Uid) {
if !s.conf_bool("conn_waitpong", false) {
if !s.conf_bool("conn_waitpong", false) || exempt(s, uid) {
return;
}
let c = cookie();

View file

@ -52,6 +52,7 @@ pub struct ConnClass {
pub requireident: bool, // refuse if the ident lookup fails
pub resolvehostnames: bool, // resolve rDNS for this class (default yes)
pub maxconnwarn: bool, // snotice opers when a limit refuses a client
pub waitpongexempt: bool, // skip the conn_waitpong cookie for this class
}
/// Split a `key=value` value on commas into non-empty pieces.
@ -91,6 +92,7 @@ fn apply(c: &mut ConnClass, k: &str, v: &str) {
"requireident" => c.requireident = v.eq_ignore_ascii_case("yes"),
"resolvehostnames" => c.resolvehostnames = !v.eq_ignore_ascii_case("no"),
"maxconnwarn" => c.maxconnwarn = v.eq_ignore_ascii_case("yes"),
"waitpongexempt" => c.waitpongexempt = v.eq_ignore_ascii_case("yes"),
_ => {}
}
}
@ -512,6 +514,10 @@ pub fn flood_over(s: &Server, uid: Uid) -> Option<(Option<usize>, Option<u64>, b
pub fn resolve_hostnames(s: &Server, uid: Uid) -> bool {
class_of(s, uid).map(|c| c.resolvehostnames).unwrap_or(true)
}
/// Whether this client's class opts out of the conn_waitpong cookie.
pub fn waitpong_exempt(s: &Server, uid: Uid) -> bool {
class_of(s, uid).map(|c| c.waitpongexempt).unwrap_or(false)
}
/// `(useident, requireident)` for this client's class.
pub fn ident_policy(s: &Server, uid: Uid) -> (bool, bool) {
class_of(s, uid)