diff --git a/src/config.rs b/src/config.rs index 29d27c0..56e20fa 100644 --- a/src/config.rs +++ b/src/config.rs @@ -25,12 +25,21 @@ pub struct Server { // on the single-threaded link matters more than verifier strength. #[serde(default = "default_scram_iterations")] pub scram_iterations: u32, + // Nick prefix a user is renamed to on NickServ LOGOUT (they keep the ircd's + // guest number appended, e.g. Guest12345). Must start with a letter — the + // ircd rejects a digit-leading SVSNICK and falls back to the raw uuid. + #[serde(default = "default_guest_nick")] + pub guest_nick: String, } fn default_protocol() -> u32 { 1206 // InspIRCd 4 spanning-tree protocol (1205 = insp3) } +fn default_guest_nick() -> String { + "Guest".to_string() +} + fn default_scram_iterations() -> u32 { crate::engine::scram::DEFAULT_ITERATIONS } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 45bc148..d614456 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -468,7 +468,7 @@ mod tests { let mut db = Db::open(&path); db.scram_iterations = 4096; // keep the debug-build verifier cheap in tests assert!(db.register(account, password, None).is_ok()); - Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".to_string() })], db) + Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".to_string(), guest_nick: "Guest".to_string(), guest_seq: 12345 })], db) } fn sasl(engine: &mut Engine, mode: &str, chunk: &str) -> Vec { @@ -721,4 +721,27 @@ mod tests { e.db.register("bar", "sesame", None).unwrap(); assert!(matches!(e.db.certfp_add("bar", fp), Err(db::CertError::InUse)), "a fingerprint maps to one account"); } + + // IDENTIFY logs in, LOGOUT clears the accountname (ircd emits RPL_LOGGEDOUT). + #[test] + fn nickserv_logout_clears_account() { + let mut e = engine_with("nslogout", "foo", "sesame"); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + + let login = e.handle(NetEvent::Privmsg { + from: "000AAAAAB".into(), + to: "42SAAAAAA".into(), + text: "IDENTIFY sesame".into(), + }); + assert!(login.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "foo")), "{login:?}"); + + let out = e.handle(NetEvent::Privmsg { + from: "000AAAAAB".into(), + to: "42SAAAAAA".into(), + text: "LOGOUT".into(), + }); + assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value.is_empty())), "logout clears accountname: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, nick } if uid == "000AAAAAB" && nick == "Guest12345")), "logout renames to guest nick: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("logged out"))), "{out:?}"); + } } diff --git a/src/engine/service.rs b/src/engine/service.rs index 4247fc2..5c8e7e1 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -54,4 +54,22 @@ impl ServiceCtx { value: account.to_string(), }); } + + // Log a user out: clearing the accountname the ircd turns into RPL_LOGGEDOUT + // (901) and drops from account-tag / WHOX. The inverse of `login`. + pub fn logout(&mut self, uid: &str) { + self.actions.push(NetAction::Metadata { + target: uid.to_string(), + key: "accountname".to_string(), + value: String::new(), + }); + } + + // Force a user's nick (SVSNICK), e.g. to a guest nick after logout. + pub fn force_nick(&mut self, uid: &str, nick: &str) { + self.actions.push(NetAction::ForceNick { + uid: uid.to_string(), + nick: nick.to_string(), + }); + } } diff --git a/src/main.rs b/src/main.rs index e5210a1..13c5239 100644 --- a/src/main.rs +++ b/src/main.rs @@ -34,6 +34,8 @@ async fn main() -> Result<()> { let services: Vec> = vec![Box::new(NickServ { uid: format!("{}AAAAAA", cfg.server.sid), + guest_nick: cfg.server.guest_nick.clone(), + guest_seq: (ts % 100_000) as u32, })]; let mut db = engine::db::Db::open("fedserv.db.jsonl"); db.scram_iterations = cfg.server.scram_iterations; diff --git a/src/proto/inspircd.rs b/src/proto/inspircd.rs index 5c63f0d..2775dd8 100644 --- a/src/proto/inspircd.rs +++ b/src/proto/inspircd.rs @@ -2,6 +2,7 @@ // sequence in Network-Links (protocols/inspircd.py); mode/burst details get // firmed up against a live insp4 uplink. use super::{NetAction, NetEvent, Protocol}; +use std::time::{SystemTime, UNIX_EPOCH}; pub struct InspIrcd { sid: String, @@ -163,6 +164,12 @@ impl Protocol for InspIrcd { NetAction::Metadata { target, key, value } => { vec![self.from_us(format!("METADATA {} {} :{}", target, key, value))] } + // SVSNICK — the new nick takes the current + // time as its TS so it wins any collision resolution. + NetAction::ForceNick { uid, nick } => { + let now = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(self.ts); + vec![self.from_us(format!("SVSNICK {} {} {}", uid, nick, now))] + } NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles this before serialization. NetAction::DeferRegister { .. } => vec![], diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 63d90c1..4fb297d 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -34,6 +34,9 @@ pub enum NetAction { // Publish network state to the uplink: target "*" is server-global (e.g. the // advertised SASL mechanism list), otherwise a user uid (e.g. their account). Metadata { target: String, key: String, value: String }, + // Force a user's nick (SVSNICK), e.g. renaming to a guest nick on logout. + // The protocol stamps the new nick's timestamp. + ForceNick { uid: String, nick: String }, Raw(String), // Internal only, never serialized to the wire: a registration whose password // still needs its (expensive) key derivation. The link layer runs the diff --git a/src/services/nickserv.rs b/src/services/nickserv.rs index cf61ea8..9c4336d 100644 --- a/src/services/nickserv.rs +++ b/src/services/nickserv.rs @@ -4,6 +4,10 @@ use crate::proto::RegReply; pub struct NickServ { pub uid: String, + // Nick prefix assigned on LOGOUT (default "Guest"); a per-session sequence is + // appended so successive guests don't collide within a run. + pub guest_nick: String, + pub guest_seq: u32, } impl Service for NickServ { @@ -48,11 +52,20 @@ impl Service for NickServ { None => ctx.notice(me, from.uid, "Invalid password."), } } + Some("LOGOUT") | Some("LOGOFF") => { + // Guest nick = prefix + sequence (seeded from the link TS, bumped + // per logout). Inlined field access so it stays disjoint from `me`. + let guest = format!("{}{}", self.guest_nick, self.guest_seq); + self.guest_seq = self.guest_seq.wrapping_add(1); + ctx.logout(from.uid); + ctx.force_nick(from.uid, &guest); + ctx.notice(me, from.uid, format!("You are now logged out. Your nick is now \x02{}\x02.", guest)); + } Some("CERT") => self.cert(from, &args, ctx, db), Some("HELP") => ctx.notice( me, from.uid, - "Commands: REGISTER [email], IDENTIFY , CERT ADD|DEL|LIST [fingerprint].", + "Commands: REGISTER [email], IDENTIFY , LOGOUT, CERT ADD|DEL|LIST [fingerprint].", ), Some(other) => ctx.notice(me, from.uid, format!("Unknown command: {}. Try HELP.", other)), None => {}