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.
This commit is contained in:
parent
f0ce2d9d1b
commit
acd0e60946
13 changed files with 233 additions and 26 deletions
7
.gitignore
vendored
7
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
22
modules/chanserv/ban.rs
Normal file
22
modules/chanserv/ban.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
use crate::engine::db::Db;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::Network;
|
||||
|
||||
// BAN <#channel> <nick> [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> <nick> [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);
|
||||
}
|
||||
|
|
@ -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> <modes>, \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) {
|
||||
|
|
|
|||
20
modules/chanserv/kick.rs
Normal file
20
modules/chanserv/kick.rs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
use crate::engine::db::Db;
|
||||
use crate::engine::service::{Sender, ServiceCtx};
|
||||
use crate::engine::state::Network;
|
||||
|
||||
// KICK <#channel> <nick> [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> <nick> [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);
|
||||
}
|
||||
26
modules/chanserv/op.rs
Normal file
26
modules/chanserv/op.rs
Normal file
|
|
@ -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}"));
|
||||
}
|
||||
22
modules/chanserv/unban.rs
Normal file
22
modules/chanserv/unban.rs
Normal file
|
|
@ -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."));
|
||||
}
|
||||
|
|
@ -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") => {
|
||||
|
|
|
|||
|
|
@ -72,11 +72,14 @@ impl Protocol for InspIrcd {
|
|||
}
|
||||
// UID <uuid> <nickts> <nick> … — track who's online so services can
|
||||
// resolve a sender's current nick.
|
||||
// UID <uuid> <nickts> <nick> <realhost> <disphost> … — 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:?}"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ pub enum NetEvent {
|
|||
EndBurst,
|
||||
Ping { token: String, from: Option<String> },
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -165,8 +165,8 @@ impl Engine {
|
|||
pub fn handle(&mut self, event: NetEvent) -> Vec<NetAction> {
|
||||
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() });
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue