nickserv: grouped nicks (GROUP/GLIST/UNGROUP) and GHOST
A nick can be grouped to an account and then identifies to it; the store resolves an alias to its account everywhere it authenticates. GHOST (and RECOVER) rename off a session using a nick you own. Grouped nicks federate and are dropped with their account.
This commit is contained in:
parent
ef89f97158
commit
020e9bd576
7 changed files with 213 additions and 12 deletions
33
modules/nickserv/ghost.rs
Normal file
33
modules/nickserv/ghost.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
use crate::engine::db::Db;
|
||||||
|
use crate::engine::service::{Sender, ServiceCtx};
|
||||||
|
use crate::engine::state::Network;
|
||||||
|
|
||||||
|
// GHOST/RECOVER <nick> [password]: rename off a session using a nick you own,
|
||||||
|
// either by being identified to its account or giving that account's password.
|
||||||
|
pub fn handle(me: &str, guest_nick: &str, guest_seq: &mut u32, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) {
|
||||||
|
let Some(&target) = args.get(1) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: GHOST <nick> [password]");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(account) = db.resolve_account(target).map(str::to_string) else {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{target}\x02 isn't registered."));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let owns = from.account == Some(account.as_str()) || args.get(2).is_some_and(|pw| db.authenticate(target, pw).is_some());
|
||||||
|
if !owns {
|
||||||
|
ctx.notice(me, from.uid, format!("Access denied. Identify to \x02{account}\x02 or give its password."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(ghost) = net.uid_by_nick(target).map(str::to_string) else {
|
||||||
|
ctx.notice(me, from.uid, format!("Nobody is using \x02{target}\x02."));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if ghost == from.uid {
|
||||||
|
ctx.notice(me, from.uid, "That's you.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let guest = format!("{guest_nick}{guest_seq}");
|
||||||
|
*guest_seq = guest_seq.wrapping_add(1);
|
||||||
|
ctx.force_nick(&ghost, &guest);
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{target}\x02 has been freed."));
|
||||||
|
}
|
||||||
17
modules/nickserv/glist.rs
Normal file
17
modules/nickserv/glist.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
use crate::engine::db::Db;
|
||||||
|
use crate::engine::service::{Sender, ServiceCtx};
|
||||||
|
|
||||||
|
// GLIST: list the nicks grouped to your account.
|
||||||
|
pub fn handle(me: &str, from: &Sender, ctx: &mut ServiceCtx, db: &Db) {
|
||||||
|
let Some(account) = from.account else {
|
||||||
|
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut nicks = db.grouped_nicks(account);
|
||||||
|
nicks.sort();
|
||||||
|
ctx.notice(me, from.uid, format!("Nicks grouped to \x02{account}\x02:"));
|
||||||
|
ctx.notice(me, from.uid, format!(" \x02{account}\x02 (main)"));
|
||||||
|
for n in nicks {
|
||||||
|
ctx.notice(me, from.uid, format!(" \x02{n}\x02"));
|
||||||
|
}
|
||||||
|
}
|
||||||
23
modules/nickserv/group.rs
Normal file
23
modules/nickserv/group.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
use crate::engine::db::Db;
|
||||||
|
use crate::engine::service::{Sender, ServiceCtx};
|
||||||
|
|
||||||
|
// GROUP <account> <password>: link your current nick to an existing account, so
|
||||||
|
// you can identify to it under this nick too.
|
||||||
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
|
||||||
|
let (Some(&account), Some(&password)) = (args.get(1), args.get(2)) else {
|
||||||
|
ctx.notice(me, from.uid, "Syntax: GROUP <account> <password>");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(canonical) = db.authenticate(account, password).map(str::to_string) else {
|
||||||
|
ctx.notice(me, from.uid, "Invalid account or password.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if db.account(from.nick).is_some() {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{}\x02 is itself a registered account.", from.nick));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match db.group_nick(from.nick, &canonical) {
|
||||||
|
Ok(()) => ctx.notice(me, from.uid, format!("Your nick \x02{}\x02 is now grouped to \x02{canonical}\x02.", from.nick)),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,14 @@ mod alist;
|
||||||
mod set;
|
mod set;
|
||||||
#[path = "drop.rs"]
|
#[path = "drop.rs"]
|
||||||
mod drop;
|
mod drop;
|
||||||
|
#[path = "group.rs"]
|
||||||
|
mod group;
|
||||||
|
#[path = "glist.rs"]
|
||||||
|
mod glist;
|
||||||
|
#[path = "ungroup.rs"]
|
||||||
|
mod ungroup;
|
||||||
|
#[path = "ghost.rs"]
|
||||||
|
mod ghost;
|
||||||
|
|
||||||
pub struct NickServ {
|
pub struct NickServ {
|
||||||
pub uid: String,
|
pub uid: String,
|
||||||
|
|
@ -52,7 +60,11 @@ impl Service for NickServ {
|
||||||
Some("ALIST") => alist::handle(me, from, ctx, db),
|
Some("ALIST") => alist::handle(me, from, ctx, db),
|
||||||
Some("SET") => set::handle(me, from, args, ctx, db),
|
Some("SET") => set::handle(me, from, args, ctx, db),
|
||||||
Some("DROP") => drop::handle(me, from, args, ctx, net, db),
|
Some("DROP") => drop::handle(me, from, args, ctx, net, db),
|
||||||
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02SET\x02 PASSWORD|EMAIL, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]."),
|
Some("GROUP") => group::handle(me, from, args, ctx, db),
|
||||||
|
Some("GLIST") => glist::handle(me, from, ctx, db),
|
||||||
|
Some("UNGROUP") => ungroup::handle(me, from, args, ctx, db),
|
||||||
|
Some("GHOST") | Some("RECOVER") => ghost::handle(me, &self.guest_nick, &mut self.guest_seq, from, args, ctx, net, db),
|
||||||
|
Some("HELP") => ctx.notice(me, from.uid, "NickServ looks after your nickname. Commands: \x02REGISTER\x02 <password> [email], \x02IDENTIFY\x02 [account] <password>, \x02INFO\x02 [account], \x02ALIST\x02, \x02GROUP\x02/\x02GLIST\x02/\x02UNGROUP\x02, \x02GHOST\x02 <nick> [password], \x02SET\x02 PASSWORD|EMAIL, \x02DROP\x02 <password>, \x02LOGOUT\x02, \x02CERT\x02 ADD|DEL|LIST <password> [fingerprint]."),
|
||||||
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")),
|
||||||
None => {}
|
None => {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
24
modules/nickserv/ungroup.rs
Normal file
24
modules/nickserv/ungroup.rs
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
use crate::engine::db::Db;
|
||||||
|
use crate::engine::service::{Sender, ServiceCtx};
|
||||||
|
|
||||||
|
// UNGROUP [nick]: remove a nick grouped to your account (defaults to your current nick).
|
||||||
|
pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) {
|
||||||
|
let Some(account) = from.account else {
|
||||||
|
ctx.notice(me, from.uid, "You need to be logged in. Identify to NickServ first.");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let nick = args.get(1).copied().unwrap_or(from.nick);
|
||||||
|
if nick.eq_ignore_ascii_case(account) {
|
||||||
|
ctx.notice(me, from.uid, "You can't ungroup your main account name.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if db.resolve_account(nick).map_or(true, |a| !a.eq_ignore_ascii_case(account)) {
|
||||||
|
ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't grouped to your account."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match db.ungroup_nick(nick) {
|
||||||
|
Ok(true) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 is no longer grouped to \x02{account}\x02.")),
|
||||||
|
Ok(false) => ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't a grouped nick.")),
|
||||||
|
Err(_) => ctx.notice(me, from.uid, "Sorry, that didn't work. Please try again in a moment."),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,8 @@ pub enum Event {
|
||||||
AccountEmailSet { account: String, email: Option<String> },
|
AccountEmailSet { account: String, email: Option<String> },
|
||||||
AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String },
|
AccountPasswordSet { account: String, password_hash: String, scram256: String, scram512: String },
|
||||||
AccountDropped { account: String },
|
AccountDropped { account: String },
|
||||||
|
NickGrouped { nick: String, account: String },
|
||||||
|
NickUngrouped { nick: String },
|
||||||
ChannelRegistered { name: String, founder: String, ts: u64 },
|
ChannelRegistered { name: String, founder: String, ts: u64 },
|
||||||
ChannelDropped { name: String },
|
ChannelDropped { name: String },
|
||||||
ChannelMlock { name: String, on: String, off: String },
|
ChannelMlock { name: String, on: String, off: String },
|
||||||
|
|
@ -78,7 +80,9 @@ impl Event {
|
||||||
| Event::CertRemoved { .. }
|
| Event::CertRemoved { .. }
|
||||||
| Event::AccountEmailSet { .. }
|
| Event::AccountEmailSet { .. }
|
||||||
| Event::AccountPasswordSet { .. }
|
| Event::AccountPasswordSet { .. }
|
||||||
| Event::AccountDropped { .. } => Scope::Global,
|
| Event::AccountDropped { .. }
|
||||||
|
| Event::NickGrouped { .. }
|
||||||
|
| Event::NickUngrouped { .. } => Scope::Global,
|
||||||
Event::ChannelRegistered { .. }
|
Event::ChannelRegistered { .. }
|
||||||
| Event::ChannelDropped { .. }
|
| Event::ChannelDropped { .. }
|
||||||
| Event::ChannelMlock { .. }
|
| Event::ChannelMlock { .. }
|
||||||
|
|
@ -435,6 +439,7 @@ pub struct Credentials {
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
accounts: HashMap<String, Account>, // keyed by casefolded name
|
accounts: HashMap<String, Account>, // keyed by casefolded name
|
||||||
channels: HashMap<String, ChannelInfo>, // keyed by casefolded name
|
channels: HashMap<String, ChannelInfo>, // keyed by casefolded name
|
||||||
|
grouped: HashMap<String, String>, // casefolded alias nick -> canonical account name
|
||||||
log: EventLog,
|
log: EventLog,
|
||||||
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
|
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
|
||||||
pub(crate) scram_iterations: u32,
|
pub(crate) scram_iterations: u32,
|
||||||
|
|
@ -472,11 +477,12 @@ impl Db {
|
||||||
let (log, events) = EventLog::open(path.into(), origin.into());
|
let (log, events) = EventLog::open(path.into(), origin.into());
|
||||||
let mut accounts = HashMap::new();
|
let mut accounts = HashMap::new();
|
||||||
let mut channels = HashMap::new();
|
let mut channels = HashMap::new();
|
||||||
|
let mut grouped = HashMap::new();
|
||||||
for event in events {
|
for event in events {
|
||||||
apply(&mut accounts, &mut channels, event);
|
apply(&mut accounts, &mut channels, &mut grouped, event);
|
||||||
}
|
}
|
||||||
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
|
||||||
Self { accounts, channels, log, scram_iterations: scram::DEFAULT_ITERATIONS }
|
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fold an entry authored by another node into the store — the services-side
|
/// Fold an entry authored by another node into the store — the services-side
|
||||||
|
|
@ -492,7 +498,7 @@ impl Db {
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
if let Some(event) = self.log.ingest(entry)? {
|
if let Some(event) = self.log.ingest(entry)? {
|
||||||
apply(&mut self.accounts, &mut self.channels, event);
|
apply(&mut self.accounts, &mut self.channels, &mut self.grouped, event);
|
||||||
}
|
}
|
||||||
if let Some((name, prev_home)) = watched {
|
if let Some((name, prev_home)) = watched {
|
||||||
match (prev_home, self.account(&name).map(|c| c.home.clone())) {
|
match (prev_home, self.account(&name).map(|c| c.home.clone())) {
|
||||||
|
|
@ -526,6 +532,9 @@ impl Db {
|
||||||
snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() });
|
snapshot.push(Event::ChannelEntryMsgSet { channel: c.name.clone(), msg: c.entrymsg.clone() });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (nick, account) in &self.grouped {
|
||||||
|
snapshot.push(Event::NickGrouped { nick: nick.clone(), account: account.clone() });
|
||||||
|
}
|
||||||
self.log.compact(snapshot)?;
|
self.log.compact(snapshot)?;
|
||||||
tracing::info!(before, after = self.log.len(), "compacted event log");
|
tracing::info!(before, after = self.log.len(), "compacted event log");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -552,7 +561,49 @@ impl Db {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn exists(&self, name: &str) -> bool {
|
pub fn exists(&self, name: &str) -> bool {
|
||||||
self.accounts.contains_key(&key(name))
|
self.resolved_key(name).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a name (a registered account, or a nick grouped to one) to the
|
||||||
|
// owning account's storage key.
|
||||||
|
fn resolved_key(&self, name: &str) -> Option<String> {
|
||||||
|
let k = key(name);
|
||||||
|
if self.accounts.contains_key(&k) {
|
||||||
|
return Some(k);
|
||||||
|
}
|
||||||
|
self.grouped.get(&k).map(|acct| key(acct))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The canonical account name for `name`, whether it is the account itself or
|
||||||
|
/// a nick grouped to it.
|
||||||
|
pub fn resolve_account(&self, name: &str) -> Option<&str> {
|
||||||
|
let k = self.resolved_key(name)?;
|
||||||
|
self.accounts.get(&k).map(|a| a.name.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group alias `nick` to an existing `account`.
|
||||||
|
pub fn group_nick(&mut self, nick: &str, account: &str) -> Result<(), RegError> {
|
||||||
|
if !self.accounts.contains_key(&key(account)) {
|
||||||
|
return Err(RegError::Internal);
|
||||||
|
}
|
||||||
|
self.log.append(Event::NickGrouped { nick: nick.to_string(), account: account.to_string() }).map_err(|_| RegError::Internal)?;
|
||||||
|
self.grouped.insert(key(nick), account.to_string());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove grouped alias `nick`. Ok(false) if it wasn't grouped.
|
||||||
|
pub fn ungroup_nick(&mut self, nick: &str) -> Result<bool, RegError> {
|
||||||
|
if !self.grouped.contains_key(&key(nick)) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
self.log.append(Event::NickUngrouped { nick: nick.to_string() }).map_err(|_| RegError::Internal)?;
|
||||||
|
self.grouped.remove(&key(nick));
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The alias nicks grouped to `account` (not the account name itself).
|
||||||
|
pub fn grouped_nicks(&self, account: &str) -> Vec<String> {
|
||||||
|
self.grouped.iter().filter(|(_, a)| a.eq_ignore_ascii_case(account)).map(|(nick, _)| nick.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Derive the password-bound half of an account. Pure and CPU-heavy (no
|
/// Derive the password-bound half of an account. Pure and CPU-heavy (no
|
||||||
|
|
@ -602,13 +653,13 @@ impl Db {
|
||||||
/// Check credentials; on success return the account's canonical name (its
|
/// Check credentials; on success return the account's canonical name (its
|
||||||
/// stored casing), else None.
|
/// stored casing), else None.
|
||||||
pub fn authenticate(&self, name: &str, password: &str) -> Option<&str> {
|
pub fn authenticate(&self, name: &str, password: &str) -> Option<&str> {
|
||||||
let account = self.accounts.get(&key(name))?;
|
let account = self.accounts.get(&self.resolved_key(name)?)?;
|
||||||
verify_password(password, &account.password_hash).then_some(account.name.as_str())
|
verify_password(password, &account.password_hash).then_some(account.name.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The account's canonical name and its SCRAM verifier for `mech`, if any.
|
/// The account's canonical name and its SCRAM verifier for `mech`, if any.
|
||||||
pub fn scram_lookup(&self, name: &str, mech: &str) -> Option<(&str, &str)> {
|
pub fn scram_lookup(&self, name: &str, mech: &str) -> Option<(&str, &str)> {
|
||||||
let account = self.accounts.get(&key(name))?;
|
let account = self.accounts.get(&self.resolved_key(name)?)?;
|
||||||
let verifier = match mech {
|
let verifier = match mech {
|
||||||
"SCRAM-SHA-256" => account.scram256.as_deref(),
|
"SCRAM-SHA-256" => account.scram256.as_deref(),
|
||||||
"SCRAM-SHA-512" => account.scram512.as_deref(),
|
"SCRAM-SHA-512" => account.scram512.as_deref(),
|
||||||
|
|
@ -882,7 +933,7 @@ fn owns_over(held: &Account, claim: &Account) -> bool {
|
||||||
|
|
||||||
// Fold one event into the store. Shared by log replay (`open`) and gossip
|
// Fold one event into the store. Shared by log replay (`open`) and gossip
|
||||||
// ingest, so both routes reconstruct identical state.
|
// ingest, so both routes reconstruct identical state.
|
||||||
fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String, ChannelInfo>, event: Event) {
|
fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String, ChannelInfo>, grouped: &mut HashMap<String, String>, event: Event) {
|
||||||
match event {
|
match event {
|
||||||
Event::AccountRegistered(a) => {
|
Event::AccountRegistered(a) => {
|
||||||
// Resolve a concurrent registration of the same name deterministically:
|
// Resolve a concurrent registration of the same name deterministically:
|
||||||
|
|
@ -920,6 +971,13 @@ fn apply(accounts: &mut HashMap<String, Account>, channels: &mut HashMap<String,
|
||||||
}
|
}
|
||||||
Event::AccountDropped { account } => {
|
Event::AccountDropped { account } => {
|
||||||
accounts.remove(&key(&account));
|
accounts.remove(&key(&account));
|
||||||
|
grouped.retain(|_, a| !a.eq_ignore_ascii_case(&account)); // its aliases go too
|
||||||
|
}
|
||||||
|
Event::NickGrouped { nick, account } => {
|
||||||
|
grouped.insert(key(&nick), account);
|
||||||
|
}
|
||||||
|
Event::NickUngrouped { nick } => {
|
||||||
|
grouped.remove(&key(&nick));
|
||||||
}
|
}
|
||||||
Event::ChannelRegistered { name, founder, ts } => {
|
Event::ChannelRegistered { name, founder, ts } => {
|
||||||
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new() });
|
channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new() });
|
||||||
|
|
@ -1052,9 +1110,9 @@ mod tests {
|
||||||
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![],
|
ts, home: home.into(), scram256: None, scram512: None, certfps: vec![],
|
||||||
};
|
};
|
||||||
let converge = |first: &Account, second: &Account| {
|
let converge = |first: &Account, second: &Account| {
|
||||||
let (mut acc, mut ch) = (HashMap::new(), HashMap::new());
|
let (mut acc, mut ch, mut gr) = (HashMap::new(), HashMap::new(), HashMap::new());
|
||||||
apply(&mut acc, &mut ch, Event::AccountRegistered(first.clone()));
|
apply(&mut acc, &mut ch, &mut gr, Event::AccountRegistered(first.clone()));
|
||||||
apply(&mut acc, &mut ch, Event::AccountRegistered(second.clone()));
|
apply(&mut acc, &mut ch, &mut gr, Event::AccountRegistered(second.clone()));
|
||||||
acc["alice"].password_hash.clone()
|
acc["alice"].password_hash.clone()
|
||||||
};
|
};
|
||||||
// Earlier registration wins, regardless of which claim applies first.
|
// Earlier registration wins, regardless of which claim applies first.
|
||||||
|
|
|
||||||
|
|
@ -1190,6 +1190,40 @@ mod tests {
|
||||||
assert!(e.db.channel("#a").is_none(), "founded channel gone");
|
assert!(e.db.channel("#a").is_none(), "founded channel gone");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GROUP links a nick to an account so it identifies there too; GLIST lists the
|
||||||
|
// aliases; UNGROUP removes one, after which the nick no longer resolves.
|
||||||
|
#[test]
|
||||||
|
fn nickserv_grouped_nicks() {
|
||||||
|
let mut e = engine_with("nsgroup", "alice", "sesame");
|
||||||
|
let to_ns = |e: &mut Engine, uid: &str, text: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: text.into() });
|
||||||
|
let notice = |out: &[NetAction], needle: &str| out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle)));
|
||||||
|
// A user on a different nick groups it to alice by password.
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "ali".into(), host: "h".into() });
|
||||||
|
assert!(notice(&to_ns(&mut e, "000AAAAAB", "GROUP alice sesame"), "grouped to \x02alice\x02"));
|
||||||
|
// Identifying as the grouped nick logs into alice.
|
||||||
|
let out = to_ns(&mut e, "000AAAAAB", "IDENTIFY sesame");
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::Metadata { key, value, .. } if key == "accountname" && value == "alice")), "grouped nick identifies to alice: {out:?}");
|
||||||
|
assert!(notice(&to_ns(&mut e, "000AAAAAB", "GLIST"), "ali"));
|
||||||
|
// Ungroup it; a fresh session on that nick no longer resolves.
|
||||||
|
assert!(notice(&to_ns(&mut e, "000AAAAAB", "UNGROUP ali"), "no longer grouped"));
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "ali".into(), host: "h".into() });
|
||||||
|
assert!(notice(&to_ns(&mut e, "000AAAAAC", "IDENTIFY sesame"), "isn't registered"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GHOST renames off a session using a nick the caller owns.
|
||||||
|
#[test]
|
||||||
|
fn nickserv_ghost() {
|
||||||
|
let mut e = engine_with("nsghost", "alice", "sesame");
|
||||||
|
let to_ns = |e: &mut Engine, uid: &str, text: &str| e.handle(NetEvent::Privmsg { from: uid.into(), to: "42SAAAAAA".into(), text: text.into() });
|
||||||
|
// A ghost sits on nick alice; the owner identifies from another nick.
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h".into() });
|
||||||
|
e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "owner".into(), host: "h".into() });
|
||||||
|
e.handle(NetEvent::Privmsg { from: "000AAAAAC".into(), to: "42SAAAAAA".into(), text: "IDENTIFY alice sesame".into() });
|
||||||
|
let out = to_ns(&mut e, "000AAAAAC", "GHOST alice");
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { uid, .. } if uid == "000AAAAAB")), "ghost renamed off: {out:?}");
|
||||||
|
assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("freed"))), "{out:?}");
|
||||||
|
}
|
||||||
|
|
||||||
// IDENTIFY accepts the two-arg account+password form: log into a named
|
// IDENTIFY accepts the two-arg account+password form: log into a named
|
||||||
// account even when the current nick differs; a wrong password is refused.
|
// account even when the current nick differs; a wrong password is refused.
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue