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_zones: Vec<String>, // DNS blocklist zones to check on connect
pub dnsbl_action: String, // mark | kline | gline | zline (on a hit) pub dnsbl_action: String, // mark | kline | gline | zline (on a hit)
pub dnsbl_reason: String, // ban reason for a DNSBL 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 { impl Default for Config {
@ -100,6 +101,7 @@ impl Default for Config {
dnsbl_zones: Vec::new(), dnsbl_zones: Vec::new(),
dnsbl_action: "mark".to_string(), dnsbl_action: "mark".to_string(),
dnsbl_reason: "Your host is listed in a DNS blocklist".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_action" => c.dnsbl_action = v.to_ascii_lowercase(),
"dnsbl_reason" => c.dnsbl_reason = v.to_string(), "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_zones = fresh.dnsbl_zones;
s.dnsbl_action = fresh.dnsbl_action; s.dnsbl_action = fresh.dnsbl_action;
s.dnsbl_reason = fresh.dnsbl_reason; s.dnsbl_reason = fresh.dnsbl_reason;
s.sasl_server = fresh.sasl_server;
s.announce("Server configuration reloaded."); s.announce("Server configuration reloaded.");
s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing")); s.numeric(uid, RPL_REHASHING, &format!("{path} :Rehashing"));
} }

View file

@ -159,18 +159,24 @@ impl Command for Authenticate {
} }
let arg = &params[0]; let arg = &params[0];
let mech = s.users.get(&uid).and_then(|u| u.sasl_mech.clone()); 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 { match mech {
// step 1 — the client picks a mechanism // step 1 — the client picks a mechanism
None => { 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) { if let Some(u) = s.users.get_mut(&uid) {
u.sasl_mech = Some("PLAIN".to_string()); 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()); s.send(uid, "AUTHENTICATE +".to_string());
CmdResult::Ok CmdResult::Ok
} else if arg == "*" {
s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
CmdResult::Ok
} else { } else {
s.numeric(uid, RPL_SASLMECHS, "PLAIN :are available SASL mechanisms"); s.numeric(uid, RPL_SASLMECHS, "PLAIN :are available SASL mechanisms");
s.numeric(uid, ERR_SASLFAIL, ":Unsupported SASL mechanism"); 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 `*`) // step 2 — the client sends the base64 payload (or aborts with `*`)
Some(_) => { Some(_) => {
if let Some(u) = s.users.get_mut(&uid) {
u.sasl_mech = None;
}
if arg == "*" { 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"); s.numeric(uid, ERR_SASLABORTED, ":SASL authentication aborted");
return CmdResult::Ok; return CmdResult::Ok;
} }
if arg.len() > 400 { 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"); s.numeric(uid, ERR_SASLTOOLONG, ":SASL message too long");
return CmdResult::Fail; return CmdResult::Fail;
} }
// base64(authzid \0 authcid \0 passwd) — would be relayed to services if have_services {
let _creds = openssl::base64::decode_block(arg).unwrap_or_default(); // relay the response; the verdict (900/903 or 904) comes back
s.numeric( // over S2S in `Server::link_sasl`, which clears `sasl_mech`.
uid, s.sasl_relay(uid, &format!("C {arg}"));
ERR_SASLFAIL, CmdResult::Ok
":SASL authentication failed (services are not available)", } else {
); if let Some(u) = s.users.get_mut(&uid) {
CmdResult::Fail 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), "SVSLOGOUT" if registered => self.link_svslogout(msg),
"ENCAP" if registered => self.link_encap(uid, msg), "ENCAP" if registered => self.link_encap(uid, msg),
"METADATA" if registered => self.link_metadata(msg), "METADATA" if registered => self.link_metadata(msg),
"SASL" if registered => self.link_sasl(msg),
"BURST" => { "BURST" => {
if let Some(l) = self.links.get_mut(&uid) { if let Some(l) = self.links.get_mut(&uid) {
l.bursting = true; 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 -------------------------------------------------- // --- inbound S2S records --------------------------------------------------
fn link_uid_recv(&mut self, via: Uid, msg: &Message) { 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_zones: Vec<String>, // DNS blocklist zones checked on connect
pub dnsbl_action: String, // mark | kline | gline | zline pub dnsbl_action: String, // mark | kline | gline | zline
pub dnsbl_reason: String, // ban reason on a DNSBL hit 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) pub event_tx: Sender<Event>, // self-inject events (DNS results)
} }
@ -142,6 +143,7 @@ impl Server {
dnsbl_zones: cfg.dnsbl_zones, dnsbl_zones: cfg.dnsbl_zones,
dnsbl_action: cfg.dnsbl_action, dnsbl_action: cfg.dnsbl_action,
dnsbl_reason: cfg.dnsbl_reason, dnsbl_reason: cfg.dnsbl_reason,
sasl_server: cfg.sasl_server,
event_tx, event_tx,
} }
} }