sasl relay over s2s: authenticate plain forwarded to services (sasl_server), 900/903 on success; fails clean with no services

This commit is contained in:
Jean Chevronnet 2026-08-08 19:28:53 +00:00
parent 26c9833b7b
commit 8d31feb4e7
5 changed files with 125 additions and 15 deletions

View file

@ -74,6 +74,7 @@ pub struct Config {
pub dnsbl_zones: Vec<String>, // DNS blocklist zones to check on connect
pub dnsbl_action: String, // mark | kline | gline | zline (on a hit)
pub dnsbl_reason: String, // ban reason for a DNSBL hit
pub sasl_server: String, // linked services server that handles SASL ("" = none)
}
impl Default for Config {
@ -100,6 +101,7 @@ impl Default for Config {
dnsbl_zones: Vec::new(),
dnsbl_action: "mark".to_string(),
dnsbl_reason: "Your host is listed in a DNS blocklist".to_string(),
sasl_server: String::new(),
}
}
}
@ -232,6 +234,7 @@ impl Config {
}
"dnsbl_action" => c.dnsbl_action = v.to_ascii_lowercase(),
"dnsbl_reason" => c.dnsbl_reason = v.to_string(),
"sasl_server" | "sasl_target" => c.sasl_server = v.to_string(),
_ => {}
}
}

View file

@ -66,6 +66,7 @@ impl Command for Rehash {
s.dnsbl_zones = fresh.dnsbl_zones;
s.dnsbl_action = fresh.dnsbl_action;
s.dnsbl_reason = fresh.dnsbl_reason;
s.sasl_server = fresh.sasl_server;
s.announce("Server configuration reloaded.");
s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing"));
}

View file

@ -159,18 +159,24 @@ impl Command for Authenticate {
}
let arg = &params[0];
let mech = s.users.get(&uid).and_then(|u| u.sasl_mech.clone());
// SASL is relayed to a linked services server (see `Server::sasl_relay`);
// with none configured/linked it fails cleanly, exactly like InspIRCd.
let have_services = s.sasl_link().is_some();
match mech {
// step 1 — the client picks a mechanism
None => {
if arg.eq_ignore_ascii_case("PLAIN") {
if arg == "*" {
s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
CmdResult::Ok
} else if arg.eq_ignore_ascii_case("PLAIN") {
if let Some(u) = s.users.get_mut(&uid) {
u.sasl_mech = Some("PLAIN".to_string());
}
if have_services {
s.sasl_relay(uid, "S PLAIN"); // start the exchange at services
}
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");
@ -179,25 +185,39 @@ impl Command for Authenticate {
}
// 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 == "*" {
if have_services {
s.sasl_relay(uid, "D A");
}
if let Some(u) = s.users.get_mut(&uid) {
u.sasl_mech = None;
}
s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
return CmdResult::Ok;
}
if arg.len() > 400 {
if let Some(u) = s.users.get_mut(&uid) {
u.sasl_mech = None;
}
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
if have_services {
// relay the response; the verdict (900/903 or 904) comes back
// over S2S in `Server::link_sasl`, which clears `sasl_mech`.
s.sasl_relay(uid, &format!("C {arg}"));
CmdResult::Ok
} else {
if let Some(u) = s.users.get_mut(&uid) {
u.sasl_mech = None;
}
s.numeric(
uid,
ERR_SASLFAIL,
":SASL authentication failed (services are not available)",
);
CmdResult::Fail
}
}
}
}

View file

@ -171,6 +171,7 @@ impl Server {
"SVSLOGOUT" if registered => self.link_svslogout(msg),
"ENCAP" if registered => self.link_encap(uid, msg),
"METADATA" if registered => self.link_metadata(msg),
"SASL" if registered => self.link_sasl(msg),
"BURST" => {
if let Some(l) = self.links.get_mut(&uid) {
l.bursting = true;
@ -520,6 +521,89 @@ impl Server {
}
}
// --- SASL relay (client AUTHENTICATE ⇄ services) --------------------------
/// The local link toward the configured SASL services server, if connected.
pub fn sasl_link(&self) -> Option<Uid> {
if self.sasl_server.is_empty() {
return None;
}
self.servers
.values()
.find(|sv| sv.name == self.sasl_server)
.map(|sv| sv.via)
}
/// Relay one SASL step for local client `uid` to the services server:
/// `:<our-sid> SASL <client-uuid> <rest>`. No-op if SASL services aren't linked.
pub fn sasl_relay(&self, uid: Uid, rest: &str) {
let (Some(via), Some(uuid)) = (
self.sasl_link(),
self.users.get(&uid).map(|u| u.uuid.clone()),
) else {
return;
};
self.link_out(via, format!(":{} SASL {uuid} {rest}", self.sid));
}
/// A SASL message from services: `:<svcsid> SASL <client-uuid> <type> …`.
/// `C <data>` → relay a server challenge to the client as `AUTHENTICATE`;
/// `D S [account]` → success (log in + 900/903); `D <other>` → fail (904).
fn link_sasl(&mut self, msg: &Message) {
if msg.params.len() < 2 {
return;
}
let Some(&uid) = self.uuid_local.get(&msg.params[0]) else {
return; // not one of our clients
};
match msg.params[1].as_str() {
"C" => {
if let Some(data) = msg.params.get(2) {
self.send(uid, format!("AUTHENTICATE {data}"));
}
}
"D" => {
let ok = msg.params.get(2).map(|t| t == "S").unwrap_or(false);
let account = msg.params.get(3).cloned().unwrap_or_default();
if ok && !account.is_empty() {
self.set_login(uid, &account);
}
self.sasl_done(uid, ok, &account);
if let Some(u) = self.users.get_mut(&uid) {
u.sasl_mech = None;
}
}
_ => {}
}
}
/// Emit the SASL outcome to the client: 900 + 903 on success, 904 on failure.
fn sasl_done(&self, uid: Uid, success: bool, account: &str) {
if success {
let mask = self
.users
.get(&uid)
.map(|u| u.prefix())
.unwrap_or_else(|| "*".to_string());
self.numeric(
uid,
crate::numeric::RPL_LOGGEDIN,
&format!("{mask} {account} :You are now logged in as {account}"),
);
self.numeric(
uid,
crate::numeric::RPL_SASLSUCCESS,
":SASL authentication successful",
);
} else {
self.numeric(
uid,
crate::numeric::ERR_SASLFAIL,
":SASL authentication failed",
);
}
}
// --- inbound S2S records --------------------------------------------------
fn link_uid_recv(&mut self, via: Uid, msg: &Message) {

View file

@ -103,6 +103,7 @@ pub struct Server {
pub dnsbl_zones: Vec<String>, // DNS blocklist zones checked on connect
pub dnsbl_action: String, // mark | kline | gline | zline
pub dnsbl_reason: String, // ban reason on a DNSBL hit
pub sasl_server: String, // services server that handles SASL
pub event_tx: Sender<Event>, // self-inject events (DNS results)
}
@ -142,6 +143,7 @@ impl Server {
dnsbl_zones: cfg.dnsbl_zones,
dnsbl_action: cfg.dnsbl_action,
dnsbl_reason: cfg.dnsbl_reason,
sasl_server: cfg.sasl_server,
event_tx,
}
}