Add NickServ LOGOUT command

Clears the account (RPL_LOGGEDOUT) and renames the user to a
configurable guest nick (default Guest) via SVSNICK.
This commit is contained in:
Jean Chevronnet 2026-07-12 05:06:34 +00:00
parent a382ac8ad3
commit 60fc687162
No known key found for this signature in database
7 changed files with 77 additions and 2 deletions

View file

@ -25,12 +25,21 @@ pub struct Server {
// on the single-threaded link matters more than verifier strength. // on the single-threaded link matters more than verifier strength.
#[serde(default = "default_scram_iterations")] #[serde(default = "default_scram_iterations")]
pub scram_iterations: u32, 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 { fn default_protocol() -> u32 {
1206 // InspIRCd 4 spanning-tree protocol (1205 = insp3) 1206 // InspIRCd 4 spanning-tree protocol (1205 = insp3)
} }
fn default_guest_nick() -> String {
"Guest".to_string()
}
fn default_scram_iterations() -> u32 { fn default_scram_iterations() -> u32 {
crate::engine::scram::DEFAULT_ITERATIONS crate::engine::scram::DEFAULT_ITERATIONS
} }

View file

@ -468,7 +468,7 @@ mod tests {
let mut db = Db::open(&path); let mut db = Db::open(&path);
db.scram_iterations = 4096; // keep the debug-build verifier cheap in tests db.scram_iterations = 4096; // keep the debug-build verifier cheap in tests
assert!(db.register(account, password, None).is_ok()); 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<NetAction> { fn sasl(engine: &mut Engine, mode: &str, chunk: &str) -> Vec<NetAction> {
@ -721,4 +721,27 @@ mod tests {
e.db.register("bar", "sesame", None).unwrap(); 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"); 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:?}");
}
} }

View file

@ -54,4 +54,22 @@ impl ServiceCtx {
value: account.to_string(), 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(),
});
}
} }

View file

@ -34,6 +34,8 @@ async fn main() -> Result<()> {
let services: Vec<Box<dyn engine::service::Service>> = vec![Box::new(NickServ { let services: Vec<Box<dyn engine::service::Service>> = vec![Box::new(NickServ {
uid: format!("{}AAAAAA", cfg.server.sid), 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"); let mut db = engine::db::Db::open("fedserv.db.jsonl");
db.scram_iterations = cfg.server.scram_iterations; db.scram_iterations = cfg.server.scram_iterations;

View file

@ -2,6 +2,7 @@
// sequence in Network-Links (protocols/inspircd.py); mode/burst details get // sequence in Network-Links (protocols/inspircd.py); mode/burst details get
// firmed up against a live insp4 uplink. // firmed up against a live insp4 uplink.
use super::{NetAction, NetEvent, Protocol}; use super::{NetAction, NetEvent, Protocol};
use std::time::{SystemTime, UNIX_EPOCH};
pub struct InspIrcd { pub struct InspIrcd {
sid: String, sid: String,
@ -163,6 +164,12 @@ impl Protocol for InspIrcd {
NetAction::Metadata { target, key, value } => { NetAction::Metadata { target, key, value } => {
vec![self.from_us(format!("METADATA {} {} :{}", target, key, value))] vec![self.from_us(format!("METADATA {} {} :{}", target, key, value))]
} }
// SVSNICK <uid> <newnick> <nickts> — 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()], NetAction::Raw(s) => vec![s.clone()],
// Internal: the link layer handles this before serialization. // Internal: the link layer handles this before serialization.
NetAction::DeferRegister { .. } => vec![], NetAction::DeferRegister { .. } => vec![],

View file

@ -34,6 +34,9 @@ pub enum NetAction {
// Publish network state to the uplink: target "*" is server-global (e.g. the // 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). // advertised SASL mechanism list), otherwise a user uid (e.g. their account).
Metadata { target: String, key: String, value: String }, 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), Raw(String),
// Internal only, never serialized to the wire: a registration whose password // Internal only, never serialized to the wire: a registration whose password
// still needs its (expensive) key derivation. The link layer runs the // still needs its (expensive) key derivation. The link layer runs the

View file

@ -4,6 +4,10 @@ use crate::proto::RegReply;
pub struct NickServ { pub struct NickServ {
pub uid: String, 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 { impl Service for NickServ {
@ -48,11 +52,20 @@ impl Service for NickServ {
None => ctx.notice(me, from.uid, "Invalid password."), 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("CERT") => self.cert(from, &args, ctx, db),
Some("HELP") => ctx.notice( Some("HELP") => ctx.notice(
me, me,
from.uid, from.uid,
"Commands: REGISTER <password> [email], IDENTIFY <password>, CERT ADD|DEL|LIST <password> [fingerprint].", "Commands: REGISTER <password> [email], IDENTIFY <password>, LOGOUT, CERT ADD|DEL|LIST <password> [fingerprint].",
), ),
Some(other) => ctx.notice(me, from.uid, format!("Unknown command: {}. Try HELP.", other)), Some(other) => ctx.notice(me, from.uid, format!("Unknown command: {}. Try HELP.", other)),
None => {} None => {}