From 73467b91cac02604113e0b491e4bc2cb6cef4cb3 Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 17 Jul 2026 16:52:00 +0000 Subject: [PATCH] botserv: bots join channels as protected admins (+a) so ordinary ops can't kick or deop them --- api/src/lib.rs | 6 ++++-- modules/protocol/inspircd/src/lib.rs | 18 +++++++++--------- src/engine/mod.rs | 8 +++++--- src/engine/tests.rs | 14 +++++++------- 4 files changed, 25 insertions(+), 21 deletions(-) diff --git a/api/src/lib.rs b/api/src/lib.rs index 750277e..aa71ff8 100644 --- a/api/src/lib.rs +++ b/api/src/lib.rs @@ -117,8 +117,10 @@ pub enum NetAction { ForcePart { uid: String, channel: String, reason: String }, // Remove one of our pseudo-clients (e.g. a deleted bot) from the network. QuitUser { uid: String, reason: String }, - // A services pseudo-client (a bot) joins / parts a channel. - ServiceJoin { uid: String, channel: String }, + // A services pseudo-client (a bot) joins / parts a channel. `modes` are the + // status-mode letters it joins with: "a" (protected admin) for BotServ bots, + // "o" for the core service pseudo-clients in the services channel. + ServiceJoin { uid: String, channel: String, modes: String }, ServicePart { uid: String, channel: String }, // Set channel modes from services, e.g. +r on a registered channel. `from` is // the pseudoclient uid to source it from (empty = the services server). The diff --git a/modules/protocol/inspircd/src/lib.rs b/modules/protocol/inspircd/src/lib.rs index ae51d28..423182d 100644 --- a/modules/protocol/inspircd/src/lib.rs +++ b/modules/protocol/inspircd/src/lib.rs @@ -350,14 +350,14 @@ impl Protocol for InspIrcd { // SVSNICK — the new nick takes the current // time as its TS so it wins any collision resolution. NetAction::QuitUser { uid, reason } => vec![format!(":{} QUIT :{}", uid, reason)], - NetAction::ServiceJoin { uid, channel } => { + NetAction::ServiceJoin { uid, channel, modes } => { // IJOIN needs a membid (`IJOIN `); without it the // ircd rejects the whole link with "Insufficient parameters". The - // trailing "1 o" is the 4-param form (ts, status modes): the bot - // joins opped, matching Anope's default bot modes (+o). ts 1 keeps - // it below the channel TS so the op is always applied. + // trailing "1 " is the 4-param form (ts, status modes) — a + // BotServ bot joins "+a" (protected admin), core services "+o". ts 1 + // keeps it below the channel TS so the status is always applied. self.membid += 1; - vec![format!(":{} IJOIN {} {} 1 o", uid, channel, self.membid)] + vec![format!(":{} IJOIN {} {} 1 {}", uid, channel, self.membid, modes)] } NetAction::ServicePart { uid, channel } => vec![format!(":{} PART {}", uid, channel)], // ENCAP the target's server: CHGHOST , to set a vhost. @@ -745,10 +745,10 @@ mod tests { #[test] fn service_join_ijoin_includes_membid() { let mut p = proto(); - let a = p.serialize(&NetAction::ServiceJoin { uid: "00DB00000".into(), channel: "#taverne".into() }); - assert_eq!(a, vec![":00DB00000 IJOIN #taverne 1 1 o".to_string()], "joins opped with a membid"); - // membid is monotonic across joins - let b = p.serialize(&NetAction::ServiceJoin { uid: "00DB00000".into(), channel: "#quizz".into() }); + let a = p.serialize(&NetAction::ServiceJoin { uid: "00DB00000".into(), channel: "#taverne".into(), modes: "a".into() }); + assert_eq!(a, vec![":00DB00000 IJOIN #taverne 1 1 a".to_string()], "bot joins +a (protected) with a membid"); + // membid is monotonic across joins; core services join +o + let b = p.serialize(&NetAction::ServiceJoin { uid: "00DB00000".into(), channel: "#quizz".into(), modes: "o".into() }); assert_eq!(b, vec![":00DB00000 IJOIN #quizz 2 1 o".to_string()]); } } diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 18e4c03..063c30e 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -444,7 +444,9 @@ impl Engine { for (bot_lc, chan) in &desired { if !self.bot_channels.contains(&(bot_lc.clone(), chan.clone())) { if let Some(uid) = self.bot_uids.get(bot_lc) { - out.push(NetAction::ServiceJoin { uid: uid.clone(), channel: chan.clone() }); + // BotServ bots join as protected admins (+a) so ordinary ops + // can't kick or deop them — the standard bot default. + out.push(NetAction::ServiceJoin { uid: uid.clone(), channel: chan.clone(), modes: "a".into() }); self.bot_channels.insert((bot_lc.clone(), chan.clone())); } } @@ -1025,7 +1027,7 @@ impl Engine { } // All service pseudo-clients sit in the services channel (configurable). if !self.services_channel.is_empty() { - out.push(NetAction::ServiceJoin { uid: svc.uid().to_string(), channel: self.services_channel.clone() }); + out.push(NetAction::ServiceJoin { uid: svc.uid().to_string(), channel: self.services_channel.clone(), modes: "o".into() }); } } // Advertise our SASL mechanisms so the uplink can offer `sasl=PLAIN` to @@ -1036,7 +1038,7 @@ impl Engine { // already joined it above as the services channel. if let (false, Some(chan)) = (self.debugserv_uid.is_empty(), self.log_channel.clone()) { if !chan.eq_ignore_ascii_case(&self.services_channel) { - out.push(NetAction::ServiceJoin { uid: self.debugserv_uid.clone(), channel: chan }); + out.push(NetAction::ServiceJoin { uid: self.debugserv_uid.clone(), channel: chan, modes: "o".into() }); } } // Re-assert the network bans over the fresh link, each with its remaining diff --git a/src/engine/tests.rs b/src/engine/tests.rs index 4f1cfb9..5f10dea 100644 --- a/src/engine/tests.rs +++ b/src/engine/tests.rs @@ -546,7 +546,7 @@ e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "BOT ADD Bendy bot serv.host Helper".into() }); let out = e.handle(NetEvent::Privmsg { from: "000AAAAAB".into(), to: "42SAAAAAD".into(), text: "ASSIGN #room Bendy".into() }); let botuid = out.iter().find_map(|a| match a { - NetAction::ServiceJoin { uid, channel } if channel == "#room" => Some(uid.clone()), + NetAction::ServiceJoin { uid, channel, .. } if channel == "#room" => Some(uid.clone()), _ => None, }).expect("bot joins #room"); @@ -591,7 +591,7 @@ let _ = assign; let out = e.handle(NetEvent::Privmsg { from: "000AAAAAA".into(), to: "42SAAAAAD".into(), text: "ASSIGN #room Bendy".into() }); let botuid = out.iter().find_map(|a| match a { - NetAction::ServiceJoin { uid, channel } if channel == "#room" => Some(uid.clone()), + NetAction::ServiceJoin { uid, channel, .. } if channel == "#room" => Some(uid.clone()), _ => None, }).expect("bot joins #room"); @@ -2217,9 +2217,9 @@ ns(&mut e, "000AAAAAC", "IDENTIFY password1"); bs(&mut e, "000AAAAAC", "BOT ADD Bendy bot serv.host Helper"); // goes live - // The founder assigns the bot: it joins the channel. + // The founder assigns the bot: it joins the channel as a protected admin (+a). let out = bs(&mut e, "000AAAAAB", "ASSIGN #c Bendy"); - assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, .. } if channel == "#c")), "joins on assign: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { channel, modes, .. } if channel == "#c" && modes == "a")), "joins +a on assign: {out:?}"); // Unassigning parts it. let out = bs(&mut e, "000AAAAAB", "UNASSIGN #c"); assert!(out.iter().any(|a| matches!(a, NetAction::ServicePart { channel, .. } if channel == "#c")), "parts on unassign: {out:?}"); @@ -2254,13 +2254,13 @@ ns(&mut e, "IDENTIFY password1"); bs(&mut e, "BOT ADD Bendy bot serv.host Helper"); let bot_uid = bs(&mut e, "ASSIGN #c Bendy").iter().find_map(|a| match a { - NetAction::ServiceJoin { uid, channel } if channel == "#c" => Some(uid.clone()), + NetAction::ServiceJoin { uid, channel, .. } if channel == "#c" => Some(uid.clone()), _ => None, }).expect("bot joined on assign"); // The bot is kicked out (the ircd relays this as a PART for its uid). let out = e.handle(NetEvent::Part { uid: bot_uid.clone(), channel: "#c".into() }); - assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { uid, channel } if uid == &bot_uid && channel == "#c")), "kicked bot rejoins: {out:?}"); + assert!(out.iter().any(|a| matches!(a, NetAction::ServiceJoin { uid, channel, .. } if uid == &bot_uid && channel == "#c")), "kicked bot rejoins: {out:?}"); } // A KILL forgets a real user, but a killed services bot is reintroduced and @@ -2292,7 +2292,7 @@ ns(&mut e, "IDENTIFY password1"); bs(&mut e, "BOT ADD Bendy bot serv.host Helper"); let bot_uid = bs(&mut e, "ASSIGN #c Bendy").iter().find_map(|a| match a { - NetAction::ServiceJoin { uid, channel } if channel == "#c" => Some(uid.clone()), + NetAction::ServiceJoin { uid, channel, .. } if channel == "#c" => Some(uid.clone()), _ => None, }).expect("bot joined on assign");