From acd0e60946434b80db645684fad0669a9f91772e Mon Sep 17 00:00:00 2001 From: Jean Date: Sun, 12 Jul 2026 11:21:35 +0000 Subject: [PATCH] chanserv: op, deop, voice, devoice, kick, ban, unban Adds channel moderation commands sourced from the ChanServ pseudoclient. Tracks connected users' nicks and hosts so targets can be resolved by nick and banned by *!*@host. All commands require operator access. --- .gitignore | 7 ++++ modules/chanserv/ban.rs | 22 ++++++++++ modules/chanserv/chanserv.rs | 35 +++++++++++++++- modules/chanserv/kick.rs | 20 +++++++++ modules/chanserv/op.rs | 26 ++++++++++++ modules/chanserv/unban.rs | 22 ++++++++++ modules/nickserv/nickserv.rs | 3 +- modules/protocol/inspircd.rs | 10 ++++- modules/protocol/mod.rs | 4 +- src/engine/db.rs | 5 +++ src/engine/mod.rs | 78 ++++++++++++++++++++++++++++-------- src/engine/service.rs | 13 +++++- src/engine/state.rs | 14 ++++++- 13 files changed, 233 insertions(+), 26 deletions(-) create mode 100644 modules/chanserv/ban.rs create mode 100644 modules/chanserv/kick.rs create mode 100644 modules/chanserv/op.rs create mode 100644 modules/chanserv/unban.rs diff --git a/.gitignore b/.gitignore index 7f7062f..a3bc641 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,10 @@ config.toml fedserv.db.jsonl + +# Local demo/test scaffolding +/demo-certs +/demo-start.sh +/demo-stop.sh +/test-net +/test-net-b diff --git a/modules/chanserv/ban.rs b/modules/chanserv/ban.rs new file mode 100644 index 0000000..2bade20 --- /dev/null +++ b/modules/chanserv/ban.rs @@ -0,0 +1,22 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// BAN <#channel> [reason]: ban *!*@host and kick the user. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) { + let (Some(&chan), Some(&nick)) = (args.get(1), args.get(2)) else { + ctx.notice(me, from.uid, "Syntax: BAN <#channel> [reason]"); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + let Some(target) = net.uid_by_nick(nick).map(str::to_string) else { + ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here.")); + return; + }; + let host = net.host_of(&target).unwrap_or("*"); + ctx.channel_mode(me, chan, &format!("+b *!*@{host}")); + let reason = if args.len() > 3 { args[3..].join(" ") } else { "Banned".to_string() }; + ctx.kick(me, chan, &target, &reason); +} diff --git a/modules/chanserv/chanserv.rs b/modules/chanserv/chanserv.rs index 623d034..28501ce 100644 --- a/modules/chanserv/chanserv.rs +++ b/modules/chanserv/chanserv.rs @@ -1,10 +1,19 @@ use crate::engine::db::{ChanError, ChannelInfo, Db}; use crate::engine::service::{Sender, Service, ServiceCtx}; +use crate::engine::state::Network; #[path = "mode.rs"] mod mode; #[path = "access.rs"] mod access; +#[path = "op.rs"] +mod op; +#[path = "kick.rs"] +mod kick; +#[path = "ban.rs"] +mod ban; +#[path = "unban.rs"] +mod unban; pub struct ChanServ { pub uid: String, @@ -24,7 +33,7 @@ impl Service for ChanServ { true } - fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &mut Db) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { Some("REGISTER") => { @@ -130,13 +139,35 @@ impl Service for ChanServ { } Some("MODE") => mode::handle(me, from, args, ctx, db), Some("ACCESS") => access::handle(me, from, args, ctx, db), - Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02 <#channel>, \x02INFO\x02 <#channel>, \x02ACCESS\x02 <#channel>, \x02MODE\x02 <#channel> , \x02MLOCK\x02 <#channel> [modes], \x02DROP\x02 <#channel>."), + Some("OP") => op::handle(me, from, "+o", args, ctx, net, db), + Some("DEOP") => op::handle(me, from, "-o", args, ctx, net, db), + Some("VOICE") => op::handle(me, from, "+v", args, ctx, net, db), + Some("DEVOICE") => op::handle(me, from, "-v", args, ctx, net, db), + Some("KICK") => kick::handle(me, from, args, ctx, net, db), + Some("BAN") => ban::handle(me, from, args, ctx, net, db), + Some("UNBAN") => unban::handle(me, from, args, ctx, net, db), + Some("HELP") => ctx.notice(me, from.uid, "ChanServ registers and looks after channels. Commands: \x02REGISTER\x02, \x02INFO\x02, \x02ACCESS\x02, \x02OP\x02/\x02DEOP\x02, \x02VOICE\x02/\x02DEVOICE\x02, \x02KICK\x02, \x02BAN\x02/\x02UNBAN\x02, \x02MODE\x02, \x02MLOCK\x02, \x02DROP\x02 — each takes a <#channel>."), Some(other) => ctx.notice(me, from.uid, format!("I don't know the command \x02{other}\x02. Try \x02HELP\x02.")), None => {} } } } +// True if `from` is the founder or an access-list op of `chan`; else notices why. +fn require_op(me: &str, from: &Sender, chan: &str, ctx: &mut ServiceCtx, db: &Db) -> bool { + match (from.account, db.channel(chan)) { + (_, None) => { + ctx.notice(me, from.uid, format!("\x02{chan}\x02 isn't registered.")); + false + } + (Some(acc), Some(info)) if info.is_op(acc) => true, + _ => { + ctx.notice(me, from.uid, format!("You need operator access to \x02{chan}\x02.")); + false + } + } +} + // Parse a mode spec like "+nt-s" into (locked-on, locked-off) mode chars. `r` is // implicit for a registered channel and is ignored here. fn parse_mlock(spec: &str) -> (String, String) { diff --git a/modules/chanserv/kick.rs b/modules/chanserv/kick.rs new file mode 100644 index 0000000..b3b8808 --- /dev/null +++ b/modules/chanserv/kick.rs @@ -0,0 +1,20 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// KICK <#channel> [reason]: kick a user. +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) { + let (Some(&chan), Some(&nick)) = (args.get(1), args.get(2)) else { + ctx.notice(me, from.uid, "Syntax: KICK <#channel> [reason]"); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + let Some(target) = net.uid_by_nick(nick) else { + ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here.")); + return; + }; + let reason = if args.len() > 3 { args[3..].join(" ") } else { "Kicked".to_string() }; + ctx.kick(me, chan, target, &reason); +} diff --git a/modules/chanserv/op.rs b/modules/chanserv/op.rs new file mode 100644 index 0000000..0db1547 --- /dev/null +++ b/modules/chanserv/op.rs @@ -0,0 +1,26 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// OP/DEOP/VOICE/DEVOICE <#channel> [nick]: set a status mode on a user (self if +// no nick given). `mode` is the mode to apply, e.g. "+o". +pub fn handle(me: &str, from: &Sender, mode: &str, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: OP/DEOP/VOICE/DEVOICE <#channel> [nick]"); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + let target = match args.get(2) { + Some(&nick) => match net.uid_by_nick(nick) { + Some(u) => u.to_string(), + None => { + ctx.notice(me, from.uid, format!("\x02{nick}\x02 isn't here.")); + return; + } + }, + None => from.uid.to_string(), + }; + ctx.channel_mode(me, chan, &format!("{mode} {target}")); +} diff --git a/modules/chanserv/unban.rs b/modules/chanserv/unban.rs new file mode 100644 index 0000000..0a2965f --- /dev/null +++ b/modules/chanserv/unban.rs @@ -0,0 +1,22 @@ +use crate::engine::db::Db; +use crate::engine::service::{Sender, ServiceCtx}; +use crate::engine::state::Network; + +// UNBAN <#channel> [nick]: remove the *!*@host ban of a user (self if no nick). +pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &Db) { + let Some(&chan) = args.get(1) else { + ctx.notice(me, from.uid, "Syntax: UNBAN <#channel> [nick]"); + return; + }; + if !super::require_op(me, from, chan, ctx, db) { + return; + } + let target = args + .get(2) + .and_then(|&n| net.uid_by_nick(n)) + .map(str::to_string) + .unwrap_or_else(|| from.uid.to_string()); + let host = net.host_of(&target).unwrap_or("*"); + ctx.channel_mode(me, chan, &format!("-b *!*@{host}")); + ctx.notice(me, from.uid, format!("Cleared the *!*@{host} ban on \x02{chan}\x02.")); +} diff --git a/modules/nickserv/nickserv.rs b/modules/nickserv/nickserv.rs index 2d91474..fb2af92 100644 --- a/modules/nickserv/nickserv.rs +++ b/modules/nickserv/nickserv.rs @@ -1,5 +1,6 @@ use crate::engine::db::{CertError, Db}; use crate::engine::service::{Sender, Service, ServiceCtx}; +use crate::engine::state::Network; use crate::proto::RegReply; pub struct NickServ { @@ -21,7 +22,7 @@ impl Service for NickServ { "Nickname Services" } - fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db) { + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, _net: &Network, db: &mut Db) { let me = self.uid.as_str(); match args.first().map(|s| s.to_ascii_uppercase()).as_deref() { Some("REGISTER") => { diff --git a/modules/protocol/inspircd.rs b/modules/protocol/inspircd.rs index 04aca96..5a553c1 100644 --- a/modules/protocol/inspircd.rs +++ b/modules/protocol/inspircd.rs @@ -72,11 +72,14 @@ impl Protocol for InspIrcd { } // UID … — track who's online so services can // resolve a sender's current nick. + // UID … — take the + // displayed host for ban masks. "UID" => { let a: Vec<&str> = tokens.collect(); match (a.first(), a.get(2)) { (Some(uid), Some(nick)) => { - vec![NetEvent::UserConnect { uid: uid.to_string(), nick: nick.to_string() }] + let host = a.get(4).unwrap_or(&"").to_string(); + vec![NetEvent::UserConnect { uid: uid.to_string(), nick: nick.to_string(), host }] } _ => vec![], } @@ -230,6 +233,9 @@ impl Protocol for InspIrcd { vec![format!(":{} {}", from, cmd)] } } + NetAction::Kick { from, channel, uid, reason } => { + vec![format!(":{} KICK {} {} :{}", from, channel, uid, reason)] + } NetAction::Raw(s) => vec![s.clone()], // Internal: the link layer handles this before serialization. NetAction::DeferRegister { .. } => vec![], @@ -273,7 +279,7 @@ mod tests { fn parses_uid_burst() { let ev = proto().parse(":0IR UID 0IRAAAAAB 1783833000 alice host host user user 0.0.0.0 1783833000 +i :real"); assert!( - matches!(ev.as_slice(), [NetEvent::UserConnect { uid, nick }] if uid == "0IRAAAAAB" && nick == "alice"), + matches!(ev.as_slice(), [NetEvent::UserConnect { uid, nick, .. }] if uid == "0IRAAAAAB" && nick == "alice"), "{ev:?}" ); } diff --git a/modules/protocol/mod.rs b/modules/protocol/mod.rs index 0daa882..fefd072 100644 --- a/modules/protocol/mod.rs +++ b/modules/protocol/mod.rs @@ -10,7 +10,7 @@ pub enum NetEvent { EndBurst, Ping { token: String, from: Option }, Privmsg { from: String, to: String, text: String }, - UserConnect { uid: String, nick: String }, + UserConnect { uid: String, nick: String, host: String }, NickChange { uid: String, nick: String }, // A channel was created or bursted (InspIRCd FJOIN). Subsequent single joins // arrive as IJOIN and are not surfaced. @@ -50,6 +50,8 @@ pub enum NetAction { // the pseudoclient uid to source it from (empty = the services server). The // protocol stamps a timestamp the ircd will accept. ChannelMode { from: String, channel: String, modes: String }, + // Kick a user from a channel, sourced from pseudoclient `from`. + Kick { from: String, channel: String, uid: String, reason: 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/engine/db.rs b/src/engine/db.rs index 96769bf..7ffd7f9 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -80,6 +80,11 @@ impl ChannelInfo { .map(|a| if a.level == "voice" { "+v" } else { "+o" }) } + /// Whether `account` has operator access (founder or access-list op). + pub fn is_op(&self, account: &str) -> bool { + self.join_mode(account) == Some("+o") + } + /// The mode string services keep applied: +r plus the lock. pub fn lock_modes(&self) -> String { let mut s = format!("+r{}", self.lock_on); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 8c8e501..4b03921 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -165,8 +165,8 @@ impl Engine { pub fn handle(&mut self, event: NetEvent) -> Vec { let out = match event { NetEvent::Ping { token, from } => vec![NetAction::Pong { token, from }], - NetEvent::UserConnect { uid, nick } => { - self.network.user_connect(uid, nick); + NetEvent::UserConnect { uid, nick, host } => { + self.network.user_connect(uid, nick, host); Vec::new() } NetEvent::NickChange { uid, nick } => { @@ -427,11 +427,11 @@ impl Engine { let account = self.network.account_of(from).map(str::to_string); let mut ctx = ServiceCtx::default(); let sender = Sender { uid: from, nick: &nick, account: account.as_deref() }; - let Self { services, db, .. } = self; + let Self { services, network, db, .. } = self; for svc in services.iter_mut() { if to.eq_ignore_ascii_case(svc.uid()) || to.eq_ignore_ascii_case(svc.nick()) { let args: Vec<&str> = text.split_whitespace().collect(); - svc.on_command(&sender, &args, &mut ctx, db); + svc.on_command(&sender, &args, &mut ctx, network, db); break; } } @@ -780,7 +780,7 @@ mod tests { #[test] fn nickserv_cert_add_enables_external() { let mut e = engine_with("nscert", "foo", "sesame"); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() }); let fp = "aabbccddeeff00112233445566778899"; let out = e.handle(NetEvent::Privmsg { @@ -798,7 +798,7 @@ mod tests { #[test] fn cert_add_is_guarded_and_unique() { let mut e = engine_with("certguard", "foo", "sesame"); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() }); let fp = "aabbccddeeff00112233445566778899"; let bad = e.handle(NetEvent::Privmsg { @@ -818,7 +818,7 @@ mod tests { #[test] fn nickserv_logout_clears_account() { let mut e = engine_with("nslogout", "foo", "sesame"); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() }); let login = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), @@ -845,7 +845,7 @@ mod tests { #[test] fn logout_without_login_is_noop() { let mut e = engine_with("nologin", "foo", "sesame"); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "someone".into(), host: "host.example".into() }); let out = logout(&mut e, "000AAAAAB"); assert!(out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("not logged in"))), "{out:?}"); assert!(!out.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "must not rename when not logged in: {out:?}"); @@ -856,7 +856,7 @@ mod tests { #[test] fn logout_twice_renames_only_once() { let mut e = engine_with("twice", "foo", "sesame"); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); let first = logout(&mut e, "000AAAAAB"); @@ -875,7 +875,7 @@ mod tests { sasl(&mut e, "S", "PLAIN"); let ok = sasl(&mut e, "C", &plain(b"", b"foo", b"sesame")); assert!(is_success(&ok), "{ok:?}"); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() }); let out = logout(&mut e, "000AAAAAB"); assert!(out.iter().any(|a| matches!(a, NetAction::ForceNick { .. })), "sasl-authed user can log out: {out:?}"); @@ -886,7 +886,7 @@ mod tests { #[test] fn identify_twice_does_not_relogin() { let mut e = engine_with("reident", "foo", "sesame"); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "foo".into(), host: "host.example".into() }); let ident = |e: &mut Engine| e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into(), }); @@ -922,7 +922,7 @@ mod tests { #[test] fn identify_uses_current_nick_after_rename() { let mut e = engine_with("renameident", "realnick", "sesame"); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "Guest99999".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "Guest99999".into(), host: "host.example".into() }); e.handle(NetEvent::NickChange { uid: "000AAAAAB".into(), nick: "realnick".into() }); let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into(), @@ -950,7 +950,7 @@ mod tests { let notice = |out: &[NetAction], needle: &str| { out.iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains(needle))) }; - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "host.example".into() }); // Not identified yet: refused. assert!(notice(&to_cs(&mut e, "000AAAAAB", "REGISTER #room"), "logged in")); @@ -977,7 +977,7 @@ mod tests { assert!(notice(&to_cs(&mut e, "000AAAAAB", "ACCESS #room"), "helper")); // A different, unidentified user cannot change modes, access, the lock, or drop it. - e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "host.example".into() }); assert!(notice(&to_cs(&mut e, "000AAAAAC", "MODE #room +i"), "founder")); assert!(notice(&to_cs(&mut e, "000AAAAAC", "ACCESS #room ADD x op"), "founder")); assert!(notice(&to_cs(&mut e, "000AAAAAC", "MLOCK #room +i"), "founder")); @@ -989,6 +989,50 @@ mod tests { assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#room" && modes == "-r")), "drop clears +r: {out:?}"); } + // ChanServ moderation: an op can op/kick/ban users; a non-op is refused. + #[test] + fn chanserv_moderation() { + use crate::chanserv::ChanServ; + let path = std::env::temp_dir().join("fedserv-cs-mod.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("#m", "alice").unwrap(); + let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; + let cs = ChanServ { uid: "42SAAAAAB".into() }; + let mut e = Engine::new(vec![Box::new(ns), Box::new(cs)], db); + + let to_cs = |e: &mut Engine, from: &str, text: &str| { + e.handle(NetEvent::Privmsg { from: from.into(), to: "42SAAAAAB".into(), text: text.into() }) + }; + // Founder alice identifies; target bob connects with a known host. + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "h1".into() }); + e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "1.2.3.4".into() }); + + // OP a user by nick. + let out = to_cs(&mut e, "000AAAAAB", "OP #m bob"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#m" && modes == "+o 000AAAAAC")), "{out:?}"); + + // KICK a user by nick, with a reason. + let out = to_cs(&mut e, "000AAAAAB", "KICK #m bob rude"); + assert!(out.iter().any(|a| matches!(a, NetAction::Kick { channel, uid, reason, .. } if channel == "#m" && uid == "000AAAAAC" && reason == "rude")), "{out:?}"); + + // BAN sets +b *!*@host and kicks. + let out = to_cs(&mut e, "000AAAAAB", "BAN #m bob spam"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#m" && modes == "+b *!*@1.2.3.4")), "{out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::Kick { uid, .. } if uid == "000AAAAAC")), "{out:?}"); + + // UNBAN clears it. + let out = to_cs(&mut e, "000AAAAAB", "UNBAN #m bob"); + assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#m" && modes == "-b *!*@1.2.3.4")), "{out:?}"); + + // A user with no access is refused. + e.handle(NetEvent::UserConnect { uid: "000AAAAAD".into(), nick: "carol".into(), host: "h2".into() }); + assert!(to_cs(&mut e, "000AAAAAD", "KICK #m alice").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("operator access")))); + } + // A registered channel (re)appearing re-asserts +r; an unregistered one does not. #[test] fn channel_create_reasserts_registered_mode() { @@ -1040,13 +1084,13 @@ mod tests { db.register_channel("#x", "alice").unwrap(); let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; let mut e = Engine::new(vec![Box::new(ns)], db); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "alice".into(), host: "host.example".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#x".into() }); assert!(out.iter().any(|a| matches!(a, NetAction::ChannelMode { channel, modes, .. } if channel == "#x" && modes == "+o 000AAAAAB")), "founder opped: {out:?}"); - e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAC".into(), nick: "bob".into(), host: "host.example".into() }); assert!(e.handle(NetEvent::Join { uid: "000AAAAAC".into(), channel: "#x".into() }).is_empty(), "non-founder not opped"); } @@ -1062,7 +1106,7 @@ mod tests { db.access_add("#y", "carol", "voice").unwrap(); let ns = NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 }; let mut e = Engine::new(vec![Box::new(ns)], db); - e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "carol".into() }); + e.handle(NetEvent::UserConnect { uid: "000AAAAAB".into(), nick: "carol".into(), host: "host.example".into() }); e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAA".into(), text: "IDENTIFY sesame".into() }); let out = e.handle(NetEvent::Join { uid: "000AAAAAB".into(), channel: "#y".into() }); diff --git a/src/engine/service.rs b/src/engine/service.rs index 6259143..4b1e488 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -1,4 +1,5 @@ use crate::engine::db::Db; +use crate::engine::state::Network; use crate::proto::{NetAction, RegReply}; // Who sent the command, resolved by the engine (UID + current nick + the @@ -23,7 +24,7 @@ pub trait Service: Send { fn manages_channels(&self) -> bool { false } - fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: &mut Db); + fn on_command(&mut self, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, net: &Network, db: &mut Db); } #[derive(Default)] @@ -88,4 +89,14 @@ impl ServiceCtx { modes: modes.to_string(), }); } + + // Kick a user, sourced from pseudoclient `from`. + pub fn kick(&mut self, from: &str, channel: &str, uid: &str, reason: &str) { + self.actions.push(NetAction::Kick { + from: from.to_string(), + channel: channel.to_string(), + uid: uid.to_string(), + reason: reason.to_string(), + }); + } } diff --git a/src/engine/state.rs b/src/engine/state.rs index dfdfcac..95972ed 100644 --- a/src/engine/state.rs +++ b/src/engine/state.rs @@ -12,6 +12,7 @@ pub struct Network { pub struct User { pub uid: String, pub nick: String, + pub host: String, } #[allow(dead_code)] @@ -21,8 +22,17 @@ pub struct Channel { } impl Network { - pub fn user_connect(&mut self, uid: String, nick: String) { - self.users.insert(uid.clone(), User { uid, nick }); + pub fn user_connect(&mut self, uid: String, nick: String, host: String) { + self.users.insert(uid.clone(), User { uid, nick, host }); + } + + // Resolve a nick to its uid (case-insensitive). + pub fn uid_by_nick(&self, nick: &str) -> Option<&str> { + self.users.values().find(|u| u.nick.eq_ignore_ascii_case(nick)).map(|u| u.uid.as_str()) + } + + pub fn host_of(&self, uid: &str) -> Option<&str> { + self.users.get(uid).map(|u| u.host.as_str()) } pub fn user_nick_change(&mut self, uid: &str, nick: String) {