diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 6812f29..cc6e15c 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -387,6 +387,13 @@ impl Db { self.host_cfg.template.as_deref() } + /// `account`'s current (non-expired) vhost host string, if it has one. + pub fn active_vhost(&self, account: &str) -> Option { + self.accounts + .get(&key(account)) + .and_then(|a| a.vhost.as_ref().filter(|v| v.expires.is_none_or(|e| e > now())).map(|v| v.host.clone())) + } + /// The account whose current (non-expired) vhost is `host`, if any — so a /// vhost can't be assigned to two accounts and collide on the network. pub fn vhost_owner(&self, host: &str) -> Option { diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 76897cb..c3b431c 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -400,6 +400,38 @@ impl Engine { sasl_success(agent, client, account) } + // The login side-effects — auto-join, vhost, and a waiting-memo notice — for a + // user who is already authenticated the instant they connect: they logged in + // via SASL during registration, so the NickServ IDENTIFY path (which applies + // these itself) never ran for them. Mirrors modules/nickserv/src/identify.rs. + fn login_connect_effects(&self, uid: &str, account: &str) -> Vec { + let mut out = Vec::new(); + for entry in self.db.ajoin_list(account) { + out.push(NetAction::ForceJoin { uid: uid.to_string(), channel: entry.channel.clone(), key: entry.key.clone() }); + } + if let Some(vhost) = self.db.active_vhost(account) { + // apply_vhost semantics: an ident@host spec sets the ident as well. + match vhost.split_once('@') { + Some((ident, host)) => { + out.push(NetAction::SetIdent { uid: uid.to_string(), ident: ident.to_string() }); + out.push(NetAction::SetHost { uid: uid.to_string(), host: host.to_string() }); + } + None => out.push(NetAction::SetHost { uid: uid.to_string(), host: vhost }), + } + } + let unread = self.db.unread_memos(account); + if unread > 0 && self.db.memo_notify_on(account) { + if let Some(ns) = &self.nick_service { + out.push(NetAction::Notice { + from: ns.clone(), + to: uid.to_string(), + text: format!("You have \x02{unread}\x02 new memo(s). Read them with \x02/msg MemoServ READ NEW\x02."), + }); + } + } + out + } + fn emit_irc(&self, action: NetAction) { if let Some(tx) = &self.irc_out { let _ = tx.send(action); // unbounded: never blocks; only fails if the link is down @@ -757,6 +789,12 @@ impl Engine { // identified to. let mut out = self.news_notices("logon", "News", &uid); out.extend(self.enforce_registered_nick(&uid, &arriving_nick)); + // A user already logged in the moment they connect (SASL during + // registration) never ran the IDENTIFY path, so give them their + // auto-join, vhost, and waiting-memo notice here. + if let Some(account) = self.network.account_of(&uid).map(str::to_string) { + out.extend(self.login_connect_effects(&uid, &account)); + } out } NetEvent::NickChange { uid, nick } => { diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 322c3d5..6174924 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -473,6 +473,37 @@ assert!(notice(&to_ns(&mut e, "ALIST"), "#a")); } + // A user authenticated via SASL during registration never runs the IDENTIFY + // path, so the engine applies their auto-join and vhost when they connect. + #[test] + fn sasl_login_applies_ajoin_and_vhost_on_connect() { + let path = std::env::temp_dir().join("echo-saslajoin.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.register("alice", "sesame", None).unwrap(); + db.register_channel("#auto", "alice").unwrap(); + db.ajoin_add("alice", "#auto", "").unwrap(); + db.set_vhost("alice", "cool.example", "boss", None).unwrap(); + let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db); + e.set_sid("42S".into()); + + // SASL PLAIN logs uid 000AAAAAB into alice during registration. + e.handle(NetEvent::Sasl { client: "000AAAAAB".into(), agent: "*".into(), mode: "S".into(), data: vec!["PLAIN".into()] }); + let out = e.handle(NetEvent::Sasl { client: "000AAAAAB".into(), agent: "*".into(), mode: "C".into(), data: vec![plain(b"", b"alice", b"sesame")] }); + assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "alice")), "sasl login set the account: {out:?}"); + + // On connect the SASL user is force-joined and their vhost is applied, + // exactly as an IDENTIFY would have done. + let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into(), ip: "0.0.0.0".into() }); + assert!(out.iter().any(|a| matches!(a, NetAction::ForceJoin { uid, channel, .. } if uid == "000AAAAAB" && channel == "#auto")), "auto-join applied on SASL connect: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::SetHost { uid, host } if uid == "000AAAAAB" && host == "cool.example")), "vhost applied on SASL connect: {out:?}"); + + // A plain (unauthenticated) connection gets neither. + let out = e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "h".into(), ip: "0.0.0.0".into() }); + assert!(!out.iter().any(|a| matches!(a, NetAction::ForceJoin { .. } | NetAction::SetHost { .. })), "no effects for an unauthenticated connect: {out:?}"); + } + // SET HIDE STATUS keeps the last-seen/online line to the owner and opers. #[test] fn nickserv_set_hide_status() {