diff --git a/src/config.rs b/src/config.rs index 101deb0..5bb05e5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -74,6 +74,7 @@ pub struct Config { pub dnsbl_zones: Vec, // 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(), _ => {} } } diff --git a/src/coremods/core_rehash.rs b/src/coremods/core_rehash.rs index d9bcb32..51f1777 100644 --- a/src/coremods/core_rehash.rs +++ b/src/coremods/core_rehash.rs @@ -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")); } diff --git a/src/coremods/core_user.rs b/src/coremods/core_user.rs index 6e8d789..26e9a4b 100644 --- a/src/coremods/core_user.rs +++ b/src/coremods/core_user.rs @@ -159,18 +159,24 @@ impl Command for Authenticate { } let arg = ¶ms[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 + } } } } diff --git a/src/link.rs b/src/link.rs index dd6984e..65b6305 100644 --- a/src/link.rs +++ b/src/link.rs @@ -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 { + 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: + /// `: SASL `. 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: `: SASL …`. + /// `C ` → relay a server challenge to the client as `AUTHENTICATE`; + /// `D S [account]` → success (log in + 900/903); `D ` → 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) { diff --git a/src/server.rs b/src/server.rs index 63d2389..949f5da 100644 --- a/src/server.rs +++ b/src/server.rs @@ -103,6 +103,7 @@ pub struct Server { pub dnsbl_zones: Vec, // 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, // 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, } }